From 3aa699b6b9c5a8a81348f24a87606e4a730f7610 Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:53:20 +0200 Subject: [PATCH 01/22] Bump app version and ignore remote config - Update MARKETING_VERSION from 1.6.10 to 1.6.15 across all Xcode targets - Add config.json to .gitignore for remote configuration files --- .gitignore | 3 +++ openclient-llm.xcodeproj/project.pbxproj | 16 ++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 5d37f6a0..7a47b8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Secrets.xcconfig # VS Code .vscode/ + +# Remote config +config.json diff --git a/openclient-llm.xcodeproj/project.pbxproj b/openclient-llm.xcodeproj/project.pbxproj index b35b914e..0e640cd5 100644 --- a/openclient-llm.xcodeproj/project.pbxproj +++ b/openclient-llm.xcodeproj/project.pbxproj @@ -751,7 +751,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -799,7 +799,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -857,7 +857,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm"; PRODUCT_NAME = OpenClient; REGISTER_APP_GROUPS = YES; @@ -913,7 +913,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm"; PRODUCT_NAME = OpenClient; REGISTER_APP_GROUPS = YES; @@ -990,7 +990,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1021,7 +1021,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1054,7 +1054,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1086,7 +1086,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.10; + MARKETING_VERSION = 1.6.15; PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.widgets"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From 72a9d880ab987bba01ba0d82bce7b4a2b84bdad2 Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:54:52 +0200 Subject: [PATCH 02/22] Remove default plain-language prompt restriction - Delete the system prompt text that discouraged structured response formats unless requested - Update chat view model tests to expect the effective prompt without the removed default text - Document the change in the changelog for build 68 --- CHANGELOG.md | 6 ++++++ .../Features/Chat/ChatViewModelTests+UserProfile.swift | 8 ++------ .../Features/Chat/ViewModels/ChatViewModel+Helpers.swift | 7 ------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0d0663..fb8bd322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +## [1.6.15-build-68] - 2026-08-10 + +### Removed + +- Default system prompt restriction that discouraged structured response formats unless explicitly requested + ## [1.6.10-build-67] - 2026-08-09 ### Added diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+UserProfile.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+UserProfile.swift index efc2b76a..3d0b0efa 100644 --- a/openclient-llm-test/Features/Chat/ChatViewModelTests+UserProfile.swift +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+UserProfile.swift @@ -61,9 +61,7 @@ final class ChatViewModelUserProfileTests: XCTestCase { memoryContext: "", conversationSystemPrompt: "" ) - XCTAssertTrue(result.contains("Respond in plain, natural language.")) - XCTAssertFalse(result.contains("background information")) - XCTAssertFalse(result.contains("previous conversations")) + XCTAssertEqual(result, "") } func test_buildEffectiveSystemPrompt_onlyProfile_returnsProfile() { @@ -83,9 +81,7 @@ final class ChatViewModelUserProfileTests: XCTestCase { memoryContext: "", conversationSystemPrompt: "You are a coding assistant." ) - XCTAssertTrue(result.contains("You are a coding assistant.")) - XCTAssertTrue(result.contains("Respond in plain, natural language.")) - XCTAssertFalse(result.contains("background information")) + XCTAssertEqual(result, "You are a coding assistant.") } func test_buildEffectiveSystemPrompt_both_combineWithNewlines() { diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift index 301954ed..5f44478a 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift @@ -116,13 +116,6 @@ extension ChatViewModel { var parts: [String] = [] - parts.append(""" - Respond in plain, natural language. \ - Never output raw JSON, XML, or other structured data formats in your responses \ - unless the user explicitly asks for it \ - (e.g. "give me a JSON", "format as JSON", "return structured data"). - """) - if !profile.isEmpty { parts.append(""" The following is background information about the user. \ From 2828664ecc21b8490228edeb282caa9233034f6d Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:09:43 +0200 Subject: [PATCH 03/22] Add iCloud sync observer and regression tests - Document iCloud sync instructions in AGENTS.md - Update README version badge to 1.6.15 - Add TestFlight release notes for 1.6.15 - Start the conversation cloud observer on macOS launch and activation - Add cloud sync regression tests for profiles, conversations, memory, and templates - Add observer tests for metadata readiness and synchronization control - Refresh remote config tests to expect version 1.6.15 - Add user profile manager tests for cloud-backed profile handling --- AGENTS.md | 1 + README.md | 2 +- TestFlight/WhatToTest.en-EN.txt | 27 + openclient-llm-macOS/App/AppDelegate.swift | 6 + .../CloudSyncManagerCategoryTests.swift | 288 + .../CloudSyncManagerConversationTests.swift | 495 + .../ConversationCloudObserverTests.swift | 176 + .../MemoryManagerCloudDeletionTests.swift | 264 + .../Managers/RemoteConfigManagerTests.swift | 14 +- .../Managers/UserProfileManagerTests.swift | 122 + .../Core/Models/CloudSyncManifestTests.swift | 99 + .../Chat/AttachmentRepositoryTests.swift | 147 + .../Chat/BranchConversationUseCaseTests.swift | 120 +- .../Chat/ChatViewModelTests+Branching.swift | 19 +- .../ChatViewModelTests+ImageGeneration.swift | 3 +- .../ChatViewModelTests+ImagePreparation.swift | 14 +- .../Chat/ChatViewModelTests+Persistence.swift | 7 +- .../ChatViewModelTests+SyncPersistence.swift | 252 + .../Features/Chat/ChatViewModelTests.swift | 15 + .../ConversationAttachmentMutationTests.swift | 291 + .../Chat/ConversationLegacySyncTests.swift | 479 + ...onversationListViewModelTests+Backup.swift | 16 + ...ersationListViewModelTests+CloudSync.swift | 19 +- ...nversationListViewModelTests+Pinning.swift | 8 + ...onversationListViewModelTests+Rename.swift | 14 + .../ConversationListViewModelTests+Tags.swift | 9 + .../Chat/ConversationListViewModelTests.swift | 37 +- .../ConversationLocalTransactionTests.swift | 176 + ...positorySyncTests+DeletionVisibility.swift | 56 + ...rsationRepositorySyncTests+Mutations.swift | 479 + .../ConversationRepositorySyncTests.swift | 260 +- .../ConversationSyncCoordinatorTests.swift | 284 + .../Chat/DeleteConversationUseCaseTests.swift | 17 +- .../Features/Chat/DeleteMemoryToolTests.swift | 17 + .../ExportConversationsUseCaseTests.swift | 89 +- .../ImportConversationsUseCaseTests.swift | 204 +- .../Chat/LoadConversationsUseCaseTests.swift | 4 +- .../Chat/PinConversationUseCaseTests.swift | 34 +- .../Features/Chat/SaveMemoryToolTests.swift | 15 + .../UpdateConversationTagsUseCaseTests.swift | 4 +- .../Features/Chat/WidgetSnapshotTests.swift | 4 +- .../AttachmentMigrationUseCaseTests.swift | 205 +- .../Launch/LaunchRemoteBannerTests.swift | 2 +- .../Launch/LaunchViewModelTests.swift | 38 +- .../Launch/ResetAppDataUseCaseTests.swift | 81 +- .../PromptTemplateRepositoryCloudTests.swift | 206 + .../PromptTemplatesViewModelTests.swift | 48 +- .../Settings/MemoryViewModelTests.swift | 72 +- .../SettingsViewModelTests+CloudSync.swift | 105 +- .../SettingsViewModelTests+Helpers.swift | 25 + .../Settings/SettingsViewModelTests.swift | 119 +- .../SynchronizeAppDataUseCaseTests.swift | 105 + .../Features/Settings/UserProfileTests.swift | 11 + .../Settings/UserProfileViewModelTests.swift | 23 +- .../Mocks/MockAttachmentRepository.swift | 12 +- .../Mocks/MockBranchConversationUseCase.swift | 2 +- .../Mocks/MockCloudSyncManager.swift | 180 +- .../Mocks/MockConversationCloudObserver.swift | 19 - .../Mocks/MockConversationRepository.swift | 66 +- .../Mocks/MockDeleteConversationUseCase.swift | 2 +- .../MockDeletePromptTemplateUseCase.swift | 2 +- .../Mocks/MockExportBackupUseCase.swift | 2 +- .../MockImportConversationsUseCase.swift | 2 +- .../Mocks/MockLoadConversationsUseCase.swift | 4 +- .../MockLoadPromptTemplatesUseCase.swift | 2 +- .../Mocks/MockMemoryManager.swift | 21 +- .../Mocks/MockPinConversationUseCase.swift | 2 +- .../Mocks/MockPromptTemplateRepository.swift | 8 +- .../Mocks/MockRemoteConfigManager.swift | 2 +- .../Mocks/MockRenameConversationUseCase.swift | 2 +- .../Mocks/MockResetAppDataUseCase.swift | 4 +- .../Mocks/MockSaveConversationUseCase.swift | 23 +- .../Mocks/MockSavePromptTemplateUseCase.swift | 2 +- .../Mocks/MockSyncConversationsUseCase.swift | 9 +- .../Mocks/MockSynchronizeAppDataUseCase.swift | 33 + .../MockUpdateConversationTagsUseCase.swift | 2 +- .../Mocks/MockUserProfileManager.swift | 26 +- openclient-llm-test/Mocks/TestAsyncGate.swift | 34 + openclient-llm/App/AppDelegate.swift | 6 + .../Foundation/Notification.Name.swift | 2 +- .../Managers/CloudCategoryOperationGate.swift | 84 + .../Managers/CloudContainerProvider.swift | 111 + .../Core/Managers/CloudFileCoordinator.swift | 84 + .../Managers/CloudMetadataReadiness.swift | 36 + .../Shared/Core/Managers/CloudSyncError.swift | 50 + .../CloudSyncManager+Availability.swift | 21 + .../CloudSyncManager+ConversationSync.swift | 493 + .../CloudSyncManager+DeleteAllMarker.swift | 26 - .../Managers/CloudSyncManager+Functions.swift | 227 +- .../Core/Managers/CloudSyncManager.swift | 534 +- .../Managers/ConversationCloudObserver.swift | 391 +- .../Shared/Core/Managers/LogManager.swift | 23 +- .../Shared/Core/Managers/MemoryManager.swift | 367 +- .../Core/Managers/SettingsManager.swift | 6 + .../Core/Managers/UserProfileManager.swift | 268 +- .../Core/Models/CloudDeletionMarker.swift | 14 + .../Core/Models/CloudSyncManifest.swift | 59 + .../Shared/Core/Models/CloudSyncStatus.swift | 50 + .../Shared/Core/Models/SyncJSONCoding.swift | 36 + .../Features/Chat/Models/ChatMessage.swift | 17 +- .../Features/Chat/Models/Conversation.swift | 6 +- .../ConversationCloudSyncSnapshot.swift | 105 + .../Models/ConversationDeleteAllMarker.swift | 2 +- .../ConversationSyncOperationError.swift | 39 + .../Chat/Models/ConversationSyncResult.swift | 2 +- .../Chat/Models/ConversationTag.swift | 2 +- .../Chat/Models/ConversationTombstone.swift | 2 +- .../Chat/Models/DeleteMemoryTool.swift | 2 +- .../Chat/Models/ModelParameters.swift | 2 +- .../Features/Chat/Models/SaveMemoryTool.swift | 2 +- .../Features/Chat/Models/TagColor.swift | 2 +- .../Features/Chat/Models/TokenUsage.swift | 2 +- .../Repositories/AttachmentFileResolver.swift | 63 + .../Repositories/AttachmentRepository.swift | 92 +- .../ConversationLocalTransaction.swift | 386 + .../Repositories/ConversationRebaser.swift | 213 + .../Repositories/ConversationRepository.swift | 455 +- ...ationStorage+AttachmentNormalization.swift | 143 + ...ConversationStorage+DeletionMetadata.swift | 235 + .../ConversationStorage+ImportBatch.swift | 49 + ...ConversationStorage+LocalPersistence.swift | 468 + .../ConversationStorage+Merge.swift | 191 + .../ConversationStorage+Mutations.swift | 452 + ...ConversationStorage+PendingMutations.swift | 78 + .../Repositories/ConversationStorage.swift | 489 + .../ConversationSyncCoordinator.swift | 285 + .../UseCases/AttachmentMigrationUseCase.swift | 248 +- .../UseCases/BranchConversationUseCase.swift | 66 +- .../UseCases/DeleteConversationUseCase.swift | 6 +- .../Chat/UseCases/ExportBackupUseCase.swift | 6 +- .../UseCases/ExportConversationsUseCase.swift | 11 +- .../UseCases/ImportConversationsUseCase.swift | 119 +- .../UseCases/LoadConversationsUseCase.swift | 12 +- .../UseCases/PinConversationUseCase.swift | 10 +- .../UseCases/RenameConversationUseCase.swift | 10 +- .../UseCases/SaveConversationUseCase.swift | 25 +- .../UseCases/SyncConversationsUseCase.swift | 11 +- .../UpdateConversationTagsUseCase.swift | 22 +- .../Chat/ViewModels/ChatViewModel+Agent.swift | 6 +- .../ChatViewModel+Attachments.swift | 39 +- .../ViewModels/ChatViewModel+EditExport.swift | 36 +- .../ViewModels/ChatViewModel+Helpers.swift | 215 +- .../ChatViewModel+ImageGeneration.swift | 4 +- .../ViewModels/ChatViewModel+Message.swift | 1 + .../ViewModels/ChatViewModel+Streaming.swift | 4 +- .../Chat/ViewModels/ChatViewModel.swift | 51 +- .../ConversationListViewModel+Filter.swift | 33 + .../ConversationListViewModel+Observe.swift | 17 +- .../ConversationListViewModel.swift | 144 +- .../Home/ViewModels/HomeViewModel.swift | 2 +- .../Launch/UseCases/ResetAppDataUseCase.swift | 21 +- .../Launch/ViewModels/LaunchViewModel.swift | 25 +- .../Features/Launch/Views/LaunchView.swift | 22 +- .../Models/PromptTemplate.swift | 25 +- .../PromptTemplateRepository.swift | 259 +- .../DeletePromptTemplateUseCase.swift | 6 +- .../UseCases/LoadPromptTemplatesUseCase.swift | 6 +- .../UseCases/SavePromptTemplateUseCase.swift | 6 +- .../ViewModels/PromptTemplatesViewModel.swift | 61 +- .../Models/AppSynchronizationResult.swift | 36 + .../Models/CloudUserProfileState.swift | 15 + .../Features/Settings/Models/MemoryItem.swift | 38 +- .../Settings/Models/UserProfile.swift | 19 +- .../UseCases/SynchronizeAppDataUseCase.swift | 120 + .../Settings/ViewModels/MemoryViewModel.swift | 56 +- .../SettingsViewModel+CloudSync.swift | 159 + .../ViewModels/SettingsViewModel+Events.swift | 95 + .../ViewModels/SettingsViewModel.swift | 187 +- .../ViewModels/UserProfileViewModel.swift | 24 +- .../Features/Settings/Views/MemoryView.swift | 16 +- .../Views/SettingsView+CloudSync.swift | 126 + .../Settings/Views/SettingsView.swift | 76 +- .../Shared/Resources/Localizable.xcstrings | 30359 ++++++++-------- specs/icloud-sync.instructions.md | 197 + specs/roadmap.instructions.md | 12 +- 175 files changed, 28632 insertions(+), 17192 deletions(-) create mode 100644 TestFlight/WhatToTest.en-EN.txt create mode 100644 openclient-llm-test/Core/Managers/CloudSyncManagerCategoryTests.swift create mode 100644 openclient-llm-test/Core/Managers/CloudSyncManagerConversationTests.swift create mode 100644 openclient-llm-test/Core/Managers/ConversationCloudObserverTests.swift create mode 100644 openclient-llm-test/Core/Managers/MemoryManagerCloudDeletionTests.swift create mode 100644 openclient-llm-test/Core/Managers/UserProfileManagerTests.swift create mode 100644 openclient-llm-test/Core/Models/CloudSyncManifestTests.swift create mode 100644 openclient-llm-test/Features/Chat/AttachmentRepositoryTests.swift create mode 100644 openclient-llm-test/Features/Chat/ChatViewModelTests+SyncPersistence.swift create mode 100644 openclient-llm-test/Features/Chat/ConversationAttachmentMutationTests.swift create mode 100644 openclient-llm-test/Features/Chat/ConversationLegacySyncTests.swift create mode 100644 openclient-llm-test/Features/Chat/ConversationLocalTransactionTests.swift create mode 100644 openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+DeletionVisibility.swift create mode 100644 openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+Mutations.swift create mode 100644 openclient-llm-test/Features/Chat/ConversationSyncCoordinatorTests.swift create mode 100644 openclient-llm-test/Features/PromptTemplates/PromptTemplateRepositoryCloudTests.swift create mode 100644 openclient-llm-test/Features/Settings/SettingsViewModelTests+Helpers.swift create mode 100644 openclient-llm-test/Features/Settings/SynchronizeAppDataUseCaseTests.swift delete mode 100644 openclient-llm-test/Mocks/MockConversationCloudObserver.swift create mode 100644 openclient-llm-test/Mocks/MockSynchronizeAppDataUseCase.swift create mode 100644 openclient-llm-test/Mocks/TestAsyncGate.swift create mode 100644 openclient-llm/Shared/Core/Managers/CloudCategoryOperationGate.swift create mode 100644 openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift create mode 100644 openclient-llm/Shared/Core/Managers/CloudFileCoordinator.swift create mode 100644 openclient-llm/Shared/Core/Managers/CloudMetadataReadiness.swift create mode 100644 openclient-llm/Shared/Core/Managers/CloudSyncError.swift create mode 100644 openclient-llm/Shared/Core/Managers/CloudSyncManager+Availability.swift create mode 100644 openclient-llm/Shared/Core/Managers/CloudSyncManager+ConversationSync.swift delete mode 100644 openclient-llm/Shared/Core/Managers/CloudSyncManager+DeleteAllMarker.swift create mode 100644 openclient-llm/Shared/Core/Models/CloudDeletionMarker.swift create mode 100644 openclient-llm/Shared/Core/Models/CloudSyncManifest.swift create mode 100644 openclient-llm/Shared/Core/Models/CloudSyncStatus.swift create mode 100644 openclient-llm/Shared/Core/Models/SyncJSONCoding.swift create mode 100644 openclient-llm/Shared/Features/Chat/Models/ConversationCloudSyncSnapshot.swift create mode 100644 openclient-llm/Shared/Features/Chat/Models/ConversationSyncOperationError.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/AttachmentFileResolver.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationLocalTransaction.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationRebaser.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+AttachmentNormalization.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+DeletionMetadata.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+ImportBatch.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+LocalPersistence.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Merge.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Mutations.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+PendingMutations.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage.swift create mode 100644 openclient-llm/Shared/Features/Chat/Repositories/ConversationSyncCoordinator.swift create mode 100644 openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Filter.swift create mode 100644 openclient-llm/Shared/Features/Settings/Models/AppSynchronizationResult.swift create mode 100644 openclient-llm/Shared/Features/Settings/Models/CloudUserProfileState.swift create mode 100644 openclient-llm/Shared/Features/Settings/UseCases/SynchronizeAppDataUseCase.swift create mode 100644 openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+CloudSync.swift create mode 100644 openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+Events.swift create mode 100644 openclient-llm/Shared/Features/Settings/Views/SettingsView+CloudSync.swift create mode 100644 specs/icloud-sync.instructions.md diff --git a/AGENTS.md b/AGENTS.md index 52e7e6b4..b1362bef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Each specification must use the `.instructions.md` suffix and start with YAML fr | `concurrency.instructions.md` | Working with async code, isolation, or `Sendable`. | | `conversation-backup-format.instructions.md` | Exporting, importing, restoring, validating, or versioning conversation backups. | | `design-ui.instructions.md` | Designing general SwiftUI UI, accessibility, haptics, or animation. | +| `icloud-sync.instructions.md` | Implementing or changing iCloud synchronization, storage, conflict resolution, or cloud data management. | | `litellm-api.instructions.md` | Changing LiteLLM/OpenAI-compatible API integration. | | `readme.instructions.md` | Updating `README.md`. | | `roadmap.instructions.md` | Planning or prioritizing future work. | diff --git a/README.md b/README.md index fcdd2f5c..e83dbc66 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Platform Swift SwiftUI - Version 1.6.10 + Version 1.6.15 Xcode

diff --git a/TestFlight/WhatToTest.en-EN.txt b/TestFlight/WhatToTest.en-EN.txt new file mode 100644 index 00000000..73a3aa03 --- /dev/null +++ b/TestFlight/WhatToTest.en-EN.txt @@ -0,0 +1,27 @@ +Hi there! We've got some great new features for you in this update. + +***1.6.15: + +• You can now generate images directly in chat with supported models. +• We’ve added update notifications so you can easily keep the app up to date. +• The app can now display important announcements and news directly on the Home screen. +• We’ve improved how maintenance periods are handled, providing a clearer experience when the service is temporarily unavailable. +• RAW images selected from Photos are now processed correctly before being sent. +• Major improvements to iCloud conversation syncing, especially when switching between devices or returning to the app. +• Fixed several issues with conversations changed or deleted on other devices. +• General stability, reliability, and performance improvements. +• Minor bug fixes and improvements for a smoother experience. + +***Recent Updates: + +• New widgets. +• Assistant messages now render the full markdown toolkit: blockquotes, lists with nesting, task lists, horizontal rulers, code blocks with language labels, tables, and inline images all display natively in the chat. +• Steer conversations mid-stream: send a follow-up message while the model is still generating; the current stream is cancelled, the partial response is preserved, and your new message continues the conversation with full context. +• Connect MCP servers (Model Context Protocol) to give the model access to external tools like GitHub, databases, and more, all configured from your LiteLLM server. +• Browse your MCP servers in a dedicated sheet from the chat input bar or from Settings, then drill into each server to enable or disable individual tools with a master toggle. +• A new collapsible actions bar keeps the chat composer clean: attachment, web search, and MCP buttons are tucked behind a single + / × toggle, auto-collapsing after use or when sending a message. +• Contextual tips introduce MCP servers when the feature becomes available, following the same unobtrusive pattern as other feature tips. + +Thanks for your continued support and for helping us build the best possible LLM client together. + +Remember: you can suggest and vote on new features from the Feedback section in Settings. \ No newline at end of file diff --git a/openclient-llm-macOS/App/AppDelegate.swift b/openclient-llm-macOS/App/AppDelegate.swift index c80042a7..3b4a8111 100644 --- a/openclient-llm-macOS/App/AppDelegate.swift +++ b/openclient-llm-macOS/App/AppDelegate.swift @@ -18,6 +18,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var transactionObserverTask: Task? private let menuBarManager = MenuBarManager() + private let conversationCloudObserver = ConversationCloudObserver() // MARK: - NSApplicationDelegate @@ -31,5 +32,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } } + conversationCloudObserver.start() + } + + func applicationDidBecomeActive(_ notification: Notification) { + conversationCloudObserver.start() } } diff --git a/openclient-llm-test/Core/Managers/CloudSyncManagerCategoryTests.swift b/openclient-llm-test/Core/Managers/CloudSyncManagerCategoryTests.swift new file mode 100644 index 00000000..30c3f6e3 --- /dev/null +++ b/openclient-llm-test/Core/Managers/CloudSyncManagerCategoryTests.swift @@ -0,0 +1,288 @@ +// +// CloudSyncManagerCategoryTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class CloudSyncManagerCategoryTests: XCTestCase { + // MARK: - Properties + + private var rootURL: URL! + private var documentsURL: URL! + + // MARK: - Setup + + override func setUp() async throws { + try await super.setUp() + rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + documentsURL = rootURL.appendingPathComponent("Documents", isDirectory: true) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: rootURL) + rootURL = nil + documentsURL = nil + try await super.tearDown() + } + + // MARK: - Tests + + func test_readCoordinator_calledFromMainActor_runsAccessorOffMainThread() async throws { + // Given + let fileURL = rootURL.appendingPathComponent("Coordinated.json") + try Data().write(to: fileURL) + + // When + let accessorRanOnMainThread = try await CloudFileCoordinator().read(at: fileURL) { _ in + Thread.isMainThread + } + + // Then + XCTAssertFalse(accessorRanOnMainThread) + } + + func test_saveProfile_accountChangesDuringCoordination_performsNoWrite() async throws { + // Given + let provider = SwitchingCloudContainerProvider(url: rootURL) + let sut = CloudSyncManager(containerProvider: provider) + + // When + do { + try await sut.saveProfileToCloud(UserProfile(name: "Local")) + XCTFail("Expected identity change") + } catch { + XCTAssertEqual(error as? CloudSyncError, .containerIdentityChanged) + } + + // Then + XCTAssertFalse(FileManager.default.fileExists( + atPath: documentsURL.appendingPathComponent("UserProfile.json").path + )) + } + + func test_loadProfile_placeholderPresent_reportsPendingInsteadOfMissing() async throws { + // Given + let placeholderURL = documentsURL.appendingPathComponent(".UserProfile.json.icloud") + try Data().write(to: placeholderURL) + let sut = makeManager() + + // When + do { + _ = try await sut.loadProfileFromCloud() + XCTFail("Expected pending download") + } catch { + XCTAssertEqual(error as? CloudSyncError, .requiredDownloadPending) + } + } + + func test_loadProfileState_deletionNewerThanPayload_exposesDeletionIntent() async throws { + // Given + let profile = UserProfile(name: "Stale", modifiedAt: Date(timeIntervalSince1970: 100)) + let marker = CloudDeletionMarker( + id: CloudSyncManager.profileMarkerId, + deletedAt: Date(timeIntervalSince1970: 200) + ) + try encode(profile).write(to: documentsURL.appendingPathComponent("UserProfile.json"), options: .atomic) + try encode(marker).write( + to: documentsURL.appendingPathComponent("UserProfileDeletion.json"), + options: .atomic + ) + + // When + let state = try await makeManager().loadProfileStateFromCloud() + + // Then + XCTAssertEqual(state, .deleted(marker)) + } + + func test_loadProfileState_payloadNewerThanDeletionMarker_allowsRecreation() async throws { + // Given + let marker = CloudDeletionMarker( + id: CloudSyncManager.profileMarkerId, + deletedAt: Date(timeIntervalSince1970: 100) + ) + let profile = UserProfile(name: "Recreated", modifiedAt: Date(timeIntervalSince1970: 200)) + try encode(marker).write( + to: documentsURL.appendingPathComponent("UserProfileDeletion.json"), + options: .atomic + ) + try encode(profile).write(to: documentsURL.appendingPathComponent("UserProfile.json"), options: .atomic) + + // When + let state = try await makeManager().loadProfileStateFromCloud() + + // Then + XCTAssertEqual(state, .profile(profile)) + } + + func test_saveProfile_revisionNotNewerThanDeletion_rejectsStaleResurrection() async throws { + // Given + let marker = CloudDeletionMarker( + id: CloudSyncManager.profileMarkerId, + deletedAt: Date(timeIntervalSince1970: 200) + ) + try encode(marker).write( + to: documentsURL.appendingPathComponent("UserProfileDeletion.json"), + options: .atomic + ) + let stale = UserProfile(name: "Stale", modifiedAt: Date(timeIntervalSince1970: 100)) + + // When + do { + try await makeManager().saveProfileToCloud(stale) + XCTFail("Expected stale profile rejection") + } catch { + // Then + XCTAssertEqual(error as? CloudSyncError, .staleProfileRevision) + XCTAssertFalse(FileManager.default.fileExists( + atPath: documentsURL.appendingPathComponent("UserProfile.json").path + )) + } + } + + func test_saveProfile_revisionNewerThanDeletion_recreatesAndClearsMarker() async throws { + // Given + let markerURL = documentsURL.appendingPathComponent("UserProfileDeletion.json") + let marker = CloudDeletionMarker( + id: CloudSyncManager.profileMarkerId, + deletedAt: Date(timeIntervalSince1970: 100) + ) + try encode(marker).write(to: markerURL, options: .atomic) + let recreated = UserProfile(name: "Recreated", modifiedAt: Date(timeIntervalSince1970: 200)) + + // When + try await makeManager().saveProfileToCloud(recreated) + + // Then + let state = try await makeManager().loadProfileStateFromCloud() + XCTAssertEqual(state, .profile(recreated)) + XCTAssertFalse(FileManager.default.fileExists(atPath: markerURL.path)) + } + + func test_deleteMemoryItem_stalePayloadReappears_tombstonePreventsResurrection() async throws { + // Given + let item = MemoryItem(content: "Forget me") + let sut = makeManager() + try await sut.saveMemoryToCloud([item]) + try await sut.deleteMemoryItemFromCloud(item.id, deletedAt: Date()) + try encode([item]).write(to: documentsURL.appendingPathComponent("Memory.json"), options: .atomic) + + // When + let loaded = try await sut.loadMemorySyncSnapshot().items + + // Then + XCTAssertEqual(loaded, []) + XCTAssertTrue(FileManager.default.fileExists( + atPath: documentsURL.appendingPathComponent("MemoryTombstones.json").path + )) + } + + func test_deleteMemoryItem_newerPayloadExists_retainsRecreatedItemAndTombstone() async throws { + // Given + let id = UUID() + let deletedAt = Date(timeIntervalSince1970: 1_000) + let recreated = MemoryItem( + id: id, + content: "Recreated", + createdAt: deletedAt, + updatedAt: Date(timeIntervalSince1970: 2_000) + ) + let sut = makeManager() + try await sut.saveMemoryToCloud([recreated]) + + // When + try await sut.deleteMemoryItemFromCloud(id, deletedAt: deletedAt) + let snapshot = try await sut.loadMemorySyncSnapshot() + + // Then + XCTAssertEqual(snapshot.items, [recreated]) + XCTAssertEqual(snapshot.deletionMarkers, [CloudDeletionMarker(id: id, deletedAt: deletedAt)]) + } + + func test_deleteTemplate_stalePayloadReappears_tombstonePreventsResurrection() async throws { + // Given + let template = PromptTemplate(title: "Deleted", content: "Body") + let sut = makeManager() + try await sut.syncTemplatesToCloud([template]) + try await sut.deleteTemplateFromCloud(template.id, deletedAt: Date()) + let payloadURL = documentsURL.appendingPathComponent("PromptTemplates/\(template.id.uuidString).json") + try encode(template).write(to: payloadURL, options: .atomic) + + // When + let loaded = try await sut.loadTemplatesFromCloud() + + // Then + XCTAssertTrue(loaded.templates.isEmpty) + XCTAssertTrue(FileManager.default.fileExists( + atPath: documentsURL.appendingPathComponent( + "PromptTemplateTombstones/\(template.id.uuidString).json" + ).path + )) + } + + func test_syncTemplate_revisionNewerThanTombstone_allowsRecreation() async throws { + // Given + let id = UUID() + let deletedAt = Date(timeIntervalSince1970: 2_000) + let recreated = PromptTemplate( + id: id, + title: "Recreated", + content: "Body", + createdAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 3_000) + ) + let sut = makeManager() + try await sut.deleteTemplateFromCloud(id, deletedAt: deletedAt) + + // When + try await sut.syncTemplatesToCloud([recreated]) + let snapshot = try await sut.loadTemplatesFromCloud() + + // Then + XCTAssertEqual(snapshot.templates, [recreated]) + XCTAssertNil(snapshot.deletionMarkers[id]) + } + + // MARK: - Private + + private func makeManager() -> CloudSyncManager { + CloudSyncManager(containerProvider: FixedCloudContainerProvider(url: rootURL)) + } + + private func encode(_ value: Value) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(value) + } +} + +// Safety: Mutable call state is protected by `lock`; `url` is immutable. +nonisolated private final class SwitchingCloudContainerProvider: CloudContainerProviding, @unchecked Sendable { + private let lock = NSLock() + private let url: URL + private var sessionCallCount = 0 + + init(url: URL) { + self.url = url + } + + func isAvailable() -> Bool { true } + func isMetadataReady(for session: CloudSyncSession) -> Bool { true } + func containerURL() -> URL? { url } + func identityData() -> Data? { Data("first".utf8) } + + func currentSession() -> CloudSyncSession? { + lock.withLock { + sessionCallCount += 1 + let identity = sessionCallCount == 1 ? Data("first".utf8) : Data("second".utf8) + return CloudSyncSession(containerURL: url, identity: identity) + } + } +} diff --git a/openclient-llm-test/Core/Managers/CloudSyncManagerConversationTests.swift b/openclient-llm-test/Core/Managers/CloudSyncManagerConversationTests.swift new file mode 100644 index 00000000..1b30790e --- /dev/null +++ b/openclient-llm-test/Core/Managers/CloudSyncManagerConversationTests.swift @@ -0,0 +1,495 @@ +// +// CloudSyncManagerConversationTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class CloudSyncManagerConversationTests: XCTestCase { + // MARK: - Properties + + private var rootURL: URL! + private var cloudContainerURL: URL! + private var localDocumentsURL: URL! + private var sut: CloudSyncManager! + + // MARK: - Setup + + override func setUp() async throws { + try await super.setUp() + rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + cloudContainerURL = rootURL.appendingPathComponent("Cloud", isDirectory: true) + localDocumentsURL = rootURL.appendingPathComponent("Local", isDirectory: true) + try FileManager.default.createDirectory(at: cloudContainerURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: localDocumentsURL, withIntermediateDirectories: true) + sut = makeManager() + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: rootURL) + sut = nil + rootURL = nil + cloudContainerURL = nil + localDocumentsURL = nil + try await super.tearDown() + } + + // MARK: - Tests + + func test_applySnapshot_legacyContainer_writesManifestAndConversation() throws { + // Given + let conversation = Conversation(modelId: "model") + let snapshot = try sut.loadConversationSyncSnapshot() + + // When + try sut.applyConversationSyncOutput( + output(conversations: [conversation]), + basedOn: snapshot + ) + + // Then + let manifestData = try Data(contentsOf: cloudDocumentsURL.appendingPathComponent("SyncManifest.json")) + XCTAssertEqual(try CloudSyncManifest.decode(manifestData), .current) + XCTAssertTrue(FileManager.default.fileExists(atPath: cloudConversationURL(for: conversation.id).path)) + } + + func test_loadSnapshot_missingManifest_readsLegacyVersionOneData() throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let conversation = Conversation(modelId: "model", createdAt: timestamp, updatedAt: timestamp) + try writeCloudConversation(conversation) + + // When + let snapshot = try sut.loadConversationSyncSnapshot() + + // Then + XCTAssertEqual(snapshot.conversations[conversation.id], conversation) + } + + func test_loadSnapshot_unsupportedManifest_performsNoConversationWrite() throws { + // Given + try FileManager.default.createDirectory(at: cloudDocumentsURL, withIntermediateDirectories: true) + let unsupported = CloudSyncManifest( + format: CloudSyncManifest.expectedFormat, + schemaVersion: 2, + minimumReaderVersion: 2 + ) + try JSONEncoder().encode(unsupported).write( + to: cloudDocumentsURL.appendingPathComponent("SyncManifest.json"), + options: .atomic + ) + + // When + XCTAssertThrowsError(try sut.loadConversationSyncSnapshot()) { error in + XCTAssertEqual(error as? CloudSyncManifest.ValidationError, .unsupportedSchemaVersion(2)) + } + + // Then + XCTAssertFalse(FileManager.default.fileExists(atPath: cloudConversationsURL.path)) + } + + func test_save_unsupportedManifest_persistsMutationLocallyWithoutCloudWrite() async throws { + // Given + let base = Conversation(modelId: "model") + try writeLocalConversation(base, to: localDocumentsURL) + try FileManager.default.createDirectory(at: cloudDocumentsURL, withIntermediateDirectories: true) + let unsupported = CloudSyncManifest( + format: CloudSyncManifest.expectedFormat, + schemaVersion: 2, + minimumReaderVersion: 2 + ) + try JSONEncoder().encode(unsupported).write( + to: cloudDocumentsURL.appendingPathComponent("SyncManifest.json"), + options: .atomic + ) + let repository = makeRepository(localDocuments: localDocumentsURL) + var updated = base + updated.messages.append(ChatMessage(role: .user, content: "Local change")) + updated.updatedAt = Date() + + // When + try await repository.save(updated, expectedBase: base) + + // Then + let local = try await repository.loadLocal() + XCTAssertEqual(local.first?.messages.map(\.content), ["Local change"]) + XCTAssertFalse(FileManager.default.fileExists(atPath: cloudConversationsURL.path)) + } + + func test_loadSnapshot_corruptCloudFile_throwsWithoutChangingBytes() throws { + // Given + let corruptData = Data("not-json".utf8) + let conversationURL = cloudConversationURL(for: UUID()) + try FileManager.default.createDirectory( + at: conversationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try corruptData.write(to: conversationURL) + + // When + XCTAssertThrowsError(try sut.loadConversationSyncSnapshot()) + + // Then + XCTAssertEqual(try Data(contentsOf: conversationURL), corruptData) + } + + func test_loadSnapshot_symlinkedAttachmentRoot_throwsWithoutReadingExternalFiles() throws { + // Given + let outsideDirectory = rootURL.appendingPathComponent("Outside", isDirectory: true) + try FileManager.default.createDirectory(at: outsideDirectory, withIntermediateDirectories: true) + let sentinelURL = outsideDirectory.appendingPathComponent("sentinel") + try Data("sentinel".utf8).write(to: sentinelURL) + try FileManager.default.createDirectory(at: cloudDocumentsURL, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + at: cloudDocumentsURL.appendingPathComponent("Attachments", isDirectory: true), + withDestinationURL: outsideDirectory + ) + + // When / Then + XCTAssertThrowsError(try sut.loadConversationSyncSnapshot()) { error in + XCTAssertEqual(error as? CloudSyncError, .invalidAttachmentPath) + } + XCTAssertEqual(try Data(contentsOf: sentinelURL), Data("sentinel".utf8)) + } + + func test_loadSnapshot_deleteAllPlaceholder_throwsPendingDownload() throws { + // Given + try FileManager.default.createDirectory(at: cloudDocumentsURL, withIntermediateDirectories: true) + let placeholder = cloudDocumentsURL.appendingPathComponent(".ConversationDeleteAll.json.icloud") + try Data().write(to: placeholder) + + // When / Then + XCTAssertThrowsError(try sut.loadConversationSyncSnapshot()) { error in + XCTAssertEqual(error as? CloudSyncError, .requiredDownloadPending) + } + } + + func test_loadSnapshot_unreferencedAttachmentPlaceholder_doesNotBlock() throws { + // Given + let folder = cloudDocumentsURL + .appendingPathComponent("Attachments", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data().write(to: folder.appendingPathComponent(".orphan.bin.icloud")) + + // When + let snapshot = try sut.loadConversationSyncSnapshot() + + // Then + XCTAssertTrue(snapshot.conversations.isEmpty) + } + + func test_synchronize_unreferencedAttachmentPlaceholder_removesWithoutWaiting() async throws { + // Given + let folder = cloudDocumentsURL + .appendingPathComponent("Attachments", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let placeholderURL = folder.appendingPathComponent(".orphan.bin.icloud") + try Data().write(to: placeholderURL) + let repository = makeRepository(localDocuments: localDocumentsURL) + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + XCTAssertFalse(FileManager.default.fileExists(atPath: placeholderURL.path)) + } + + func test_applySnapshot_cloudChangedAfterRead_throwsWithoutOverwriting() throws { + // Given + let original = Conversation(title: "Original", modelId: "model") + try writeCloudConversation(original) + let snapshot = try sut.loadConversationSyncSnapshot() + var remoteUpdate = original + remoteUpdate.title = "Remote update" + remoteUpdate.updatedAt = original.updatedAt.addingTimeInterval(1) + try writeCloudConversation(remoteUpdate) + + // When + XCTAssertThrowsError( + try sut.applyConversationSyncOutput(output(conversations: [original]), basedOn: snapshot) + ) { error in + XCTAssertEqual(error as? CloudSyncError, .cloudContentChanged) + } + + // Then + XCTAssertEqual(try readCloudConversation(original.id).title, "Remote update") + } + + func test_applySnapshot_identityChanged_throwsWithoutWriting() throws { + // Given + let provider = MutableCloudContainerProvider(url: cloudContainerURL, identity: Data("A".utf8)) + sut = CloudSyncManager(containerProvider: provider) + let snapshot = try sut.loadConversationSyncSnapshot() + let conversation = Conversation(modelId: "model") + provider.setIdentity(Data("B".utf8)) + + // When + XCTAssertThrowsError( + try sut.applyConversationSyncOutput(output(conversations: [conversation]), basedOn: snapshot) + ) { error in + XCTAssertEqual(error as? CloudSyncError, .containerIdentityChanged) + } + + // Then + XCTAssertFalse(FileManager.default.fileExists(atPath: cloudConversationURL(for: conversation.id).path)) + } + + func test_loadSnapshot_identityChangedWithoutNewBaseline_throwsPendingDownload() throws { + // Given + let provider = MutableCloudContainerProvider(url: cloudContainerURL, identity: Data("A".utf8)) + sut = CloudSyncManager(containerProvider: provider) + _ = try sut.loadConversationSyncSnapshot() + provider.setIdentity(Data("B".utf8)) + + // When / Then + XCTAssertThrowsError(try sut.loadConversationSyncSnapshot()) { error in + XCTAssertEqual(error as? CloudSyncError, .requiredDownloadPending) + } + } + + func test_applySnapshot_metadataReadinessReset_throwsWithoutWriting() throws { + // Given + let provider = MutableCloudContainerProvider(url: cloudContainerURL, identity: Data("A".utf8)) + sut = CloudSyncManager(containerProvider: provider) + let snapshot = try sut.loadConversationSyncSnapshot() + let conversation = Conversation(modelId: "model") + provider.setMetadataReady(false) + + // When + XCTAssertThrowsError( + try sut.applyConversationSyncOutput(output(conversations: [conversation]), basedOn: snapshot) + ) { error in + XCTAssertEqual(error as? CloudSyncError, .requiredDownloadPending) + } + + // Then + XCTAssertFalse(FileManager.default.fileExists(atPath: cloudConversationURL(for: conversation.id).path)) + } + + func test_synchronize_secondDevice_materializesExactAttachmentBytes() async throws { + // Given + let bytes = Data([0x01, 0x02, 0x03]) + let conversation = makeConversationWithAttachment(updatedAt: Date()) + try writeLocalConversation(conversation, to: localDocumentsURL) + try writeAttachment(bytes, conversation: conversation, to: localDocumentsURL) + let firstRepository = makeRepository(localDocuments: localDocumentsURL) + let firstResult = await firstRepository.synchronize() + XCTAssertEqual(firstResult, .synchronized) + let secondDeviceURL = rootURL.appendingPathComponent("SecondDevice", isDirectory: true) + try FileManager.default.createDirectory(at: secondDeviceURL, withIntermediateDirectories: true) + let secondRepository = makeRepository(localDocuments: secondDeviceURL) + + // When + let result = await secondRepository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + XCTAssertEqual(try attachmentData(for: conversation, in: secondDeviceURL), bytes) + } + + func test_synchronize_cloudWinner_replacesStaleLocalAttachmentBytes() async throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let localConversation = makeConversationWithAttachment(updatedAt: timestamp) + var cloudConversation = localConversation + cloudConversation.updatedAt = timestamp.addingTimeInterval(1) + let key = try XCTUnwrap(try ConversationAttachmentPath.key( + for: try XCTUnwrap(cloudConversation.messages.first?.attachments.first), + conversationId: cloudConversation.id + )) + let snapshot = try sut.loadConversationSyncSnapshot() + try sut.applyConversationSyncOutput( + output(conversations: [cloudConversation], attachments: [key: Data("cloud".utf8)]), + basedOn: snapshot + ) + try writeLocalConversation(localConversation, to: localDocumentsURL) + try writeAttachment(Data("local".utf8), conversation: localConversation, to: localDocumentsURL) + let repository = makeRepository(localDocuments: localDocumentsURL) + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + XCTAssertEqual(try attachmentData(for: localConversation, in: localDocumentsURL), Data("cloud".utf8)) + XCTAssertEqual(try sut.loadConversationSyncSnapshot().attachmentData[key], Data("cloud".utf8)) + let recoveryFiles = try attachmentRecoveryData(for: localConversation.id) + XCTAssertTrue(recoveryFiles.contains(Data("local".utf8))) + } + + func test_synchronize_missingLocalAttachment_doesNotPublishParent() async throws { + // Given + let conversation = makeConversationWithAttachment(updatedAt: Date()) + try writeLocalConversation(conversation, to: localDocumentsURL) + let repository = makeRepository(localDocuments: localDocumentsURL) + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .failed) + XCTAssertNil(try sut.loadConversationSyncSnapshot().conversations[conversation.id]) + } + + func test_synchronize_unchangedInputs_doesNotCreateAnotherLocalTransaction() async throws { + // Given + let conversation = Conversation(modelId: "model") + try writeLocalConversation(conversation, to: localDocumentsURL) + let repository = makeRepository(localDocuments: localDocumentsURL) + let initialResult = await repository.synchronize() + XCTAssertEqual(initialResult, .synchronized) + let transactionsURL = localDocumentsURL + .appendingPathComponent("ConversationRecovery", isDirectory: true) + .appendingPathComponent("Transactions", isDirectory: true) + XCTAssertFalse(FileManager.default.fileExists(atPath: transactionsURL.path)) + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + XCTAssertFalse(FileManager.default.fileExists(atPath: transactionsURL.path)) + } +} + +// MARK: - Private + +private extension CloudSyncManagerConversationTests { + var cloudDocumentsURL: URL { + cloudContainerURL.appendingPathComponent("Documents", isDirectory: true) + } + + var cloudConversationsURL: URL { + cloudDocumentsURL.appendingPathComponent("Conversations", isDirectory: true) + } + + func makeManager() -> CloudSyncManager { + CloudSyncManager(containerProvider: FixedCloudContainerProvider(url: cloudContainerURL)) + } + + func makeRepository(localDocuments: URL) -> ConversationRepository { + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = true + return ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: makeManager(), + attachmentRepository: AttachmentRepository(baseURL: localDocuments), + baseDirectory: localDocuments + ) + } + + func output( + conversations: [Conversation], + attachments: [CloudAttachmentKey: Data] = [:] + ) throws -> ConversationCloudSyncOutput { + let encoder = SyncJSONCoding.makeEncoder() + let encodedConversations = try conversations.map { conversation in + (conversation.id, try encoder.encode(conversation)) + } + let conversationData = Dictionary(uniqueKeysWithValues: encodedConversations) + let decoder = SyncJSONCoding.makeDecoder() + let canonicalConversations = try conversationData.values.map { + try decoder.decode(Conversation.self, from: $0) + } + return ConversationCloudSyncOutput( + conversations: canonicalConversations, + conversationData: conversationData, + tombstones: [], + deleteAllMarker: nil, + attachments: attachments + ) + } + + func cloudConversationURL(for id: UUID) -> URL { + cloudConversationsURL.appendingPathComponent("\(id.uuidString).json") + } + + func writeCloudConversation(_ conversation: Conversation) throws { + let url = cloudConversationURL(for: conversation.id) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try SyncJSONCoding.makeEncoder().encode(conversation).write(to: url, options: .atomic) + } + + func readCloudConversation(_ id: UUID) throws -> Conversation { + let data = try Data(contentsOf: cloudConversationURL(for: id)) + return try SyncJSONCoding.makeDecoder().decode(Conversation.self, from: data) + } + + func makeConversationWithAttachment(updatedAt: Date) -> Conversation { + let conversationId = UUID() + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: "Attachments/\(conversationId.uuidString)/image.png" + ) + return Conversation( + id: conversationId, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Image", attachments: [attachment])], + updatedAt: updatedAt + ) + } + + func writeLocalConversation(_ conversation: Conversation, to documentsURL: URL) throws { + let directory = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try SyncJSONCoding.makeEncoder().encode(conversation).write( + to: directory.appendingPathComponent("\(conversation.id.uuidString).json"), + options: .atomic + ) + } + + func writeAttachment(_ data: Data, conversation: Conversation, to documentsURL: URL) throws { + let attachment = try XCTUnwrap(conversation.messages.first?.attachments.first) + let url = documentsURL.appendingPathComponent(attachment.fileRelativePath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + + func attachmentData(for conversation: Conversation, in documentsURL: URL) throws -> Data { + let attachment = try XCTUnwrap(conversation.messages.first?.attachments.first) + return try Data(contentsOf: documentsURL.appendingPathComponent(attachment.fileRelativePath)) + } + + func attachmentRecoveryData(for conversationId: UUID) throws -> [Data] { + let directory = localDocumentsURL + .appendingPathComponent("ConversationRecovery/Attachments", isDirectory: true) + .appendingPathComponent(conversationId.uuidString, isDirectory: true) + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + return try files.map { try Data(contentsOf: $0) } + } +} + +// Safety: Mutable state is protected by `lock`; `url` is immutable. +nonisolated private final class MutableCloudContainerProvider: CloudContainerProviding, @unchecked Sendable { + private let lock = NSLock() + private let url: URL + private var identity: Data + private var readyIdentity: Data + + init(url: URL, identity: Data) { + self.url = url + self.identity = identity + self.readyIdentity = identity + } + + func isAvailable() -> Bool { true } + func isMetadataReady(for session: CloudSyncSession) -> Bool { + lock.withLock { readyIdentity == session.identity } + } + func containerURL() -> URL? { url } + func identityData() -> Data? { lock.withLock { identity } } + func setIdentity(_ identity: Data) { lock.withLock { self.identity = identity } } + func setMetadataReady(_ isReady: Bool) { + lock.withLock { readyIdentity = isReady ? identity : Data() } + } +} diff --git a/openclient-llm-test/Core/Managers/ConversationCloudObserverTests.swift b/openclient-llm-test/Core/Managers/ConversationCloudObserverTests.swift new file mode 100644 index 00000000..76884bd9 --- /dev/null +++ b/openclient-llm-test/Core/Managers/ConversationCloudObserverTests.swift @@ -0,0 +1,176 @@ +// +// ConversationCloudObserverTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class ConversationCloudObserverTests: XCTestCase { + func test_synchronizedPathComponents_includeAllPayloadAndDeletionMetadata() { + // Given / When + let paths = Set(ConversationCloudObserver.synchronizedPathComponents) + + // Then + XCTAssertEqual(paths, [ + "SyncManifest.json", + "UserProfile.json", + "UserProfileDeletion.json", + "Memory.json", + "MemoryTombstones.json", + "/PromptTemplates", + "/PromptTemplateTombstones", + "/Conversations", + "/ConversationTombstones", + "ConversationTombstones.json", + "ConversationDeleteAll.json", + "/Attachments" + ]) + } + + func test_requiresDownload_nonCurrentStatus_returnsTrue() { + // Given / When / Then + XCTAssertTrue(ConversationCloudObserver.requiresDownload( + forDownloadingStatus: NSMetadataUbiquitousItemDownloadingStatusNotDownloaded + )) + XCTAssertTrue(ConversationCloudObserver.requiresDownload( + forDownloadingStatus: NSMetadataUbiquitousItemDownloadingStatusDownloaded + )) + XCTAssertFalse(ConversationCloudObserver.requiresDownload( + forDownloadingStatus: NSMetadataUbiquitousItemDownloadingStatusCurrent + )) + XCTAssertFalse(ConversationCloudObserver.requiresDownload(forDownloadingStatus: nil)) + } + + func test_metadataReadiness_differentIdentity_isNotReady() { + // Given + let sut = CloudMetadataReadiness() + let first = CloudSyncSession( + containerURL: URL(fileURLWithPath: "/cloud"), + identity: Data("A".utf8) + ) + let second = CloudSyncSession( + containerURL: URL(fileURLWithPath: "/cloud"), + identity: Data("B".utf8) + ) + sut.setReady(for: first) + + // When / Then + XCTAssertTrue(sut.isReady(for: first)) + XCTAssertFalse(sut.isReady(for: second)) + } + + func test_metadataReadiness_resetForDifferentSession_keepsReadySession() { + // Given + let sut = CloudMetadataReadiness() + let readySession = CloudSyncSession( + containerURL: URL(fileURLWithPath: "/cloud"), + identity: Data("A".utf8) + ) + let staleSession = CloudSyncSession( + containerURL: URL(fileURLWithPath: "/cloud"), + identity: Data("B".utf8) + ) + sut.setReady(for: readySession) + + // When + sut.reset(for: staleSession) + + // Then + XCTAssertTrue(sut.isReady(for: readySession)) + } + + func test_ubiquityProvider_usesInjectedMetadataReadiness() { + // Given + let readiness = CloudMetadataReadiness() + let session = CloudSyncSession( + containerURL: URL(fileURLWithPath: "/cloud"), + identity: Data("A".utf8) + ) + let sut = UbiquityCloudContainerProvider( + fileManager: .default, + metadataReadiness: readiness + ) + + // When + readiness.setReady(for: session) + + // Then + XCTAssertTrue(sut.isMetadataReady(for: session)) + } + + func test_handleMetadataChange_withoutEstablishedBaseline_doesNotSynchronize() async { + // Given + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = true + let syncConversations = MockSyncConversationsUseCase() + let notificationCenter = NotificationCenter() + let sut = ConversationCloudObserver( + settingsManager: settingsManager, + cloudSyncManager: MockCloudSyncManager(), + syncConversationsUseCase: syncConversations, + notificationCenter: notificationCenter, + metadataReadiness: CloudMetadataReadiness(), + metadataDebounceDuration: .zero + ) + + // When + sut.handleMetadataChange() + for _ in 0..<10 { await Task.yield() } + + // Then + XCTAssertEqual(syncConversations.executeCallCount, 0) + } + + func test_handleMetadataChange_syncDisabled_doesNotSynchronize() async { + // Given + let settingsManager = MockSettingsManager() + let syncConversations = MockSyncConversationsUseCase() + let sut = ConversationCloudObserver( + settingsManager: settingsManager, + cloudSyncManager: MockCloudSyncManager(), + syncConversationsUseCase: syncConversations, + notificationCenter: NotificationCenter(), + metadataReadiness: CloudMetadataReadiness(), + metadataDebounceDuration: .zero + ) + + // When + sut.handleMetadataChange() + for _ in 0..<10 { await Task.yield() } + + // Then + XCTAssertEqual(syncConversations.executeCallCount, 0) + } + + func test_stop_readyMetadata_marksMetadataNotReadyAndCancelsSynchronization() async { + // Given + let metadataReadiness = CloudMetadataReadiness() + let session = CloudSyncSession( + containerURL: URL(fileURLWithPath: "/test-cloud"), + identity: Data("test".utf8) + ) + metadataReadiness.setReady(for: session) + let syncConversations = MockSyncConversationsUseCase() + let sut = ConversationCloudObserver( + settingsManager: MockSettingsManager(), + cloudSyncManager: MockCloudSyncManager(), + syncConversationsUseCase: syncConversations, + notificationCenter: NotificationCenter(), + metadataReadiness: metadataReadiness, + metadataDebounceDuration: .zero + ) + + // When + sut.stop() + for _ in 0..<10 { await Task.yield() } + + // Then + XCTAssertFalse(metadataReadiness.isReady(for: session)) + XCTAssertEqual(syncConversations.cancelCallCount, 1) + } +} diff --git a/openclient-llm-test/Core/Managers/MemoryManagerCloudDeletionTests.swift b/openclient-llm-test/Core/Managers/MemoryManagerCloudDeletionTests.swift new file mode 100644 index 00000000..6c43a384 --- /dev/null +++ b/openclient-llm-test/Core/Managers/MemoryManagerCloudDeletionTests.swift @@ -0,0 +1,264 @@ +// +// MemoryManagerCloudDeletionTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class MemoryManagerCloudDeletionTests: XCTestCase { + func test_delete_cloudFailure_retainsIntentAndRetriesWithoutResurrection() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: documentsURL) } + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + let settings = MockSettingsManager() + let cloud = MockCloudSyncManager() + let userDefaults = try makeUserDefaults() + cloud.cloudAvailable = false + let item = MemoryItem(content: "Deleted") + cloud.cloudMemoryItems = [item] + let sut = MemoryManager( + settingsManager: settings, + cloudSyncManager: cloud, + documentsURL: documentsURL, + userDefaults: userDefaults + ) + settings.isCloudSyncEnabled = true + try await sut.add(item) + cloud.syncError = CloudSyncError.containerUnavailable + + // When + do { + try await sut.delete(id: item.id) + XCTFail("Expected cloud delete failure") + } catch { + XCTAssertEqual(error as? CloudSyncError, .containerUnavailable) + } + do { + try await sut.synchronize() + XCTFail("Expected retained deletion retry to fail") + } catch { + XCTAssertEqual(error as? CloudSyncError, .containerUnavailable) + } + cloud.syncError = nil + try await sut.synchronize() + + // Then + XCTAssertFalse(sut.getItems().contains { $0.id == item.id }) + XCTAssertFalse(cloud.cloudMemoryItems?.contains { $0.id == item.id } ?? true) + XCTAssertTrue(FileManager.default.fileExists( + atPath: documentsURL.appendingPathComponent("MemoryTombstones.json").path + )) + } + + func test_synchronize_equalRevisionConflict_selectsSameWinnerAndPreservesLoser() async throws { + // Given + let id = UUID() + let revision = Date(timeIntervalSince1970: 1_000) + let first = MemoryItem(id: id, content: "Alpha", createdAt: revision, updatedAt: revision) + let second = MemoryItem(id: id, content: "Beta", createdAt: revision, updatedAt: revision) + + // When + let firstResult = try await synchronize(local: first, cloud: second) + let secondResult = try await synchronize(local: second, cloud: first) + + // Then + XCTAssertEqual(firstResult.winner, secondResult.winner) + XCTAssertEqual(firstResult.recovery, [firstResult.winner == first ? second : first]) + XCTAssertEqual(secondResult.recovery, [secondResult.winner == first ? second : first]) + } + + func test_synchronize_remoteDeletionIsNewer_removesStaleLocalItem() async throws { + // Given + let documentsURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: documentsURL) } + let settings = MockSettingsManager() + let cloud = MockCloudSyncManager() + let item = MemoryItem(content: "Stale", updatedAt: Date(timeIntervalSince1970: 1_000)) + let sut = MemoryManager( + settingsManager: settings, + cloudSyncManager: cloud, + documentsURL: documentsURL, + userDefaults: try makeUserDefaults() + ) + try await sut.add(item) + cloud.cloudMemoryDeletionMarkers = [ + CloudDeletionMarker(id: item.id, deletedAt: Date(timeIntervalSince1970: 2_000)) + ] + settings.isCloudSyncEnabled = true + + // When + try await sut.synchronize() + + // Then + XCTAssertTrue(sut.getItems().isEmpty) + XCTAssertEqual(cloud.cloudMemoryItems ?? [], []) + } + + func test_add_sameIdAfterDeletion_createsNewerRevisionAndRetainsTombstone() async throws { + // Given + let documentsURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: documentsURL) } + let settings = MockSettingsManager() + let cloud = MockCloudSyncManager() + let original = MemoryItem(content: "Original") + let sut = MemoryManager( + settingsManager: settings, + cloudSyncManager: cloud, + documentsURL: documentsURL, + userDefaults: try makeUserDefaults() + ) + try await sut.add(original) + try await sut.delete(id: original.id) + + // When + let recreation = MemoryItem( + id: original.id, + content: "Recreated", + createdAt: original.createdAt, + updatedAt: .distantPast + ) + try await sut.add(recreation) + settings.isCloudSyncEnabled = true + try await sut.synchronize() + + // Then + let recreatedItem = try XCTUnwrap(sut.getItems().first) + let marker = try XCTUnwrap(cloud.cloudMemoryDeletionMarkers.first) + XCTAssertGreaterThan(recreatedItem.updatedAt, marker.deletedAt) + XCTAssertEqual(cloud.cloudMemoryItems?.map(\.content), ["Recreated"]) + XCTAssertEqual(cloud.cloudMemoryDeletionMarkers.count, 1) + } + + func test_migration_legacyItemWithoutUpdatedAt_verifiesWriteBeforeRemovingSource() async throws { + // Given + let documentsURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: documentsURL) } + let defaults = try makeUserDefaults() + let createdAt = Date(timeIntervalSince1970: 1_000) + let legacy = LegacyMemoryItem(content: "Legacy", createdAt: createdAt) + defaults.set(try legacyEncoder().encode([legacy]), forKey: "memory_items") + let sut = MemoryManager( + settingsManager: MockSettingsManager(), + cloudSyncManager: MockCloudSyncManager(), + documentsURL: documentsURL, + userDefaults: defaults + ) + + // When + try await sut.add(MemoryItem(content: "New")) + + // Then + XCTAssertNil(defaults.data(forKey: "memory_items")) + XCTAssertEqual(sut.getItems().first { $0.content == "Legacy" }?.updatedAt, createdAt) + XCTAssertTrue(FileManager.default.fileExists(atPath: documentsURL.appendingPathComponent("Memory.json").path)) + } + + func test_migration_localWriteFails_throwsAndRetainsUserDefaultsSource() async throws { + // Given + let unavailableDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("Missing") + let defaults = try makeUserDefaults() + let legacy = LegacyMemoryItem(content: "Legacy", createdAt: Date(timeIntervalSince1970: 1_000)) + defaults.set(try legacyEncoder().encode([legacy]), forKey: "memory_items") + let sut = MemoryManager( + settingsManager: MockSettingsManager(), + cloudSyncManager: MockCloudSyncManager(), + documentsURL: unavailableDirectory, + userDefaults: defaults + ) + + // When + do { + try await sut.add(MemoryItem(content: "New")) + XCTFail("Expected local write failure") + } catch { + // Then + XCTAssertNotNil(defaults.data(forKey: "memory_items")) + } + } + + func test_add_localWriteFails_throwsFailure() async throws { + // Given + let unavailableDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("Missing") + let sut = MemoryManager( + settingsManager: MockSettingsManager(), + cloudSyncManager: MockCloudSyncManager(), + documentsURL: unavailableDirectory, + userDefaults: try makeUserDefaults() + ) + + // When + do { + try await sut.add(MemoryItem(content: "New")) + XCTFail("Expected local write failure") + } catch { + // Then + XCTAssertFalse(FileManager.default.fileExists( + atPath: unavailableDirectory.appendingPathComponent("Memory.json").path + )) + } + } + + // MARK: - Private + + private func synchronize(local: MemoryItem, cloud: MemoryItem) async throws -> SyncResult { + let documentsURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: documentsURL) } + let settings = MockSettingsManager() + let cloudManager = MockCloudSyncManager() + let sut = MemoryManager( + settingsManager: settings, + cloudSyncManager: cloudManager, + documentsURL: documentsURL, + userDefaults: try makeUserDefaults() + ) + try await sut.add(local) + cloudManager.cloudMemoryItems = [cloud] + settings.isCloudSyncEnabled = true + try await sut.synchronize() + let recoveryData = try Data(contentsOf: documentsURL.appendingPathComponent("MemoryRecovery.json")) + let recovery = try SyncJSONCoding.makeDecoder().decode([MemoryItem].self, from: recoveryData) + return SyncResult(winner: try XCTUnwrap(sut.getItems().first), recovery: recovery) + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func makeUserDefaults() throws -> UserDefaults { + let suiteName = "MemoryManagerCloudDeletionTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { throw CocoaError(.fileWriteUnknown) } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func legacyEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +private struct SyncResult { + let winner: MemoryItem + let recovery: [MemoryItem] +} + +private struct LegacyMemoryItem: Codable { + let id = UUID() + let content: String + let isEnabled = true + let createdAt: Date + let source = MemoryItem.Source.user +} diff --git a/openclient-llm-test/Core/Managers/RemoteConfigManagerTests.swift b/openclient-llm-test/Core/Managers/RemoteConfigManagerTests.swift index 45efe93f..b8792b4f 100644 --- a/openclient-llm-test/Core/Managers/RemoteConfigManagerTests.swift +++ b/openclient-llm-test/Core/Managers/RemoteConfigManagerTests.swift @@ -40,7 +40,7 @@ final class RemoteConfigManagerTests: XCTestCase { // Given let manager = RemoteConfigManager( endpoint: endpoint, - dataLoader: try makeLoader(version: "1.6.10"), + dataLoader: try makeLoader(version: "1.6.15"), defaults: defaults, refreshInterval: 6 * 60 * 60 ) @@ -49,13 +49,13 @@ final class RemoteConfigManagerTests: XCTestCase { let config = try await manager.loadConfig() // Then - XCTAssertEqual(config.appUpdate.ios.latestVersion, "1.6.10") + XCTAssertEqual(config.appUpdate.ios.latestVersion, "1.6.15") } func test_loadConfig_withFreshCache_returnsCachedConfig() async throws { // Given let now = Date(timeIntervalSince1970: 1_000) - _ = try await makeManager(version: "1.6.10", now: now, refreshInterval: .zero).loadConfig() + _ = try await makeManager(version: "1.6.15", now: now, refreshInterval: .zero).loadConfig() let manager = try makeManager( version: "2.0.0", now: now.addingTimeInterval(60), @@ -66,13 +66,13 @@ final class RemoteConfigManagerTests: XCTestCase { let config = try await manager.loadConfig() // Then - XCTAssertEqual(config.appUpdate.ios.latestVersion, "1.6.10") + XCTAssertEqual(config.appUpdate.ios.latestVersion, "1.6.15") } func test_loadConfig_withExpiredCache_downloadsLatestConfig() async throws { // Given let now = Date(timeIntervalSince1970: 1_000) - _ = try await makeManager(version: "1.6.10", now: now, refreshInterval: .zero).loadConfig() + _ = try await makeManager(version: "1.6.15", now: now, refreshInterval: .zero).loadConfig() let manager = try makeManager( version: "2.0.0", now: now.addingTimeInterval((6 * 60 * 60) + 1), @@ -89,7 +89,7 @@ final class RemoteConfigManagerTests: XCTestCase { func test_loadConfig_whenRefreshFails_returnsCachedConfig() async throws { // Given let now = Date(timeIntervalSince1970: 1_000) - _ = try await makeManager(version: "1.6.10", now: now, refreshInterval: .zero).loadConfig() + _ = try await makeManager(version: "1.6.15", now: now, refreshInterval: .zero).loadConfig() let manager = RemoteConfigManager( endpoint: endpoint, dataLoader: { _ in throw URLError(.notConnectedToInternet) }, @@ -102,7 +102,7 @@ final class RemoteConfigManagerTests: XCTestCase { let config = try await manager.loadConfig() // Then - XCTAssertEqual(config.appUpdate.ios.latestVersion, "1.6.10") + XCTAssertEqual(config.appUpdate.ios.latestVersion, "1.6.15") } } diff --git a/openclient-llm-test/Core/Managers/UserProfileManagerTests.swift b/openclient-llm-test/Core/Managers/UserProfileManagerTests.swift new file mode 100644 index 00000000..ae5becbf --- /dev/null +++ b/openclient-llm-test/Core/Managers/UserProfileManagerTests.swift @@ -0,0 +1,122 @@ +// +// UserProfileManagerTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class UserProfileManagerTests: XCTestCase { + // MARK: - Properties + + private var documentsURL: URL! + private var defaults: UserDefaults! + private var defaultsSuiteName: String! + private var settingsManager: MockSettingsManager! + private var cloudSyncManager: MockCloudSyncManager! + + // MARK: - Setup + + override func setUp() async throws { + try await super.setUp() + documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + defaultsSuiteName = UUID().uuidString + defaults = UserDefaults(suiteName: defaultsSuiteName) + settingsManager = MockSettingsManager() + cloudSyncManager = MockCloudSyncManager() + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: documentsURL) + defaults.removePersistentDomain(forName: defaultsSuiteName) + documentsURL = nil + defaults = nil + defaultsSuiteName = nil + settingsManager = nil + cloudSyncManager = nil + try await super.tearDown() + } + + // MARK: - Tests + + func test_init_legacyBlob_writesValidProfileBeforeRemovingSource() throws { + // Given + let legacyData = Data(#"{"name":"Legacy","profileDescription":"Developer","extraInfo":"Swift"}"#.utf8) + defaults.set(legacyData, forKey: "userProfile_data") + + // When + let sut = makeManager() + + // Then + XCTAssertEqual(sut.getLocalProfile().name, "Legacy") + XCTAssertNil(defaults.data(forKey: "userProfile_data")) + } + + func test_init_migrationWriteFails_retainsLegacySource() throws { + // Given + let legacyData = Data(#"{"name":"Legacy","profileDescription":"","extraInfo":""}"#.utf8) + defaults.set(legacyData, forKey: "userProfile_data") + let missingDirectory = documentsURL.appendingPathComponent("Missing", isDirectory: true) + + // When + let sut = makeManager(documentsURL: missingDirectory) + + // Then + XCTAssertEqual(defaults.data(forKey: "userProfile_data"), legacyData) + XCTAssertNotNil(sut.migrationError) + } + + func test_getCloudProfileState_remoteDeletion_preservesLocalProfileBeforeRemoval() async throws { + // Given + let sut = makeManager() + let local = UserProfile(name: "Local", modifiedAt: Date(timeIntervalSince1970: 100)) + try await sut.saveProfile(local) + let marker = CloudDeletionMarker( + id: CloudSyncManager.profileMarkerId, + deletedAt: Date(timeIntervalSince1970: 200) + ) + cloudSyncManager.cloudProfileDeletionMarker = marker + + // When + let state = try await sut.getCloudProfileState() + + // Then + XCTAssertEqual(state, .deleted(marker)) + XCTAssertTrue(sut.getLocalProfile().isEmpty) + let recoveryURL = documentsURL.appendingPathComponent("ProfileRecovery", isDirectory: true) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: recoveryURL.path).count, 1) + } + + func test_resolveCloudSyncConflict_keepCloud_preservesLosingLocalProfile() async throws { + // Given + let sut = makeManager() + let local = UserProfile(name: "Local", modifiedAt: Date(timeIntervalSince1970: 100)) + let cloud = UserProfile(name: "Cloud", modifiedAt: Date(timeIntervalSince1970: 200)) + try await sut.saveProfile(local) + cloudSyncManager.cloudProfile = cloud + + // When + try await sut.resolveCloudSyncConflict(keepLocal: false) + + // Then + XCTAssertEqual(sut.getLocalProfile(), cloud) + let recoveryURL = documentsURL.appendingPathComponent("ProfileRecovery", isDirectory: true) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: recoveryURL.path).count, 1) + } + + // MARK: - Private + + private func makeManager(documentsURL: URL? = nil) -> UserProfileManager { + UserProfileManager( + settingsManager: settingsManager, + cloudSyncManager: cloudSyncManager, + defaults: defaults, + documentsURL: documentsURL ?? self.documentsURL + ) + } +} diff --git a/openclient-llm-test/Core/Models/CloudSyncManifestTests.swift b/openclient-llm-test/Core/Models/CloudSyncManifestTests.swift new file mode 100644 index 00000000..2aa6ad93 --- /dev/null +++ b/openclient-llm-test/Core/Models/CloudSyncManifestTests.swift @@ -0,0 +1,99 @@ +// +// CloudSyncManifestTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class CloudSyncManifestTests: XCTestCase { + func test_decode_missingData_returnsCurrentManifest() throws { + // Given + let data: Data? = nil + + // When + let manifest = try CloudSyncManifest.decode(data) + + // Then + XCTAssertEqual(manifest, .current) + } + + func test_decode_currentManifest_returnsDecodedManifest() throws { + // Given + let data = try JSONEncoder().encode(CloudSyncManifest.current) + + // When + let manifest = try CloudSyncManifest.decode(data) + + // Then + XCTAssertEqual(manifest, .current) + } + + func test_decode_unexpectedFormat_throwsInvalidFormat() throws { + // Given + let manifest = CloudSyncManifest( + format: "invalid", + schemaVersion: 1, + minimumReaderVersion: 1 + ) + let data = try JSONEncoder().encode(manifest) + + // When + XCTAssertThrowsError(try CloudSyncManifest.decode(data)) { error in + // Then + XCTAssertEqual(error as? CloudSyncManifest.ValidationError, .invalidFormat) + } + } + + func test_decode_invalidVersionRange_throwsInvalidVersionRange() throws { + // Given + let manifest = CloudSyncManifest( + format: CloudSyncManifest.expectedFormat, + schemaVersion: 1, + minimumReaderVersion: 2 + ) + let data = try JSONEncoder().encode(manifest) + + // When + XCTAssertThrowsError(try CloudSyncManifest.decode(data)) { error in + // Then + XCTAssertEqual(error as? CloudSyncManifest.ValidationError, .invalidVersionRange) + } + } + + func test_decode_newerAdditiveSchemaSupportingCurrentReader_returnsManifest() throws { + // Given + let manifest = CloudSyncManifest( + format: CloudSyncManifest.expectedFormat, + schemaVersion: 2, + minimumReaderVersion: 1 + ) + let data = try JSONEncoder().encode(manifest) + + // When + let decoded = try CloudSyncManifest.decode(data) + + // Then + XCTAssertEqual(decoded, manifest) + } + + func test_decode_newerSchemaRequiringNewerReader_throwsUnsupportedSchemaVersion() throws { + // Given + let manifest = CloudSyncManifest( + format: CloudSyncManifest.expectedFormat, + schemaVersion: 2, + minimumReaderVersion: 2 + ) + let data = try JSONEncoder().encode(manifest) + + // When + XCTAssertThrowsError(try CloudSyncManifest.decode(data)) { error in + // Then + XCTAssertEqual(error as? CloudSyncManifest.ValidationError, .unsupportedSchemaVersion(2)) + } + } +} diff --git a/openclient-llm-test/Features/Chat/AttachmentRepositoryTests.swift b/openclient-llm-test/Features/Chat/AttachmentRepositoryTests.swift new file mode 100644 index 00000000..1a71b841 --- /dev/null +++ b/openclient-llm-test/Features/Chat/AttachmentRepositoryTests.swift @@ -0,0 +1,147 @@ +// +// AttachmentRepositoryTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class AttachmentRepositoryTests: XCTestCase { + // MARK: - Properties + + private var rootURL: URL! + private var outsideURL: URL! + private var sut: AttachmentRepository! + + // MARK: - Setup + + override func setUp() async throws { + try await super.setUp() + rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + outsideURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + try Data("sentinel".utf8).write(to: outsideURL) + sut = AttachmentRepository(baseURL: rootURL) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: outsideURL) + sut = nil + rootURL = nil + outsideURL = nil + try await super.tearDown() + } + + // MARK: - Tests + + func test_load_pathTraversal_throwsWithoutReadingExternalFile() throws { + // Given + let attachment = makeAttachment(path: "Attachments/\(UUID().uuidString)/../sentinel") + + // When / Then + XCTAssertThrowsError(try sut.load(attachment: attachment)) + XCTAssertEqual(try Data(contentsOf: outsideURL), Data("sentinel".utf8)) + } + + func test_delete_percentEncodedTraversal_throwsWithoutDeletingExternalFile() throws { + // Given + let attachment = makeAttachment(path: "Attachments/\(UUID().uuidString)/%2E%2E") + + // When / Then + XCTAssertThrowsError(try sut.delete(attachment: attachment)) + XCTAssertTrue(FileManager.default.fileExists(atPath: outsideURL.path)) + } + + func test_attachmentPath_iCloudPlaceholderShapedFileName_throwsInvalidPath() throws { + // Given + let attachment = makeAttachment(path: "Attachments/\(UUID().uuidString)/.image.png.icloud") + + // When / Then + XCTAssertThrowsError(try ConversationAttachmentPath.key(for: attachment)) { error in + XCTAssertEqual(error as? CloudSyncError, .invalidAttachmentPath) + } + } + + func test_load_symlinkEscape_throwsWithoutReadingExternalFile() throws { + // Given + let conversationId = UUID() + let relativePath = "Attachments/\(conversationId.uuidString)/image.png" + let symlinkURL = rootURL.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: symlinkURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: outsideURL) + let attachment = makeAttachment(path: relativePath) + + // When / Then + XCTAssertThrowsError(try sut.load(attachment: attachment)) + XCTAssertEqual(try Data(contentsOf: outsideURL), Data("sentinel".utf8)) + } + + func test_save_validAttachment_writesInsideConversationFolder() throws { + // Given + let conversationId = UUID() + let attachment = makeAttachment(path: "") + let bytes = Data([0x01, 0x02]) + + // When + let relativePath = try sut.save(data: bytes, for: attachment, conversationId: conversationId) + + // Then + XCTAssertTrue(relativePath.contains(conversationId.uuidString)) + XCTAssertEqual(try Data(contentsOf: rootURL.appendingPathComponent(relativePath)), bytes) + } + + func test_save_symlinkedAttachmentRoot_throwsWithoutWritingOutsideRoot() throws { + // Given + let outsideDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: outsideDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outsideDirectory) } + let attachmentRoot = rootURL.appendingPathComponent("Attachments", isDirectory: true) + try FileManager.default.createSymbolicLink(at: attachmentRoot, withDestinationURL: outsideDirectory) + let attachment = makeAttachment(path: "") + + // When / Then + XCTAssertThrowsError(try sut.save(data: Data([0x01]), for: attachment, conversationId: UUID())) + XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath: outsideDirectory.path).isEmpty) + } + + func test_deleteAll_symlinkedConversationFolder_throwsWithoutDeletingOutsideFolder() throws { + // Given + let outsideDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: outsideDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outsideDirectory) } + let sentinelURL = outsideDirectory.appendingPathComponent("sentinel") + try Data("sentinel".utf8).write(to: sentinelURL) + let conversationId = UUID() + let attachmentRoot = rootURL.appendingPathComponent("Attachments", isDirectory: true) + try FileManager.default.createDirectory(at: attachmentRoot, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + at: attachmentRoot.appendingPathComponent(conversationId.uuidString), + withDestinationURL: outsideDirectory + ) + + // When / Then + XCTAssertThrowsError(try sut.deleteAll(forConversationId: conversationId)) + XCTAssertTrue(FileManager.default.fileExists(atPath: sentinelURL.path)) + } +} + +// MARK: - Private + +private extension AttachmentRepositoryTests { + func makeAttachment(path: String) -> ChatMessage.Attachment { + ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: path + ) + } +} diff --git a/openclient-llm-test/Features/Chat/BranchConversationUseCaseTests.swift b/openclient-llm-test/Features/Chat/BranchConversationUseCaseTests.swift index 8244b836..fde8dcc9 100644 --- a/openclient-llm-test/Features/Chat/BranchConversationUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/BranchConversationUseCaseTests.swift @@ -15,24 +15,30 @@ final class BranchConversationUseCaseTests: XCTestCase { var sut: BranchConversationUseCase! var mockSave: MockSaveConversationUseCase! + var mockAttachments: MockAttachmentRepository! // MARK: - Setup override func setUp() async throws { try await super.setUp() mockSave = MockSaveConversationUseCase() - sut = BranchConversationUseCase(saveConversationUseCase: mockSave) + mockAttachments = MockAttachmentRepository() + sut = BranchConversationUseCase( + saveConversationUseCase: mockSave, + attachmentRepository: mockAttachments + ) } override func tearDown() async throws { sut = nil mockSave = nil + mockAttachments = nil try await super.tearDown() } // MARK: - Tests - func test_execute_createsNewConversationWithMessagesUpToAndIncludingTarget() throws { + func test_execute_createsNewConversationWithMessagesUpToAndIncludingTarget() async throws { // Given let msg1 = ChatMessage(role: .user, content: "First") let msg2 = ChatMessage(role: .assistant, content: "Second") @@ -40,7 +46,7 @@ final class BranchConversationUseCaseTests: XCTestCase { let conversation = Conversation(modelId: "gpt-4", messages: [msg1, msg2, msg3]) // When — fork from msg2 (assistant) - let fork = try sut.execute(conversation: conversation, fromMessageId: msg2.id) + let fork = try await sut.execute(conversation: conversation, fromMessageId: msg2.id) // Then XCTAssertEqual(fork.messages.count, 2) @@ -48,31 +54,31 @@ final class BranchConversationUseCaseTests: XCTestCase { XCTAssertEqual(fork.messages.last?.content, "Second") } - func test_execute_setsParentConversationId() throws { + func test_execute_setsParentConversationId() async throws { // Given let msg = ChatMessage(role: .user, content: "Hello") let conversation = Conversation(modelId: "gpt-4", messages: [msg]) // When - let fork = try sut.execute(conversation: conversation, fromMessageId: msg.id) + let fork = try await sut.execute(conversation: conversation, fromMessageId: msg.id) // Then XCTAssertEqual(fork.parentConversationId, conversation.id) } - func test_execute_setsBranchedFromMessageId() throws { + func test_execute_setsBranchedFromMessageId() async throws { // Given let msg = ChatMessage(role: .user, content: "Hello") let conversation = Conversation(modelId: "gpt-4", messages: [msg]) // When - let fork = try sut.execute(conversation: conversation, fromMessageId: msg.id) + let fork = try await sut.execute(conversation: conversation, fromMessageId: msg.id) // Then XCTAssertEqual(fork.branchedFromMessageId, msg.id) } - func test_execute_preservesModelAndSystemPrompt() throws { + func test_execute_preservesModelAndSystemPrompt() async throws { // Given let msg = ChatMessage(role: .user, content: "Hello") let conversation = Conversation( @@ -82,50 +88,73 @@ final class BranchConversationUseCaseTests: XCTestCase { ) // When - let fork = try sut.execute(conversation: conversation, fromMessageId: msg.id) + let fork = try await sut.execute(conversation: conversation, fromMessageId: msg.id) // Then XCTAssertEqual(fork.modelId, "llama3") XCTAssertEqual(fork.systemPrompt, "Be concise") } - func test_execute_forkSavedToPersistence() throws { + func test_execute_forkSavedToPersistence() async throws { // Given let msg = ChatMessage(role: .user, content: "Hello") let conversation = Conversation(modelId: "gpt-4", messages: [msg]) // When - _ = try sut.execute(conversation: conversation, fromMessageId: msg.id) + _ = try await sut.execute(conversation: conversation, fromMessageId: msg.id) // Then XCTAssertFalse(mockSave.savedConversations.isEmpty) } - func test_execute_forkHasUniqueId() throws { + func test_execute_forkHasUniqueId() async throws { // Given let msg = ChatMessage(role: .user, content: "Hello") let conversation = Conversation(modelId: "gpt-4", messages: [msg]) // When - let fork = try sut.execute(conversation: conversation, fromMessageId: msg.id) + let fork = try await sut.execute(conversation: conversation, fromMessageId: msg.id) // Then XCTAssertNotEqual(fork.id, conversation.id) } - func test_execute_withUnknownMessageId_throwsError() throws { + func test_execute_forkMessagesHaveUniqueIdsAndRemappedSummaryCursor() async throws { + // Given + let first = ChatMessage(role: .user, content: "First") + let second = ChatMessage(role: .assistant, content: "Second") + let conversation = Conversation( + modelId: "gpt-4", + contextSummary: "First exchange", + contextSummaryCursorMessageId: first.id, + messages: [first, second] + ) + + // When + let fork = try await sut.execute(conversation: conversation, fromMessageId: second.id) + + // Then + XCTAssertTrue(Set(fork.messages.map(\.id)).isDisjoint(with: Set(conversation.messages.map(\.id)))) + XCTAssertEqual(fork.contextSummaryCursorMessageId, fork.messages.first?.id) + XCTAssertEqual(fork.branchedFromMessageId, second.id) + } + + func test_execute_withUnknownMessageId_throwsError() async { // Given let msg = ChatMessage(role: .user, content: "Hello") let conversation = Conversation(modelId: "gpt-4", messages: [msg]) let unknownId = UUID() // When / Then - XCTAssertThrowsError(try sut.execute(conversation: conversation, fromMessageId: unknownId)) { error in + do { + _ = try await sut.execute(conversation: conversation, fromMessageId: unknownId) + XCTFail("Expected message-not-found error") + } catch { XCTAssertEqual(error as? BranchConversationError, .messageNotFound) } } - func test_execute_forkFromLastMessage_includesAllMessages() throws { + func test_execute_forkFromLastMessage_includesAllMessages() async throws { // Given let messages = (1...5).map { idx in ChatMessage(role: idx % 2 != 0 ? .user : .assistant, content: "Message \(idx)") @@ -134,13 +163,13 @@ final class BranchConversationUseCaseTests: XCTestCase { let lastId = try XCTUnwrap(messages.last).id // When - let fork = try sut.execute(conversation: conversation, fromMessageId: lastId) + let fork = try await sut.execute(conversation: conversation, fromMessageId: lastId) // Then XCTAssertEqual(fork.messages.count, 5) } - func test_execute_forkFromFirstMessage_includesOnlyFirstMessage() throws { + func test_execute_forkFromFirstMessage_includesOnlyFirstMessage() async throws { // Given let messages = [ ChatMessage(role: .user, content: "First"), @@ -151,14 +180,14 @@ final class BranchConversationUseCaseTests: XCTestCase { let firstId = try XCTUnwrap(messages.first).id // When - let fork = try sut.execute(conversation: conversation, fromMessageId: firstId) + let fork = try await sut.execute(conversation: conversation, fromMessageId: firstId) // Then XCTAssertEqual(fork.messages.count, 1) XCTAssertEqual(fork.messages.first?.content, "First") } - func test_execute_beforeSummaryCursor_doesNotLeakFutureSummary() throws { + func test_execute_beforeSummaryCursor_doesNotLeakFutureSummary() async throws { // Given let first = ChatMessage(role: .user, content: "First") let summarized = ChatMessage(role: .assistant, content: "Summarized") @@ -170,9 +199,58 @@ final class BranchConversationUseCaseTests: XCTestCase { ) // When - let fork = try sut.execute(conversation: conversation, fromMessageId: first.id) + let fork = try await sut.execute(conversation: conversation, fromMessageId: first.id) // Then XCTAssertNil(fork.contextSummary) } + + func test_execute_withAttachment_stagesBytesForAtomicPersistence() async throws { + // Given + let parentId = UUID() + let bytes = Data([0x01, 0x02, 0x03]) + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: "Attachments/\(parentId.uuidString)/image.png" + ) + let message = ChatMessage(role: .user, content: "Image", attachments: [attachment]) + let conversation = Conversation(id: parentId, modelId: "model", messages: [message]) + mockAttachments.loadedData = bytes + + // When + let fork = try await sut.execute(conversation: conversation, fromMessageId: message.id) + + // Then + let copiedAttachment = try XCTUnwrap(fork.messages.first?.attachments.first) + XCTAssertEqual(copiedAttachment.transientData, bytes) + XCTAssertTrue(copiedAttachment.fileRelativePath.isEmpty) + XCTAssertTrue(mockAttachments.savedAttachments.isEmpty) + } + + func test_execute_saveFails_leavesNoStagedAttachmentFolder() async { + // Given + let parentId = UUID() + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: "Attachments/\(parentId.uuidString)/image.png" + ) + let message = ChatMessage(role: .user, content: "Image", attachments: [attachment]) + let conversation = Conversation(id: parentId, modelId: "model", messages: [message]) + mockAttachments.loadedData = Data([0x01]) + mockSave.error = NSError(domain: "test", code: 1) + + // When + do { + _ = try await sut.execute(conversation: conversation, fromMessageId: message.id) + XCTFail("Expected save to fail") + } catch { + // Then + XCTAssertTrue(mockAttachments.savedAttachments.isEmpty) + XCTAssertTrue(mockAttachments.deleteAllConversationIds.isEmpty) + } + } } diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+Branching.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+Branching.swift index a3d18eb1..34432aad 100644 --- a/openclient-llm-test/Features/Chat/ChatViewModelTests+Branching.swift +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+Branching.swift @@ -43,6 +43,10 @@ extension ChatViewModelTests { // When sut.send(.forkFromMessage(msgId)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.branchedConversation?.id == expectedFork.id + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -84,6 +88,10 @@ extension ChatViewModelTests { // When sut.send(.forkFromMessage(userMessage.id)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.branchedConversation != nil + } // Then guard case .loaded(let loadedState) = sut.state, @@ -91,7 +99,8 @@ extension ChatViewModelTests { XCTFail("Expected a branched conversation") return } - XCTAssertEqual(fork.messages, [userMessage]) + XCTAssertEqual(fork.messages.map(\.content), [userMessage.content]) + XCTAssertNotEqual(fork.messages.first?.id, userMessage.id) XCTAssertEqual(mockSaveConversation.savedConversations.last, fork) } @@ -134,6 +143,10 @@ extension ChatViewModelTests { // When sut.send(.forkFromMessage(msgId)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.errorMessage != nil + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -166,6 +179,10 @@ extension ChatViewModelTests { let fork = Conversation(modelId: "gpt-4", parentConversationId: loaded.conversation?.id) mockBranchConversation.branchResult = .success(fork) sut.send(.forkFromMessage(msgId)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.branchedConversation != nil + } guard case .loaded(let withFork) = sut.state else { XCTFail("Expected loaded state") diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+ImageGeneration.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+ImageGeneration.swift index 45722f88..b19ba80e 100644 --- a/openclient-llm-test/Features/Chat/ChatViewModelTests+ImageGeneration.swift +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+ImageGeneration.swift @@ -37,7 +37,8 @@ extension ChatViewModelTests { XCTAssertEqual(mockGenerateImage.prompts, ["A cat on the Moon"]) XCTAssertEqual(mockGenerateImage.models, ["gpt-image-2"]) XCTAssertEqual(loadedState.messages.last?.attachments.first?.type, .image) - XCTAssertEqual(mockAttachmentRepository.savedAttachments.first?.data, imageData) + XCTAssertEqual(loadedState.messages.last?.attachments.first?.transientData, imageData) + XCTAssertTrue(mockAttachmentRepository.savedAttachments.isEmpty) XCTAssertFalse(loadedState.isStreaming) } } diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+ImagePreparation.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+ImagePreparation.swift index 8bc89bb4..12f1564a 100644 --- a/openclient-llm-test/Features/Chat/ChatViewModelTests+ImagePreparation.swift +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+ImagePreparation.swift @@ -13,7 +13,7 @@ import XCTest @MainActor extension ChatViewModelTests { - func test_send_attachmentAdded_withConvertedImage_savesPreparedImage() async throws { + func test_send_attachmentAdded_withConvertedImage_stagesPreparedImage() async throws { // Given let preparedData = Data([0xFF, 0xD8, 0xFF]) mockFetchModels.result = .success([LLMModel(id: "gpt-4")]) @@ -30,10 +30,14 @@ extension ChatViewModelTests { try await Task.sleep(for: .milliseconds(50)) // Then - let record = try XCTUnwrap(mockAttachmentRepository.savedAttachments.first) - XCTAssertEqual(record.data, preparedData) - XCTAssertEqual(record.attachment.fileName, "photo.jpg") - XCTAssertEqual(record.attachment.mimeType, "image/jpeg") + guard case .loaded(let loadedState) = sut.state else { + return XCTFail("Expected loaded state") + } + let attachment = try XCTUnwrap(loadedState.pendingAttachments.first) + XCTAssertEqual(attachment.transientData, preparedData) + XCTAssertEqual(attachment.fileName, "photo.jpg") + XCTAssertEqual(attachment.mimeType, "image/jpeg") + XCTAssertTrue(mockAttachmentRepository.savedAttachments.isEmpty) } func test_send_attachmentAdded_whenImagePreparationFails_showsErrorWithoutSaving() async throws { diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+Persistence.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+Persistence.swift index 6af3856b..02dadcee 100644 --- a/openclient-llm-test/Features/Chat/ChatViewModelTests+Persistence.swift +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+Persistence.swift @@ -125,7 +125,9 @@ extension ChatViewModelTests { // When sut.send(.conversationLoaded(selectedConversation)) - try await Task.sleep(for: .milliseconds(400)) + await waitUntil { + self.mockSaveConversation.savedConversations.contains { $0.id == previousConversation.id } + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -153,7 +155,7 @@ extension ChatViewModelTests { try await Task.sleep(for: .milliseconds(200)) // Then - XCTAssertEqual(mockStreamMessage.receivedMessages.last?.count, 51) + XCTAssertEqual(mockStreamMessage.receivedMessages.last?.count, 50) XCTAssertEqual(mockStreamMessage.receivedMessages.last?.last?.content, "Latest message") } @@ -434,6 +436,7 @@ extension ChatViewModelTests { // When let parameters = ModelParameters(temperature: 1.2) sut.send(.modelParametersChanged(parameters)) + await waitUntil { self.mockSaveConversation.savedConversations.count > savedCountBefore } // Then XCTAssertGreaterThan(mockSaveConversation.savedConversations.count, savedCountBefore) diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+SyncPersistence.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+SyncPersistence.swift new file mode 100644 index 00000000..c6a79b54 --- /dev/null +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+SyncPersistence.swift @@ -0,0 +1,252 @@ +// +// ChatViewModelTests+SyncPersistence.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +// MARK: - Synchronization Persistence + +@MainActor +extension ChatViewModelTests { + func test_send_sendTapped_newConversationUsesNilPersistenceBase() async throws { + // Given + mockFetchModels.result = .success([LLMModel(id: "gpt-4")]) + mockStreamMessage.chunks = [.token("Response")] + sut.send(.viewAppeared) + try await Task.sleep(for: .milliseconds(100)) + sut.send(.inputChanged("Hello")) + + // When + sut.send(.sendTapped) + await waitUntil { !self.mockSaveConversation.expectedBases.isEmpty } + + // Then + XCTAssertNil(mockSaveConversation.expectedBases[0]) + } + + func test_send_systemPromptChanged_persistsAgainstDurableConversationBase() async throws { + // Given + mockFetchModels.result = .success([LLMModel(id: "gpt-4")]) + let conversation = Conversation(modelId: "gpt-4", systemPrompt: "Original") + sut.send(.viewAppeared) + try await Task.sleep(for: .milliseconds(100)) + sut.send(.conversationLoaded(conversation)) + + // When + sut.send(.systemPromptChanged("Updated")) + await waitUntil { !self.mockSaveConversation.savedConversations.isEmpty } + + // Then + XCTAssertEqual((mockSaveConversation.expectedBases.last ?? nil)?.systemPrompt, "Original") + XCTAssertEqual(mockSaveConversation.savedConversations.last?.systemPrompt, "Updated") + } + + func test_send_sendTapped_pendingAttachmentUsesCreatedConversationFolder() async throws { + // Given + mockFetchModels.result = .success([LLMModel(id: "gpt-4")]) + mockStreamMessage.chunks = [.token("Response")] + mockSaveConversation.executeHandler = { conversation, _ in + var persisted = conversation + for messageIndex in persisted.messages.indices { + for attachmentIndex in persisted.messages[messageIndex].attachments.indices { + let attachment = persisted.messages[messageIndex].attachments[attachmentIndex] + persisted.messages[messageIndex].attachments[attachmentIndex] = ChatMessage.Attachment( + id: attachment.id, + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileRelativePath: ConversationAttachmentPath.relativePath( + for: attachment, + conversationId: conversation.id + ) + ) + } + } + return persisted + } + sut.send(.viewAppeared) + try await Task.sleep(for: .milliseconds(100)) + sut.send(.attachmentAdded(data: Data([0xFF, 0xD8]), fileName: "test.jpg", type: .image)) + try await Task.sleep(for: .milliseconds(50)) + sut.send(.inputChanged("Describe")) + + // When + sut.send(.sendTapped) + await waitUntil { + self.sut.persistenceBase?.messages.first?.attachments.first?.fileRelativePath.isEmpty == false + } + + // Then + let submitted = try XCTUnwrap(mockSaveConversation.savedConversations.last) + XCTAssertEqual(submitted.messages.first?.attachments.first?.transientData, Data([0xFF, 0xD8])) + let conversation = try XCTUnwrap(sut.persistenceBase) + let attachment = try XCTUnwrap(conversation.messages.first?.attachments.first) + XCTAssertTrue(attachment.fileRelativePath.contains(conversation.id.uuidString)) + XCTAssertNil(attachment.transientData) + } + + func test_persistence_previousSaveFails_nextSaveUsesLastDurableBase() async throws { + // Given + mockFetchModels.result = .success([LLMModel(id: "gpt-4")]) + let conversation = Conversation(modelId: "gpt-4", systemPrompt: "Original") + mockSaveConversation.failureAtCall = 1 + sut.send(.viewAppeared) + try await Task.sleep(for: .milliseconds(100)) + sut.send(.conversationLoaded(conversation)) + + // When + sut.send(.systemPromptChanged("First")) + sut.send(.systemPromptChanged("Second")) + await waitUntil { self.mockSaveConversation.executeCallCount == 2 } + + // Then + XCTAssertEqual((mockSaveConversation.expectedBases.last ?? nil)?.systemPrompt, "Original") + XCTAssertEqual(mockSaveConversation.savedConversations.last?.systemPrompt, "Second") + } + + func test_persistence_reconciledResult_updatesVisibleHistoryAndDurableBase() async throws { + // Given + mockFetchModels.result = .success([LLMModel(id: "gpt-4")]) + let initial = ChatMessage(role: .user, content: "Initial") + let conversation = Conversation(modelId: "gpt-4", messages: [initial]) + sut.send(.viewAppeared) + try await Task.sleep(for: .milliseconds(100)) + sut.send(.conversationLoaded(conversation)) + guard case .loaded(var loadedState) = sut.state else { + return XCTFail("Expected loaded state") + } + let local = ChatMessage(role: .assistant, content: "Local") + loadedState.messages.append(local) + sut.state = .loaded(loadedState) + var reconciled = conversation + reconciled.messages = [ + initial, + ChatMessage(role: .assistant, content: "Remote"), + local + ] + reconciled.updatedAt = Date() + mockSaveConversation.result = reconciled + + // When + let didPersist = await sut.persistConversation() + + // Then + XCTAssertTrue(didPersist) + guard case .loaded(let finalState) = sut.state else { + return XCTFail("Expected loaded state") + } + XCTAssertEqual(finalState.messages.map(\.content), ["Initial", "Remote", "Local"]) + XCTAssertEqual(sut.persistenceBase, reconciled) + } + + func test_persistence_queuedRevision_rebasesOntoDurableResult() async throws { + // Given + let gate = TestAsyncGate() + let initialMessage = ChatMessage(role: .user, content: "Initial") + let remoteMessage = ChatMessage(role: .assistant, content: "Remote") + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "generated.png", + mimeType: "image/png", + fileRelativePath: "", + transientData: Data([0x01, 0x02]) + ) + let assistantId = UUID() + let conversation = Conversation(modelId: "gpt-4", messages: [initialMessage]) + configureQueuedPersistence(gate: gate, remoteMessage: remoteMessage, assistantId: assistantId) + sut.state = .loaded(sut.makeLoadedState(models: [LLMModel(id: "gpt-4")], pending: conversation)) + guard case .loaded(var firstState) = sut.state else { + return XCTFail("Expected loaded state") + } + firstState.messages.append(ChatMessage( + id: assistantId, + role: .assistant, + content: "Partial", + attachments: [attachment] + )) + sut.state = .loaded(firstState) + sut.scheduleConversationPersistence() + await waitUntil { self.mockSaveConversation.executeCallCount == 1 } + guard case .loaded(var finalState) = sut.state, + let assistantIndex = finalState.messages.firstIndex(where: { $0.id == assistantId }) else { + return XCTFail("Expected assistant message") + } + finalState.messages[assistantIndex].content = "Final" + sut.state = .loaded(finalState) + + // When + sut.scheduleConversationPersistence() + await gate.open() + await waitUntil { self.mockSaveConversation.executeCallCount == 2 } + + // Then + let durableBase = try XCTUnwrap(mockSaveConversation.expectedBases[1]) + let finalSubmission = mockSaveConversation.savedConversations[1] + XCTAssertTrue(durableBase.messages.contains(where: { $0.id == remoteMessage.id })) + XCTAssertTrue(finalSubmission.messages.contains(where: { $0.id == remoteMessage.id })) + let savedAssistant = try XCTUnwrap(finalSubmission.messages.first(where: { $0.id == assistantId })) + XCTAssertEqual(savedAssistant.content, "Final") + XCTAssertEqual(savedAssistant.attachments.first?.transientData, Data([0x01, 0x02])) + } + + func test_resetAfterAppDataReset_streamingConversation_doesNotQueuePersistence() async { + // Given + let gate = TestAsyncGate() + let conversation = Conversation(modelId: "gpt-4", messages: [ChatMessage(role: .user, content: "Hello")]) + mockSaveConversation.asyncExecuteHandler = { submitted, _, _ in + await gate.wait() + return submitted + } + var loadedState = sut.makeLoadedState(models: [LLMModel(id: "gpt-4")], pending: conversation) + loadedState.isStreaming = true + sut.state = .loaded(loadedState) + sut.scheduleConversationPersistence() + await waitUntil { self.mockSaveConversation.executeCallCount == 1 } + let pendingPersistence = sut.persistenceTask + + // When + sut.resetAfterAppDataReset() + await gate.open() + _ = await pendingPersistence?.value + + // Then + XCTAssertEqual(mockSaveConversation.executeCallCount, 1) + XCTAssertNil(sut.persistenceTask) + XCTAssertNil(sut.queuedPersistenceConversation) + XCTAssertNil(sut.persistenceBase) + } +} + +// MARK: - Private + +private extension ChatViewModelTests { + func configureQueuedPersistence( + gate: TestAsyncGate, + remoteMessage: ChatMessage, + assistantId: UUID + ) { + mockSaveConversation.asyncExecuteHandler = { submitted, _, call in + guard call == 1 else { return submitted } + await gate.wait() + var persisted = submitted + persisted.messages.insert(remoteMessage, at: 1) + if let index = persisted.messages.firstIndex(where: { $0.id == assistantId }) { + persisted.messages[index].attachments = persisted.messages[index].attachments.map { attachment in + ChatMessage.Attachment( + id: attachment.id, + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileRelativePath: "Attachments/generated.png" + ) + } + } + return persisted + } + } +} diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests.swift index 25aaf721..cca45fd4 100644 --- a/openclient-llm-test/Features/Chat/ChatViewModelTests.swift +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests.swift @@ -425,5 +425,20 @@ final class ChatViewModelTests: XCTestCase { ) XCTAssertEqual(loadedState.messages[1].role, .assistant) } +} +// MARK: - Helpers + +extension ChatViewModelTests { + func waitUntil( + _ condition: @MainActor () -> Bool, + file: StaticString = #filePath, + line: UInt = #line + ) async { + for _ in 0..<100 { + if condition() { return } + await Task.yield() + } + XCTFail("Condition was not satisfied", file: file, line: line) + } } diff --git a/openclient-llm-test/Features/Chat/ConversationAttachmentMutationTests.swift b/openclient-llm-test/Features/Chat/ConversationAttachmentMutationTests.swift new file mode 100644 index 00000000..70eb0fcf --- /dev/null +++ b/openclient-llm-test/Features/Chat/ConversationAttachmentMutationTests.swift @@ -0,0 +1,291 @@ +// +// ConversationAttachmentMutationTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class ConversationAttachmentMutationTests: XCTestCase { + func test_save_syncDisabled_transientAttachment_materializesWithConversation() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = false + let repository = ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: MockCloudSyncManager(), + attachmentRepository: AttachmentRepository(baseURL: documentsURL), + baseDirectory: documentsURL + ) + let bytes = Data("attachment".utf8) + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: "", + transientData: bytes + ) + let conversation = Conversation( + modelId: "model", + messages: [ChatMessage(role: .user, content: "Image", attachments: [attachment])] + ) + + // When + let saved = try await repository.save(conversation) + + // Then + let persistedAttachment = try XCTUnwrap(saved.messages.first?.attachments.first) + XCTAssertNil(persistedAttachment.transientData) + XCTAssertTrue(persistedAttachment.fileRelativePath.contains(conversation.id.uuidString)) + XCTAssertEqual( + try Data(contentsOf: documentsURL.appendingPathComponent(persistedAttachment.fileRelativePath)), + bytes + ) + } + + func test_save_missingAttachmentBytes_doesNotPersistConversation() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = false + let repository = ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: MockCloudSyncManager(), + attachmentRepository: AttachmentRepository(baseURL: documentsURL), + baseDirectory: documentsURL + ) + let conversationId = UUID() + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "missing.png", + mimeType: "image/png", + fileRelativePath: "Attachments/\(conversationId.uuidString)/missing.png" + ) + let conversation = Conversation( + id: conversationId, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Image", attachments: [attachment])] + ) + + // When + do { + _ = try await repository.save(conversation) + XCTFail("Expected missing attachment error") + } catch { + // Then + let conversationURL = documentsURL + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(conversationId.uuidString).json") + XCTAssertFalse(FileManager.default.fileExists(atPath: conversationURL.path)) + } + } + + func test_save_syncDisabled_removingAttachment_cleansBytesAndKeepsRecoveryCopy() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = false + let repository = ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: MockCloudSyncManager(), + attachmentRepository: AttachmentRepository(baseURL: documentsURL), + baseDirectory: documentsURL + ) + let conversationId = UUID() + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: "Attachments/\(conversationId.uuidString)/image.png" + ) + let base = Conversation( + id: conversationId, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Image", attachments: [attachment])] + ) + let bytes = Data("attachment".utf8) + let attachmentURL = documentsURL.appendingPathComponent(attachment.fileRelativePath) + try FileManager.default.createDirectory( + at: attachmentURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try bytes.write(to: attachmentURL) + try await repository.save(base) + var updated = base + updated.messages[0].attachments = [] + updated.updatedAt = Date() + + // When + try await repository.save(updated, expectedBase: base) + + // Then + XCTAssertFalse(FileManager.default.fileExists(atPath: attachmentURL.path)) + let recoveryURL = documentsURL + .appendingPathComponent("ConversationRecovery/Attachments", isDirectory: true) + .appendingPathComponent(conversationId.uuidString, isDirectory: true) + let recoveryFiles = try FileManager.default.contentsOfDirectory( + at: recoveryURL, + includingPropertiesForKeys: nil + ) + XCTAssertTrue(try recoveryFiles.contains { try Data(contentsOf: $0) == bytes }) + } + + func test_setPinned_legacyInlineAttachment_materializesBytesTransactionally() async throws { + // Given + let fixture = try makeLegacyAttachmentFixture(cloudSyncEnabled: false) + + // When + let updated = try await fixture.repository.setPinned(true, conversationId: fixture.conversationId) + + // Then + let attachment = try XCTUnwrap(updated?.messages.first?.attachments.first) + XCTAssertTrue(try XCTUnwrap(updated?.isPinned)) + XCTAssertNil(attachment.transientData) + XCTAssertEqual(try Data(contentsOf: documentsURL(fixture, path: attachment.fileRelativePath)), fixture.bytes) + XCTAssertFalse(try conversationJSON(fixture).containsLegacyAttachmentData) + } + + func test_rename_cloudUnavailableLegacyAttachment_materializesPendingMutation() async throws { + // Given + let fixture = try makeLegacyAttachmentFixture(cloudSyncEnabled: true, cloudAvailable: false) + + // When + let updated = try await fixture.repository.rename(fixture.conversationId, title: "Renamed") + + // Then + let attachment = try XCTUnwrap(updated?.messages.first?.attachments.first) + XCTAssertEqual(updated?.title, "Renamed") + XCTAssertEqual(try Data(contentsOf: documentsURL(fixture, path: attachment.fileRelativePath)), fixture.bytes) + let pendingURL = fixture.documentsURL + .appendingPathComponent("ConversationPendingMutations", isDirectory: true) + .appendingPathComponent("\(fixture.conversationId.uuidString).json") + let pendingData = try Data(contentsOf: pendingURL) + XCTAssertFalse(try JSONFixture(data: pendingData).containsLegacyAttachmentData) + } + + func test_updateTags_legacyInlineAttachment_materializesBytes() async throws { + // Given + let fixture = try makeLegacyAttachmentFixture(cloudSyncEnabled: false) + let tags = [ConversationTag(name: "Legacy", color: .blue)] + + // When + let updated = try await fixture.repository.updateTags(fixture.conversationId, tags: tags) + + // Then + let attachment = try XCTUnwrap(updated?.messages.first?.attachments.first) + XCTAssertEqual(updated?.tags, tags) + XCTAssertEqual(try Data(contentsOf: documentsURL(fixture, path: attachment.fileRelativePath)), fixture.bytes) + XCTAssertFalse(try conversationJSON(fixture).containsLegacyAttachmentData) + } +} + +// MARK: - Private + +private extension ConversationAttachmentMutationTests { + struct LegacyFixture { + let repository: ConversationRepository + let documentsURL: URL + let conversationId: UUID + let bytes: Data + } + + struct JSONFixture { + let data: Data + + var containsLegacyAttachmentData: Bool { + get throws { + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let messages = try XCTUnwrap(root["messages"] as? [[String: Any]]) + let attachments = try XCTUnwrap(messages.first?["attachments"] as? [[String: Any]]) + return attachments.first?["data"] != nil + } + } + } + + func makeLegacyAttachmentFixture( + cloudSyncEnabled: Bool, + cloudAvailable: Bool = true + ) throws -> LegacyFixture { + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory( + at: documentsURL.appendingPathComponent("Conversations", isDirectory: true), + withIntermediateDirectories: true + ) + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = cloudSyncEnabled + let cloudSyncManager = MockCloudSyncManager() + cloudSyncManager.cloudAvailable = cloudAvailable + let repository = ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: cloudSyncManager, + attachmentRepository: AttachmentRepository(baseURL: documentsURL), + baseDirectory: documentsURL + ) + let conversationId = UUID() + let bytes = Data("legacy attachment".utf8) + let attachmentId = UUID() + let data = try legacyConversationData( + conversationId: conversationId, + attachmentId: attachmentId, + bytes: bytes + ) + try data.write( + to: documentsURL + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(conversationId.uuidString).json") + ) + addTeardownBlock { try? FileManager.default.removeItem(at: documentsURL) } + return LegacyFixture( + repository: repository, + documentsURL: documentsURL, + conversationId: conversationId, + bytes: bytes + ) + } + + func legacyConversationData(conversationId: UUID, attachmentId: UUID, bytes: Data) throws -> Data { + let json: [String: Any] = [ + "id": conversationId.uuidString, + "modelId": "model", + "title": "Original", + "createdAt": "2026-08-11T10:00:00Z", + "updatedAt": "2026-08-11T10:00:00Z", + "isPinned": false, + "messages": [[ + "id": UUID().uuidString, + "role": "user", + "content": "Image", + "timestamp": "2026-08-11T10:00:00Z", + "attachments": [[ + "id": attachmentId.uuidString, + "type": "image", + "fileName": "image.png", + "data": bytes.base64EncodedString() + ]] + ]] + ] + return try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + } + + func conversationJSON(_ fixture: LegacyFixture) throws -> JSONFixture { + let url = fixture.documentsURL + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(fixture.conversationId.uuidString).json") + return JSONFixture(data: try Data(contentsOf: url)) + } + + func documentsURL(_ fixture: LegacyFixture, path: String) -> URL { + fixture.documentsURL.appendingPathComponent(path) + } +} diff --git a/openclient-llm-test/Features/Chat/ConversationLegacySyncTests.swift b/openclient-llm-test/Features/Chat/ConversationLegacySyncTests.swift new file mode 100644 index 00000000..e548527b --- /dev/null +++ b/openclient-llm-test/Features/Chat/ConversationLegacySyncTests.swift @@ -0,0 +1,479 @@ +// +// ConversationLegacySyncTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class ConversationLegacySyncTests: XCTestCase { + // MARK: - Properties + + private var rootURL: URL! + private var cloudContainerURL: URL! + private var localDocumentsURL: URL! + + // MARK: - Setup + + override func setUp() async throws { + try await super.setUp() + rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + cloudContainerURL = rootURL.appendingPathComponent("Cloud", isDirectory: true) + localDocumentsURL = rootURL.appendingPathComponent("Local", isDirectory: true) + try FileManager.default.createDirectory(at: cloudContainerURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: localDocumentsURL, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: rootURL) + rootURL = nil + cloudContainerURL = nil + localDocumentsURL = nil + try await super.tearDown() + } + + // MARK: - Tests + + func test_synchronize_cloudLegacyInlineAttachment_materializesAndPreservesExactBytes() async throws { + // Given + let bytes = Data([0x01, 0x02, 0x03, 0x04]) + let conversation = legacyConversation() + let legacyData = try legacyData(for: conversation, attachmentData: bytes) + try writeCloudConversationData(legacyData, id: conversation.id) + let repository = makeRepository() + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + let localConversations = try await repository.loadLocal() + let localConversation = try XCTUnwrap(localConversations.first) + let attachment = try XCTUnwrap(localConversation.messages.first?.attachments.first) + XCTAssertFalse(attachment.fileRelativePath.isEmpty) + XCTAssertEqual( + try Data(contentsOf: localDocumentsURL.appendingPathComponent(attachment.fileRelativePath)), + bytes + ) + let recoveryFiles = try recoveryData() + XCTAssertTrue(recoveryFiles.contains(legacyData)) + let rewrittenData = try Data(contentsOf: cloudConversationURL(for: conversation.id)) + let rewrittenObject = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData) as? [String: Any]) + let messages = try XCTUnwrap(rewrittenObject["messages"] as? [[String: Any]]) + let attachments = try XCTUnwrap(messages.first?["attachments"] as? [[String: Any]]) + XCTAssertNil(attachments.first?["data"]) + } + + func test_synchronize_pendingAttachmentWithNewerTombstone_deletesParentWithoutDownload() async throws { + // Given + let conversation = conversationWithAttachment(updatedAt: .distantPast) + try writeCloudConversationData(SyncJSONCoding.makeEncoder().encode(conversation), id: conversation.id) + let attachment = try XCTUnwrap(conversation.messages.first?.attachments.first) + let attachmentURL = cloudDocumentsURL.appendingPathComponent(attachment.fileRelativePath) + try FileManager.default.createDirectory( + at: attachmentURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let placeholderURL = attachmentURL.deletingLastPathComponent() + .appendingPathComponent(".\(attachmentURL.lastPathComponent).icloud") + try Data().write(to: placeholderURL) + let tombstone = ConversationTombstone(conversationId: conversation.id, deletedAt: Date()) + try SyncJSONCoding.makeEncoder().encode([tombstone]).write( + to: localDocumentsURL.appendingPathComponent("ConversationTombstones.json"), + options: .atomic + ) + let repository = makeRepository() + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + XCTAssertFalse(FileManager.default.fileExists(atPath: cloudConversationURL(for: conversation.id).path)) + let localConversations = try await repository.loadLocal() + XCTAssertTrue(localConversations.isEmpty) + } + + func test_branchWithAttachment_parentDeleted_secondDeviceKeepsForkBytes() async throws { + // Given + let bytes = Data([0x01, 0x02, 0x03]) + let parent = conversationWithAttachment(updatedAt: Date()) + try writeLocalConversation(parent, documentsURL: localDocumentsURL) + let parentAttachment = try XCTUnwrap(parent.messages.first?.attachments.first) + let parentAttachmentURL = localDocumentsURL.appendingPathComponent(parentAttachment.fileRelativePath) + try FileManager.default.createDirectory( + at: parentAttachmentURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try bytes.write(to: parentAttachmentURL) + let firstRepository = makeRepository() + let initialSyncResult = await firstRepository.synchronize() + XCTAssertEqual(initialSyncResult, .synchronized) + let branchUseCase = BranchConversationUseCase( + saveConversationUseCase: SaveConversationUseCase(repository: firstRepository), + attachmentRepository: AttachmentRepository(baseURL: localDocumentsURL) + ) + let sourceMessage = try XCTUnwrap(parent.messages.first) + let fork = try await branchUseCase.execute(conversation: parent, fromMessageId: sourceMessage.id) + try await firstRepository.delete(parent.id) + let secondDeviceURL = rootURL.appendingPathComponent("SecondDevice", isDirectory: true) + try FileManager.default.createDirectory(at: secondDeviceURL, withIntermediateDirectories: true) + let secondRepository = makeRepository(at: secondDeviceURL) + + // When + let result = await secondRepository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + let secondConversations = try await secondRepository.loadLocal() + let secondFork = try XCTUnwrap(secondConversations.first { $0.id == fork.id }) + let forkAttachment = try XCTUnwrap(secondFork.messages.first?.attachments.first) + let downloadedBytes = try Data( + contentsOf: secondDeviceURL.appendingPathComponent(forkAttachment.fileRelativePath) + ) + XCTAssertEqual(downloadedBytes, bytes) + } + + func test_synchronize_attachmentRemovedFromParent_cleansLocalAndCloudAfterRecovery() async throws { + // Given + let conversationId = UUID() + let firstAttachment = attachment(fileName: "first.png", conversationId: conversationId) + let secondAttachment = attachment(fileName: "second.png", conversationId: conversationId) + let message = ChatMessage( + role: .user, + content: "Images", + attachments: [firstAttachment, secondAttachment] + ) + let conversation = Conversation(id: conversationId, modelId: "model", messages: [message]) + try writeLocalConversation(conversation, documentsURL: localDocumentsURL) + try writeAttachment(Data("first".utf8), attachment: firstAttachment, documentsURL: localDocumentsURL) + let removedBytes = Data("second".utf8) + try writeAttachment(removedBytes, attachment: secondAttachment, documentsURL: localDocumentsURL) + let repository = makeRepository() + let initialResult = await repository.synchronize() + XCTAssertEqual(initialResult, .synchronized) + var updatedConversation = conversation + updatedConversation.messages[0].attachments = [firstAttachment] + updatedConversation.updatedAt = conversation.updatedAt.addingTimeInterval(1) + + // When + try await repository.save(updatedConversation) + + // Then + let localRemovedURL = localDocumentsURL.appendingPathComponent(secondAttachment.fileRelativePath) + let cloudRemovedURL = cloudDocumentsURL.appendingPathComponent(secondAttachment.fileRelativePath) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRemovedURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: cloudRemovedURL.path)) + let recoveryFiles = try attachmentRecoveryData(for: conversationId) + XCTAssertTrue(recoveryFiles.contains(removedBytes)) + } + + func test_synchronize_losingCloudAttachmentPlaceholder_waitsBeforeConflictCleanup() async throws { + // Given + let conversationId = UUID() + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let cloudAttachment = attachment(fileName: "cloud.png", conversationId: conversationId) + let cloudMessage = ChatMessage(role: .user, content: "Cloud", attachments: [cloudAttachment]) + let cloudConversation = Conversation( + id: conversationId, + modelId: "model", + messages: [cloudMessage], + updatedAt: timestamp + ) + let localConversation = Conversation( + id: conversationId, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Local")], + updatedAt: timestamp.addingTimeInterval(1) + ) + try writeCloudConversationData( + SyncJSONCoding.makeEncoder().encode(cloudConversation), + id: conversationId + ) + let cloudAttachmentURL = cloudDocumentsURL.appendingPathComponent(cloudAttachment.fileRelativePath) + try FileManager.default.createDirectory( + at: cloudAttachmentURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let placeholderURL = cloudAttachmentURL.deletingLastPathComponent() + .appendingPathComponent(".\(cloudAttachmentURL.lastPathComponent).icloud") + try Data().write(to: placeholderURL) + try writeLocalConversation(localConversation, documentsURL: localDocumentsURL) + let repository = makeRepository() + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .pendingDownload) + XCTAssertTrue(FileManager.default.fileExists(atPath: placeholderURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: cloudConversationURL(for: conversationId).path)) + } + + func test_synchronize_unpublishedLocalAttachment_doesNotCleanPendingBytes() async throws { + // Given + let conversationId = UUID() + let pendingAttachment = attachment(fileName: "pending.png", conversationId: conversationId) + let pendingURL = localDocumentsURL.appendingPathComponent(pendingAttachment.fileRelativePath) + try FileManager.default.createDirectory( + at: pendingURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let bytes = Data("pending".utf8) + try bytes.write(to: pendingURL) + let repository = makeRepository() + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + XCTAssertEqual(try Data(contentsOf: pendingURL), bytes) + } + + func test_save_localLegacyInlineAttachment_materializesBeforeJSONRewrite() async throws { + // Given + let bytes = Data([0x01, 0x02, 0x03]) + let conversation = legacyConversation() + let legacyData = try legacyData(for: conversation, attachmentData: bytes) + try writeLocalConversationData(legacyData, id: conversation.id) + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = false + let repository = ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: CloudSyncManager( + containerProvider: FixedCloudContainerProvider(url: cloudContainerURL) + ), + attachmentRepository: AttachmentRepository(baseURL: localDocumentsURL), + baseDirectory: localDocumentsURL + ) + let localConversations = try await repository.loadLocal() + let base = try XCTUnwrap(localConversations.first) + var updated = base + updated.title = "Updated" + updated.updatedAt = Date() + + // When + try await repository.save(updated, expectedBase: base) + + // Then + let savedConversations = try await repository.loadLocal() + let saved = try XCTUnwrap(savedConversations.first) + let attachment = try XCTUnwrap(saved.messages.first?.attachments.first) + XCTAssertFalse(attachment.fileRelativePath.isEmpty) + XCTAssertEqual( + try Data(contentsOf: localDocumentsURL.appendingPathComponent(attachment.fileRelativePath)), + bytes + ) + } + + func test_synchronize_legacyPendingFolder_rehomesAttachmentUnderParentConversation() async throws { + // Given + let conversationId = UUID() + let pendingId = UUID() + let attachmentId = UUID() + let fileName = "\(attachmentId.uuidString).png" + let oldPath = "Attachments/\(pendingId.uuidString)/\(fileName)" + let attachment = ChatMessage.Attachment( + id: attachmentId, + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: oldPath + ) + let conversation = Conversation( + id: conversationId, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Image", attachments: [attachment])] + ) + try writeLocalConversation(conversation, documentsURL: localDocumentsURL) + let bytes = Data("legacy-pending".utf8) + let oldURL = localDocumentsURL.appendingPathComponent(oldPath) + try FileManager.default.createDirectory( + at: oldURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try bytes.write(to: oldURL) + let repository = makeRepository() + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + let savedConversations = try await repository.loadLocal() + let saved = try XCTUnwrap(savedConversations.first) + let savedAttachment = try XCTUnwrap(saved.messages.first?.attachments.first) + XCTAssertTrue(savedAttachment.fileRelativePath.contains(conversationId.uuidString)) + XCTAssertEqual( + try Data(contentsOf: localDocumentsURL.appendingPathComponent(savedAttachment.fileRelativePath)), + bytes + ) + } + + func test_synchronize_remoteTombstone_removesLocalAttachmentWithoutRecoveryCopy() async throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let conversation = conversationWithAttachment(updatedAt: timestamp) + try writeLocalConversation(conversation, documentsURL: localDocumentsURL) + let attachment = try XCTUnwrap(conversation.messages.first?.attachments.first) + try writeAttachment(Data("deleted".utf8), attachment: attachment, documentsURL: localDocumentsURL) + let tombstone = ConversationTombstone( + conversationId: conversation.id, + deletedAt: timestamp.addingTimeInterval(1) + ) + let tombstoneURL = cloudDocumentsURL + .appendingPathComponent("ConversationTombstones", isDirectory: true) + .appendingPathComponent("\(conversation.id.uuidString).json") + try FileManager.default.createDirectory( + at: tombstoneURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try SyncJSONCoding.makeEncoder().encode(tombstone).write(to: tombstoneURL) + let repository = makeRepository() + + // When + let result = await repository.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + XCTAssertFalse(FileManager.default.fileExists( + atPath: localDocumentsURL.appendingPathComponent(attachment.fileRelativePath).path + )) + let recoveryURL = localDocumentsURL + .appendingPathComponent("ConversationRecovery/Attachments", isDirectory: true) + .appendingPathComponent(conversation.id.uuidString, isDirectory: true) + XCTAssertFalse(FileManager.default.fileExists(atPath: recoveryURL.path)) + } +} + +// MARK: - Private + +private extension ConversationLegacySyncTests { + var cloudDocumentsURL: URL { + cloudContainerURL.appendingPathComponent("Documents", isDirectory: true) + } + + func makeRepository(at documentsURL: URL? = nil) -> ConversationRepository { + let documentsURL = documentsURL ?? localDocumentsURL + let settingsManager = MockSettingsManager() + settingsManager.isCloudSyncEnabled = true + return ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: CloudSyncManager( + containerProvider: FixedCloudContainerProvider(url: cloudContainerURL) + ), + attachmentRepository: AttachmentRepository(baseURL: documentsURL), + baseDirectory: documentsURL + ) + } + + func legacyConversation() -> Conversation { + let conversationId = UUID() + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "legacy.png", + mimeType: "image/png", + fileRelativePath: "" + ) + return Conversation( + id: conversationId, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Legacy", attachments: [attachment])] + ) + } + + func conversationWithAttachment(updatedAt: Date) -> Conversation { + let conversationId = UUID() + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: "Attachments/\(conversationId.uuidString)/image.png" + ) + return Conversation( + id: conversationId, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Image", attachments: [attachment])], + updatedAt: updatedAt + ) + } + + func attachment(fileName: String, conversationId: UUID) -> ChatMessage.Attachment { + ChatMessage.Attachment( + type: .image, + fileName: fileName, + mimeType: "image/png", + fileRelativePath: "Attachments/\(conversationId.uuidString)/\(fileName)" + ) + } + + func legacyData(for conversation: Conversation, attachmentData: Data) throws -> Data { + let encoded = try SyncJSONCoding.makeEncoder().encode(conversation) + var root = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + var messages = try XCTUnwrap(root["messages"] as? [[String: Any]]) + var attachments = try XCTUnwrap(messages.first?["attachments"] as? [[String: Any]]) + attachments[0].removeValue(forKey: "fileRelativePath") + attachments[0].removeValue(forKey: "mimeType") + attachments[0]["data"] = attachmentData.base64EncodedString() + messages[0]["attachments"] = attachments + root["messages"] = messages + return try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + } + + func writeCloudConversationData(_ data: Data, id: UUID) throws { + let url = cloudConversationURL(for: id) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + + func writeLocalConversationData(_ data: Data, id: UUID) throws { + let url = localDocumentsURL + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(id.uuidString).json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + + func writeLocalConversation(_ conversation: Conversation, documentsURL: URL) throws { + let directory = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try SyncJSONCoding.makeEncoder().encode(conversation) + let fileURL = directory.appendingPathComponent("\(conversation.id.uuidString).json") + try data.write(to: fileURL, options: .atomic) + } + + func writeAttachment( + _ data: Data, + attachment: ChatMessage.Attachment, + documentsURL: URL + ) throws { + let url = documentsURL.appendingPathComponent(attachment.fileRelativePath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + + func cloudConversationURL(for id: UUID) -> URL { + cloudDocumentsURL + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(id.uuidString).json") + } + + func recoveryData() throws -> [Data] { + let directory = localDocumentsURL.appendingPathComponent("ConversationRecovery", isDirectory: true) + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + return try files.filter { $0.pathExtension == "json" }.map { try Data(contentsOf: $0) } + } + + func attachmentRecoveryData(for conversationId: UUID) throws -> [Data] { + let directory = localDocumentsURL + .appendingPathComponent("ConversationRecovery/Attachments", isDirectory: true) + .appendingPathComponent(conversationId.uuidString, isDirectory: true) + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + return try files.map { try Data(contentsOf: $0) } + } +} diff --git a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Backup.swift b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Backup.swift index 881d08ad..d0151f2e 100644 --- a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Backup.swift +++ b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Backup.swift @@ -23,6 +23,10 @@ extension ConversationListViewModelTests { // When sut.send(.exportBackupTapped) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.backupData == backupData + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -50,6 +54,10 @@ extension ConversationListViewModelTests { // When let backupData = Data("backup".utf8) sut.send(.importBackupData(backupData)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.importResult == result + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -70,6 +78,10 @@ extension ConversationListViewModelTests { // When sut.send(.importBackupData(Data("invalid".utf8))) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.errorMessage != nil + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -93,6 +105,10 @@ extension ConversationListViewModelTests { sut.send(.viewAppeared) try await Task.sleep(for: .milliseconds(100)) sut.send(.importBackupData(Data())) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.importResult != nil + } // When sut.send(.importResultConsumed) diff --git a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+CloudSync.swift b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+CloudSync.swift index 7b340ffb..dbda31c0 100644 --- a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+CloudSync.swift +++ b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+CloudSync.swift @@ -24,6 +24,11 @@ extension ConversationListViewModelTests { // When sut.send(.refreshTapped) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return self.mockSyncConversations.executeCallCount == 2 + && loadedState.conversations == [remoteConversation] + } // Then XCTAssertEqual(mockSyncConversations.executeCallCount, 2) @@ -34,7 +39,7 @@ extension ConversationListViewModelTests { XCTAssertEqual(loadedState.conversations, [remoteConversation]) } - func test_cloudChangeNotification_synchronizesBeforeReloadingConversations() async throws { + func test_conversationUpdateNotification_reloadsWithoutStartingSynchronization() async throws { // Given mockLoadConversations.result = .success([]) mockFetchModels.result = .success([]) @@ -44,11 +49,14 @@ extension ConversationListViewModelTests { mockLoadConversations.result = .success([remoteConversation]) // When - NotificationCenter.default.post(name: .conversationCloudDidChange, object: nil) - try await Task.sleep(for: .milliseconds(600)) + NotificationCenter.default.post(name: .conversationDidUpdate, object: nil) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations == [remoteConversation] + } // Then - XCTAssertEqual(mockSyncConversations.executeCallCount, 2) + XCTAssertEqual(mockSyncConversations.executeCallCount, 1) guard case .loaded(let loadedState) = sut.state else { XCTFail("Expected loaded state") return @@ -61,10 +69,11 @@ extension ConversationListViewModelTests { mockLoadConversations.result = .success([]) mockFetchModels.result = .success([]) mockSyncConversations.results = [.pendingDownload, .synchronized] + mockSettingsManager.isCloudSyncEnabled = true // When sut.send(.viewAppeared) - try await Task.sleep(for: .milliseconds(1_100)) + await waitUntil { self.mockSyncConversations.executeCallCount == 2 } // Then XCTAssertEqual(mockSyncConversations.executeCallCount, 2) diff --git a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Pinning.swift b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Pinning.swift index 93646f1c..cb5057e8 100644 --- a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Pinning.swift +++ b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Pinning.swift @@ -23,6 +23,10 @@ extension ConversationListViewModelTests { // When sut.send(.pinToggled(conversation.id)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations.first?.isPinned == true + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -44,6 +48,10 @@ extension ConversationListViewModelTests { // When sut.send(.pinToggled(conversation.id)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations.first?.isPinned == false + } // Then XCTAssertEqual(mockPinConversation.executedIsPinned, false) diff --git a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Rename.swift b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Rename.swift index 5a49123d..789fa775 100644 --- a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Rename.swift +++ b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Rename.swift @@ -23,6 +23,10 @@ extension ConversationListViewModelTests { // When sut.send(.titleEdited(conversation.id, "New Title")) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations.first?.title == "New Title" + } // Then XCTAssertEqual(mockRenameConversation.capturedId, conversation.id) @@ -45,6 +49,10 @@ extension ConversationListViewModelTests { // When sut.send(.titleEdited(conversation.id, " Trimmed ")) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations.first?.title == "Trimmed" + } // Then XCTAssertEqual(mockRenameConversation.capturedTitle, "Trimmed") @@ -66,6 +74,7 @@ extension ConversationListViewModelTests { // When sut.send(.titleEdited(conversation.id, " ")) + for _ in 0..<10 { await Task.yield() } // Then XCTAssertNil(mockRenameConversation.capturedId) @@ -94,6 +103,10 @@ extension ConversationListViewModelTests { // When sut.send(.titleEdited(conversation.id, "New Title")) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.errorMessage == "Save failed" + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -115,6 +128,7 @@ extension ConversationListViewModelTests { // When — send an ID that does not exist in state sut.send(.titleEdited(UUID(), "Irrelevant")) + for _ in 0..<10 { await Task.yield() } // Then XCTAssertNil(mockRenameConversation.capturedId) diff --git a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Tags.swift b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Tags.swift index 1b2f52dd..df18fc07 100644 --- a/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Tags.swift +++ b/openclient-llm-test/Features/Chat/ConversationListViewModelTests+Tags.swift @@ -27,6 +27,10 @@ extension ConversationListViewModelTests { // When sut.send(.tagsUpdated(conversation.id, tags)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations.first?.tags == tags + } // Then XCTAssertEqual(mockUpdateTags.executedId, conversation.id) @@ -173,6 +177,11 @@ extension ConversationListViewModelTests { // When sut.send(.tagsUpdated(conv1.id, [])) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.activeTagFilter == nil + && loadedState.conversations.first(where: { $0.id == conv1.id })?.tags.isEmpty == true + } // Then guard case .loaded(let loadedState) = sut.state else { diff --git a/openclient-llm-test/Features/Chat/ConversationListViewModelTests.swift b/openclient-llm-test/Features/Chat/ConversationListViewModelTests.swift index 096a9937..2dcd55c9 100644 --- a/openclient-llm-test/Features/Chat/ConversationListViewModelTests.swift +++ b/openclient-llm-test/Features/Chat/ConversationListViewModelTests.swift @@ -22,7 +22,6 @@ final class ConversationListViewModelTests: XCTestCase { var mockFetchModels: MockFetchModelsUseCase! var mockSyncConversations: MockSyncConversationsUseCase! var mockSettingsManager: MockSettingsManager! - var mockConversationCloudObserver: MockConversationCloudObserver! var mockExportBackup: MockExportBackupUseCase! var mockImportConversations: MockImportConversationsUseCase! @@ -39,7 +38,6 @@ final class ConversationListViewModelTests: XCTestCase { mockFetchModels = MockFetchModelsUseCase() mockSyncConversations = MockSyncConversationsUseCase() mockSettingsManager = MockSettingsManager() - mockConversationCloudObserver = MockConversationCloudObserver() mockExportBackup = MockExportBackupUseCase() mockImportConversations = MockImportConversationsUseCase() sut = ConversationListViewModel( @@ -53,7 +51,7 @@ final class ConversationListViewModelTests: XCTestCase { exportBackupUseCase: mockExportBackup, importConversationsUseCase: mockImportConversations, settingsManager: mockSettingsManager, - conversationCloudObserver: mockConversationCloudObserver + cloudRetryDelays: [.zero] ) } @@ -67,7 +65,6 @@ final class ConversationListViewModelTests: XCTestCase { mockFetchModels = nil mockSyncConversations = nil mockSettingsManager = nil - mockConversationCloudObserver = nil mockExportBackup = nil mockImportConversations = nil @@ -175,6 +172,10 @@ final class ConversationListViewModelTests: XCTestCase { // When sut.send(.deleteConversation(conversation.id)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations.isEmpty + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -202,6 +203,10 @@ final class ConversationListViewModelTests: XCTestCase { // When sut.send(.deleteConversation(conversation.id)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.selectedConversation == nil + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -224,6 +229,10 @@ final class ConversationListViewModelTests: XCTestCase { // When sut.send(.deleteConversation(conversation.id)) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.errorMessage != nil + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -249,6 +258,10 @@ final class ConversationListViewModelTests: XCTestCase { // When sut.refresh() + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.conversations.count == 1 + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -361,3 +374,19 @@ final class ConversationListViewModelTests: XCTestCase { XCTAssertTrue(loadedState.filteredConversations.isEmpty) } } + +// MARK: - Helpers + +extension ConversationListViewModelTests { + func waitUntil( + _ condition: @MainActor () -> Bool, + file: StaticString = #filePath, + line: UInt = #line + ) async { + for _ in 0..<100 { + if condition() { return } + await Task.yield() + } + XCTFail("Condition was not satisfied", file: file, line: line) + } +} diff --git a/openclient-llm-test/Features/Chat/ConversationLocalTransactionTests.swift b/openclient-llm-test/Features/Chat/ConversationLocalTransactionTests.swift new file mode 100644 index 00000000..c28c7b1f --- /dev/null +++ b/openclient-llm-test/Features/Chat/ConversationLocalTransactionTests.swift @@ -0,0 +1,176 @@ +// +// ConversationLocalTransactionTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class ConversationLocalTransactionTests: XCTestCase { + func test_loadLocal_abandonedTransaction_restoresOriginalConversation() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let storage = ConversationStorage( + cloudSyncManager: MockCloudSyncManager(), + attachmentRepository: MockAttachmentRepository(), + baseDirectory: documentsURL + ) + let conversation = Conversation(title: "Original", modelId: "model") + try await storage.save(conversation) + _ = try ConversationLocalTransaction(fileManager: .default, documentsURL: documentsURL) + var mutated = conversation + mutated.title = "Partial write" + let conversationURL = documentsURL + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(conversation.id.uuidString).json") + try SyncJSONCoding.makeEncoder().encode(mutated).write(to: conversationURL, options: .atomic) + + // When + let restored = try await storage.loadLocal() + + // Then + XCTAssertEqual(restored.first?.title, "Original") + } + + func test_recoverPendingTransaction_restoresMetadataPendingBaseAndAttachmentBytes() throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let conversationId = UUID() + let attachmentKey = CloudAttachmentKey(conversationId: conversationId, fileName: "attachment.bin") + let files: [String: Data] = [ + "ConversationTombstones.json": Data("tombstones".utf8), + "ConversationDeleteAll.json": Data("marker".utf8), + "ConversationPendingMutations/\(conversationId.uuidString).json": Data("pending".utf8), + ConversationAttachmentPath.relativePath(for: attachmentKey): Data("attachment".utf8) + ] + for (path, data) in files { + let url = documentsURL.appendingPathComponent(path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url) + } + _ = try ConversationLocalTransaction( + fileManager: .default, + documentsURL: documentsURL, + attachmentKeys: [attachmentKey] + ) + for path in files.keys { + try FileManager.default.removeItem(at: documentsURL.appendingPathComponent(path)) + } + + // When + try ConversationLocalTransaction.recoverPendingTransactions( + fileManager: .default, + documentsURL: documentsURL + ) + + // Then + for (path, data) in files { + XCTAssertEqual(try Data(contentsOf: documentsURL.appendingPathComponent(path)), data) + } + } + + func test_loadLocal_importConversationWrittenBeforeSaveError_rollsBackInterruptedBatch() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let storage = ConversationStorage( + cloudSyncManager: MockCloudSyncManager(), + attachmentRepository: MockAttachmentRepository(), + baseDirectory: documentsURL + ) + let existing = Conversation(title: "Existing", modelId: "model") + try await storage.save(existing) + _ = try ConversationLocalTransaction(fileManager: .default, documentsURL: documentsURL) + let imported = Conversation(title: "Committed before error", modelId: "model") + let importedURL = documentsURL + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(imported.id.uuidString).json") + do { + try SyncJSONCoding.makeEncoder().encode(imported).write(to: importedURL, options: .atomic) + throw NSError(domain: "CommittedSave", code: 1) + } catch { + XCTAssertTrue(FileManager.default.fileExists(atPath: importedURL.path)) + } + + // When + let restored = try await storage.loadLocal() + + // Then + XCTAssertEqual(restored.map(\.id), [existing.id]) + XCTAssertEqual(restored.first?.title, "Existing") + } + + func test_commit_changedConversationJSON_rejectsCommitAndKeepsRecovery() throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let conversationsURL = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + try FileManager.default.createDirectory(at: conversationsURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let originalData = try SyncJSONCoding.makeEncoder().encode( + Conversation(title: "Original", modelId: "model") + ) + let original = try SyncJSONCoding.makeDecoder().decode(Conversation.self, from: originalData) + let url = conversationsURL.appendingPathComponent("\(original.id.uuidString).json") + try originalData.write(to: url) + let transaction = try ConversationLocalTransaction(fileManager: .default, documentsURL: documentsURL) + var changed = original + changed.title = "Changed" + try SyncJSONCoding.makeEncoder().encode(changed).write(to: url, options: .atomic) + + // When / Then + XCTAssertThrowsError(try transaction.commit(verifying: .init(conversations: [original.id: original]))) + try ConversationLocalTransaction.recoverPendingTransactions( + fileManager: .default, + documentsURL: documentsURL + ) + let restored = try SyncJSONCoding.makeDecoder().decode(Conversation.self, from: Data(contentsOf: url)) + XCTAssertEqual(restored, original) + } + + func test_commit_changedPendingBaseOrAttachmentBytes_rejectsCommit() throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let conversation = Conversation(modelId: "model") + let pendingURL = documentsURL + .appendingPathComponent("ConversationPendingMutations", isDirectory: true) + .appendingPathComponent("\(conversation.id.uuidString).json") + let key = CloudAttachmentKey(conversationId: conversation.id, fileName: "attachment.bin") + let attachmentURL = documentsURL.appendingPathComponent(ConversationAttachmentPath.relativePath(for: key)) + try FileManager.default.createDirectory( + at: pendingURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: attachmentURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try SyncJSONCoding.makeEncoder().encode(conversation).write(to: pendingURL) + let originalBytes = Data("original".utf8) + try originalBytes.write(to: attachmentURL) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let transaction = try ConversationLocalTransaction( + fileManager: .default, + documentsURL: documentsURL, + attachmentKeys: [key] + ) + try Data("changed".utf8).write(to: attachmentURL, options: .atomic) + + // When / Then + XCTAssertThrowsError(try transaction.commit(verifying: .init( + pendingMutationBases: [conversation.id: conversation], + attachments: [key: originalBytes] + ))) + } +} diff --git a/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+DeletionVisibility.swift b/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+DeletionVisibility.swift new file mode 100644 index 00000000..41c39cc3 --- /dev/null +++ b/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+DeletionVisibility.swift @@ -0,0 +1,56 @@ +// +// ConversationRepositorySyncTests+DeletionVisibility.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +extension ConversationRepositorySyncTests { + func test_loadLocal_tombstoneWrittenBeforePayloadRemoval_hidesDeletedConversation() async throws { + // Given + let conversation = Conversation(modelId: "model") + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + let tombstone = ConversationTombstone( + conversationId: conversation.id, + deletedAt: conversation.updatedAt.addingTimeInterval(1) + ) + try SyncJSONCoding.makeEncoder().encode([tombstone]).write( + to: directory.appendingPathComponent("ConversationTombstones.json"), + options: .atomic + ) + + // When + let conversations = try await sut.loadLocal() + + // Then + XCTAssertTrue(conversations.isEmpty) + let payloadURL = directory + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(conversation.id.uuidString).json") + XCTAssertTrue(FileManager.default.fileExists(atPath: payloadURL.path)) + } + + func staleLocalAndNewerCloudConversation() -> (local: Conversation, cloud: Conversation) { + let id = UUID() + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let local = Conversation( + id: id, + modelId: "model", + messages: [ChatMessage(role: .user, content: "Local")], + updatedAt: timestamp + ) + let cloud = Conversation( + id: id, + modelId: "model", + messages: local.messages + [ChatMessage(role: .assistant, content: "Cloud")], + updatedAt: timestamp.addingTimeInterval(1) + ) + return (local, cloud) + } +} diff --git a/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+Mutations.swift b/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+Mutations.swift new file mode 100644 index 00000000..c9982a4d --- /dev/null +++ b/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests+Mutations.swift @@ -0,0 +1,479 @@ +// +// ConversationRepositorySyncTests+Mutations.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +extension ConversationRepositorySyncTests { + func test_setPinned_staleLocalConversation_mutatesReconciledCloudMessages() async throws { + // Given + let (local, cloud) = staleLocalAndNewerCloudConversation() + settingsManager.isCloudSyncEnabled = false + try await sut.save(local) + cloudSyncManager.cloudConversations = [cloud] + settingsManager.isCloudSyncEnabled = true + + // When + let updated = try await sut.setPinned(true, conversationId: local.id) + + // Then + XCTAssertEqual(updated?.messages.count, 2) + XCTAssertEqual(updated?.isPinned, true) + XCTAssertEqual(cloudSyncManager.cloudConversations.first?.messages.count, 2) + } + + func test_rename_staleLocalConversation_mutatesReconciledCloudMessages() async throws { + // Given + let (local, cloud) = staleLocalAndNewerCloudConversation() + settingsManager.isCloudSyncEnabled = false + try await sut.save(local) + cloudSyncManager.cloudConversations = [cloud] + settingsManager.isCloudSyncEnabled = true + + // When + let updated = try await sut.rename(local.id, title: "Renamed") + + // Then + XCTAssertEqual(updated?.title, "Renamed") + XCTAssertEqual(updated?.messages.count, 2) + XCTAssertEqual(cloudSyncManager.cloudConversations.first?.messages.count, 2) + } + + func test_updateTags_staleLocalConversation_mutatesReconciledCloudMessages() async throws { + // Given + let (local, cloud) = staleLocalAndNewerCloudConversation() + settingsManager.isCloudSyncEnabled = false + try await sut.save(local) + cloudSyncManager.cloudConversations = [cloud] + settingsManager.isCloudSyncEnabled = true + + // When + let updated = try await sut.updateTags(local.id, tags: [ConversationTag(name: "Work", color: .blue)]) + + // Then + XCTAssertEqual(updated?.tags.map(\.name), ["Work"]) + XCTAssertEqual(updated?.messages.count, 2) + XCTAssertEqual(cloudSyncManager.cloudConversations.first?.messages.count, 2) + } + + func test_rename_pendingCloudMetadata_persistsLocalMutationForLaterReconciliation() async throws { + // Given + let conversation = Conversation(title: "Original", modelId: "model") + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + settingsManager.isCloudSyncEnabled = true + cloudSyncManager.pendingConversationDownloads = true + + // When + _ = try await sut.rename(conversation.id, title: "Renamed") + + // Then + let localConversation = try await sut.loadLocal().first + XCTAssertEqual(localConversation?.title, "Renamed") + XCTAssertTrue(cloudSyncManager.syncedConversations.isEmpty) + } + + func test_save_favouriteOnStaleLocalMessage_preservesNewerCloudMessages() async throws { + // Given + let (local, cloud) = staleLocalAndNewerCloudConversation() + settingsManager.isCloudSyncEnabled = false + try await sut.save(local) + cloudSyncManager.cloudConversations = [cloud] + settingsManager.isCloudSyncEnabled = true + var favouriteUpdate = local + favouriteUpdate.messages[0].isFavourite = true + favouriteUpdate.updatedAt = Date() + + // When + try await sut.save(favouriteUpdate) + + // Then + let cloudConversation = try XCTUnwrap(cloudSyncManager.cloudConversations.first) + XCTAssertEqual(cloudConversation.messages.count, 2) + XCTAssertTrue(cloudConversation.messages[0].isFavourite) + } + + func test_save_modelPromptAndParametersOnStaleLocal_preservesNewerCloudMessages() async throws { + // Given + let (local, cloud) = staleLocalAndNewerCloudConversation() + settingsManager.isCloudSyncEnabled = false + try await sut.save(local) + cloudSyncManager.cloudConversations = [cloud] + settingsManager.isCloudSyncEnabled = true + var metadataUpdate = local + metadataUpdate.modelId = "new-model" + metadataUpdate.systemPrompt = "New prompt" + metadataUpdate.modelParameters = ModelParameters(temperature: 0.2, maxTokens: 100) + metadataUpdate.updatedAt = Date() + + // When + try await sut.save(metadataUpdate) + + // Then + let cloudConversation = try XCTUnwrap(cloudSyncManager.cloudConversations.first) + XCTAssertEqual(cloudConversation.messages.count, 2) + XCTAssertEqual(cloudConversation.modelId, "new-model") + XCTAssertEqual(cloudConversation.systemPrompt, "New prompt") + XCTAssertEqual(cloudConversation.modelParameters, metadataUpdate.modelParameters) + } + + func test_delete_cloudApplyUnavailable_throwsRetryableSyncError() async throws { + // Given + let conversation = Conversation(modelId: "model") + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + settingsManager.isCloudSyncEnabled = true + cloudSyncManager.syncError = CloudSyncError.containerIdentityChanged + + // When + do { + try await sut.delete(conversation.id) + XCTFail("Expected cloud deletion to remain retryable") + } catch { + // Then + XCTAssertEqual(error as? ConversationSyncOperationError, .unavailable) + let localConversations = try await sut.loadLocal() + XCTAssertEqual(localConversations.map(\.id), [conversation.id]) + } + } + + func test_delete_newerCloudRevision_createsBarrierAndDeletesBothCopies() async throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let local = Conversation(modelId: "model", updatedAt: timestamp) + settingsManager.isCloudSyncEnabled = false + try await sut.save(local) + let cloud = Conversation( + id: local.id, + title: "Newer cloud revision", + modelId: "model", + updatedAt: timestamp.addingTimeInterval(60) + ) + cloudSyncManager.cloudConversations = [cloud] + settingsManager.isCloudSyncEnabled = true + + // When + try await sut.delete(local.id) + + // Then + let localConversations = try await sut.loadLocal() + XCTAssertTrue(localConversations.isEmpty) + XCTAssertTrue(cloudSyncManager.cloudConversations.isEmpty) + let tombstone = try XCTUnwrap(cloudSyncManager.cloudTombstones.first) + XCTAssertGreaterThan(tombstone.deletedAt, cloud.updatedAt) + } + + func test_delete_snapshotPreflightFails_preservesPayloadAndDoesNotReportSuccess() async throws { + // Given + let conversation = Conversation(modelId: "model") + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + settingsManager.isCloudSyncEnabled = true + cloudSyncManager.loadError = CloudSyncError.containerIdentityChanged + + // When + do { + try await sut.delete(conversation.id) + XCTFail("Expected cloud preflight failure") + } catch { + // Then + let localIds = try await sut.loadLocal().map(\.id) + XCTAssertEqual(error as? ConversationSyncOperationError, .unavailable) + XCTAssertEqual(localIds, [conversation.id]) + XCTAssertTrue(cloudSyncManager.cloudTombstones.isEmpty) + } + } + + func test_deleteAll_newestCloudRevision_createsNewerPurgeBarrier() async throws { + // Given + let cloud = Conversation( + modelId: "model", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + cloudSyncManager.cloudConversations = [cloud] + + // When + try await sut.deleteAll() + + // Then + XCTAssertTrue(cloudSyncManager.cloudConversations.isEmpty) + let marker = try XCTUnwrap(cloudSyncManager.cloudDeleteAllMarker) + XCTAssertGreaterThan(marker.deletedAt, cloud.updatedAt) + } + + func test_synchronize_symlinkedAttachmentRoot_failsWithoutTouchingExternalData() async throws { + // Given + let outsideDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: outsideDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outsideDirectory) } + let sentinelURL = outsideDirectory.appendingPathComponent("sentinel") + let sentinelData = Data("sentinel".utf8) + try sentinelData.write(to: sentinelURL) + try FileManager.default.createSymbolicLink( + at: directory.appendingPathComponent("Attachments", isDirectory: true), + withDestinationURL: outsideDirectory + ) + + // When + let result = await sut.synchronize() + + // Then + XCTAssertEqual(result, .failed) + XCTAssertEqual(try Data(contentsOf: sentinelURL), sentinelData) + } + + func test_save_snapshotCapturedBeforeDelete_doesNotResurrectConversation() async throws { + // Given + let conversation = Conversation(modelId: "model") + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + let initialConversations = try await sut.loadLocal() + let base = try XCTUnwrap(initialConversations.first) + try await sut.delete(conversation.id) + var staleSave = base + staleSave.messages.append(ChatMessage(role: .user, content: "Stale")) + staleSave.updatedAt = Date() + + // When + do { + try await sut.save(staleSave, expectedBase: base) + XCTFail("Expected stale save rejection") + } catch { + // Then + XCTAssertEqual(error as? CloudSyncError, .staleConversationRevision) + let localConversations = try await sut.loadLocal() + XCTAssertTrue(localConversations.isEmpty) + } + } + + func test_save_snapshotBeforeLocalRename_preservesInterveningRename() async throws { + // Given + let conversation = Conversation(modelId: "model", messages: [ChatMessage(role: .user, content: "Hello")]) + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + let initialConversations = try await sut.loadLocal() + let base = try XCTUnwrap(initialConversations.first) + _ = try await sut.rename(conversation.id, title: "Renamed") + var favouriteUpdate = base + favouriteUpdate.messages[0].isFavourite = true + favouriteUpdate.updatedAt = Date() + + // When + try await sut.save(favouriteUpdate, expectedBase: base) + + // Then + let savedConversations = try await sut.loadLocal() + let saved = try XCTUnwrap(savedConversations.first) + XCTAssertEqual(saved.title, "Renamed") + XCTAssertTrue(saved.messages[0].isFavourite) + } + + func test_save_baseOlderThanRemoteTombstone_rejectsStaleSave() async throws { + // Given + let conversation = Conversation(modelId: "model") + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + let initialConversations = try await sut.loadLocal() + let base = try XCTUnwrap(initialConversations.first) + cloudSyncManager.cloudTombstones = [ + ConversationTombstone( + conversationId: conversation.id, + deletedAt: base.updatedAt.addingTimeInterval(1) + ) + ] + settingsManager.isCloudSyncEnabled = true + var staleSave = base + staleSave.systemPrompt = "Stale prompt" + staleSave.updatedAt = Date() + + // When + do { + try await sut.save(staleSave, expectedBase: base) + XCTFail("Expected remote deletion to reject stale save") + } catch { + // Then + XCTAssertEqual(error as? CloudSyncError, .staleConversationRevision) + } + } + + func test_save_newConversationWithExpectedBase_persistsFirstRevision() async throws { + // Given + settingsManager.isCloudSyncEnabled = false + let base = Conversation(modelId: "model") + var firstRevision = base + firstRevision.messages = [ChatMessage(role: .user, content: "First message")] + firstRevision.updatedAt = Date() + + // When + try await sut.save(firstRevision, expectedBase: base) + + // Then + let savedConversations = try await sut.loadLocal() + let saved = try XCTUnwrap(savedConversations.first) + XCTAssertEqual(saved.id, base.id) + XCTAssertEqual(saved.messages.map(\.content), ["First message"]) + } + + func test_save_concurrentAppends_preservesBothDeviceHistories() async throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let firstMessage = ChatMessage(role: .user, content: "Initial", timestamp: timestamp) + let base = Conversation( + modelId: "model", + messages: [firstMessage], + createdAt: timestamp, + updatedAt: timestamp + ) + settingsManager.isCloudSyncEnabled = false + try await sut.save(base) + let remoteMessage = ChatMessage( + role: .assistant, + content: "Remote append", + timestamp: timestamp.addingTimeInterval(1) + ) + var remote = base + remote.messages.append(remoteMessage) + remote.updatedAt = timestamp.addingTimeInterval(1) + cloudSyncManager.cloudConversations = [remote] + settingsManager.isCloudSyncEnabled = true + let localMessage = ChatMessage( + role: .user, + content: "Local append", + timestamp: timestamp.addingTimeInterval(2) + ) + var local = base + local.messages.append(localMessage) + local.updatedAt = timestamp.addingTimeInterval(2) + + // When + let firstSaved = try await sut.save(local, expectedBase: base) + var followUp = local + followUp.messages.append(ChatMessage( + role: .assistant, + content: "Follow-up", + timestamp: timestamp.addingTimeInterval(3) + )) + followUp.updatedAt = timestamp.addingTimeInterval(3) + let secondSaved = try await sut.save(followUp, expectedBase: local) + + // Then + let cloud = try XCTUnwrap(cloudSyncManager.cloudConversations.first) + let expected = Set(["Initial", "Remote append", "Local append", "Follow-up"]) + XCTAssertEqual(Set(firstSaved.messages.map(\.content)), expected.subtracting(["Follow-up"])) + XCTAssertEqual(Set(secondSaved.messages.map(\.content)), expected) + XCTAssertEqual(Set(cloud.messages.map(\.content)), expected) + } + + func test_save_concurrentMessageEdits_rejectsWithoutOverwritingRemote() async throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let messageId = UUID() + let baseMessage = ChatMessage(id: messageId, role: .user, content: "Original", timestamp: timestamp) + let base = Conversation(modelId: "model", messages: [baseMessage], updatedAt: timestamp) + settingsManager.isCloudSyncEnabled = false + try await sut.save(base) + var remote = base + remote.messages[0].content = "Remote edit" + remote.updatedAt = timestamp.addingTimeInterval(1) + cloudSyncManager.cloudConversations = [remote] + settingsManager.isCloudSyncEnabled = true + var local = base + local.messages[0].content = "Local edit" + local.updatedAt = timestamp.addingTimeInterval(2) + + // When + do { + try await sut.save(local, expectedBase: base) + XCTFail("Expected concurrent edit rejection") + } catch { + // Then + XCTAssertEqual(error as? CloudSyncError, .staleConversationRevision) + XCTAssertEqual(cloudSyncManager.cloudConversations.first?.messages.first?.content, "Remote edit") + let localConversations = try await sut.loadLocal() + XCTAssertEqual(localConversations.first?.messages.first?.content, "Original") + } + } + + func test_synchronize_pendingOfflineSaveWithRemoteTombstone_doesNotResurrectConversation() async throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let base = Conversation(modelId: "model", updatedAt: timestamp) + settingsManager.isCloudSyncEnabled = false + try await sut.save(base) + settingsManager.isCloudSyncEnabled = true + cloudSyncManager.cloudAvailable = false + var offlineRevision = base + offlineRevision.messages.append(ChatMessage(role: .user, content: "Offline change")) + offlineRevision.updatedAt = timestamp.addingTimeInterval(2) + try await sut.save(offlineRevision, expectedBase: base) + cloudSyncManager.cloudAvailable = true + cloudSyncManager.cloudTombstones = [ + ConversationTombstone( + conversationId: base.id, + deletedAt: timestamp.addingTimeInterval(1) + ) + ] + + // When + let result = await sut.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + let localConversations = try await sut.loadLocal() + XCTAssertTrue(localConversations.isEmpty) + XCTAssertTrue(cloudSyncManager.cloudConversations.isEmpty) + let recoveryURL = directory.appendingPathComponent("ConversationRecovery", isDirectory: true) + let recoveryFiles = try FileManager.default.contentsOfDirectory( + at: recoveryURL, + includingPropertiesForKeys: nil + ) + XCTAssertTrue(recoveryFiles.contains { $0.lastPathComponent.hasPrefix(base.id.uuidString) }) + } + + func test_synchronize_pendingOfflineAppendWithRemoteAppend_preservesBothChanges() async throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let base = Conversation( + modelId: "model", + messages: [ChatMessage(role: .user, content: "Initial", timestamp: timestamp)], + updatedAt: timestamp + ) + settingsManager.isCloudSyncEnabled = false + try await sut.save(base) + cloudSyncManager.cloudConversations = [base] + settingsManager.isCloudSyncEnabled = true + cloudSyncManager.cloudAvailable = false + var offline = base + offline.messages.append(ChatMessage( + role: .assistant, + content: "Offline append", + timestamp: timestamp.addingTimeInterval(2) + )) + offline.updatedAt = timestamp.addingTimeInterval(2) + try await sut.save(offline, expectedBase: base) + var remote = base + remote.messages.append(ChatMessage( + role: .assistant, + content: "Remote append", + timestamp: timestamp.addingTimeInterval(1) + )) + remote.updatedAt = timestamp.addingTimeInterval(1) + cloudSyncManager.cloudConversations = [remote] + cloudSyncManager.cloudAvailable = true + + // When + let result = await sut.synchronize() + + // Then + XCTAssertEqual(result, .synchronized) + let cloud = try XCTUnwrap(cloudSyncManager.cloudConversations.first) + XCTAssertEqual(Set(cloud.messages.map(\.content)), Set(["Initial", "Offline append", "Remote append"])) + } + +} diff --git a/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests.swift b/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests.swift index c78ac104..9feb6ae5 100644 --- a/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests.swift +++ b/openclient-llm-test/Features/Chat/ConversationRepositorySyncTests.swift @@ -13,10 +13,10 @@ import XCTest final class ConversationRepositorySyncTests: XCTestCase { // MARK: - Properties - private var sut: ConversationRepository! - private var settingsManager: MockSettingsManager! - private var cloudSyncManager: MockCloudSyncManager! - private var directory: URL! + var sut: ConversationRepository! + var settingsManager: MockSettingsManager! + var cloudSyncManager: MockCloudSyncManager! + var directory: URL! // MARK: - Setup @@ -46,41 +46,47 @@ final class ConversationRepositorySyncTests: XCTestCase { // MARK: - Tests - func test_synchronize_localConversation_uploadsWithoutDeletingIt() throws { + func test_synchronize_localConversation_uploadsWithoutDeletingIt() async throws { // Given let conversation = Conversation(modelId: "model") - try sut.save(conversation) + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + settingsManager.isCloudSyncEnabled = true // When - let result = sut.synchronize() + let result = await sut.synchronize() // Then XCTAssertEqual(result, .synchronized) XCTAssertTrue(cloudSyncManager.syncedConversations.contains { $0.id == conversation.id }) - XCTAssertEqual(try sut.loadAll().map(\.id), [conversation.id]) + let localIds = try await sut.loadLocal().map(\.id) + XCTAssertEqual(localIds, [conversation.id]) } - func test_synchronize_cloudConversation_restoresLocally() throws { + func test_synchronize_cloudConversation_restoresLocally() async throws { // Given let conversation = Conversation(modelId: "model") cloudSyncManager.cloudConversations = [conversation] // When - let result = sut.synchronize() + let result = await sut.synchronize() // Then XCTAssertEqual(result, .synchronized) - XCTAssertEqual(try sut.loadAll().map(\.id), [conversation.id]) + let localIds = try await sut.loadLocal().map(\.id) + XCTAssertEqual(localIds, [conversation.id]) } - func test_synchronize_withoutChanges_doesNotRewriteLocalConversation() throws { + func test_synchronize_withoutChanges_doesNotRewriteLocalConversation() async throws { // Given let conversation = Conversation( modelId: "model", updatedAt: Date(timeIntervalSince1970: 1_000_000) ) - try sut.save(conversation) - _ = sut.synchronize() + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + settingsManager.isCloudSyncEnabled = true + _ = await sut.synchronize() let fileURL = directory .appendingPathComponent("Conversations", isDirectory: true) .appendingPathComponent("\(conversation.id.uuidString).json") @@ -88,78 +94,266 @@ final class ConversationRepositorySyncTests: XCTestCase { try FileManager.default.setAttributes([.modificationDate: expectedModificationDate], ofItemAtPath: fileURL.path) // When - _ = sut.synchronize() + _ = await sut.synchronize() // Then let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) XCTAssertEqual(attributes[.modificationDate] as? Date, expectedModificationDate) } - func test_synchronize_conflict_keepsMostRecentlyUpdatedConversation() throws { + func test_synchronize_conflict_keepsMostRecentlyUpdatedConversation() async throws { // Given let id = UUID() let older = Conversation(id: id, title: "Older", modelId: "model", updatedAt: .distantPast) let newer = Conversation(id: id, title: "Newer", modelId: "model", updatedAt: Date()) settingsManager.isCloudSyncEnabled = false - try sut.save(older) + try await sut.save(older) settingsManager.isCloudSyncEnabled = true cloudSyncManager.cloudConversations = [newer] // When - _ = sut.synchronize() + _ = await sut.synchronize() // Then - XCTAssertEqual(try sut.loadAll().first?.title, "Newer") + let localConversation = try await sut.loadLocal().first + XCTAssertEqual(localConversation?.title, "Newer") } - func test_delete_offlineTombstone_preventsRemoteConversationFromReturning() throws { + func test_delete_tombstoneOlderThanRemoteConversation_preservesNewerVersion() async throws { // Given let conversation = Conversation(modelId: "model") settingsManager.isCloudSyncEnabled = false - try sut.save(conversation) + try await sut.save(conversation) cloudSyncManager.cloudConversations = [ Conversation(id: conversation.id, modelId: "model", updatedAt: Date().addingTimeInterval(60)) ] // When - try sut.delete(conversation.id) + try await sut.delete(conversation.id) + settingsManager.isCloudSyncEnabled = true + _ = await sut.synchronize() + + // Then + let local = try await sut.loadLocal() + XCTAssertEqual(local.map(\.id), [conversation.id]) + XCTAssertFalse(cloudSyncManager.deletedIds.contains(conversation.id)) + } + + func test_delete_tombstoneNewerThanRemoteConversation_preventsRestoration() async throws { + // Given + let conversation = Conversation(modelId: "model", updatedAt: .distantPast) + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + cloudSyncManager.cloudConversations = [conversation] + + // When + try await sut.delete(conversation.id) settingsManager.isCloudSyncEnabled = true - _ = sut.synchronize() + _ = await sut.synchronize() // Then - XCTAssertTrue(try sut.loadAll().isEmpty) + let local = try await sut.loadLocal() + XCTAssertTrue(local.isEmpty) XCTAssertTrue(cloudSyncManager.deletedIds.contains(conversation.id)) } - func test_synchronize_pendingPlaceholder_preservesLocalConversation() throws { + func test_synchronize_pendingPlaceholder_preservesLocalConversation() async throws { // Given let conversation = Conversation(modelId: "model") settingsManager.isCloudSyncEnabled = false - try sut.save(conversation) + try await sut.save(conversation) settingsManager.isCloudSyncEnabled = true cloudSyncManager.pendingConversationDownloads = true // When - let result = sut.synchronize() + let result = await sut.synchronize() // Then XCTAssertEqual(result, .pendingDownload) - XCTAssertEqual(try sut.loadAll().map(\.id), [conversation.id]) + let localIds = try await sut.loadLocal().map(\.id) + XCTAssertEqual(localIds, [conversation.id]) } - func test_deleteAll_syncDisabled_doesNotDeleteCloudConversationsLater() throws { + func test_deleteAll_syncDisabled_doesNotDeleteCloudConversationsLater() async throws { // Given let conversation = Conversation(modelId: "model") settingsManager.isCloudSyncEnabled = false - try sut.save(conversation) + try await sut.save(conversation) cloudSyncManager.cloudConversations = [conversation] // When - try sut.deleteAll() + try await sut.deleteAll() settingsManager.isCloudSyncEnabled = true - _ = sut.synchronize() + _ = await sut.synchronize() // Then - XCTAssertEqual(try sut.loadAll().map(\.id), [conversation.id]) + let localIds = try await sut.loadLocal().map(\.id) + XCTAssertEqual(localIds, [conversation.id]) } + + func test_deleteAll_syncEnabled_newerConversationSurvivesMarker() async throws { + // Given + let oldConversation = Conversation(modelId: "model", updatedAt: .distantPast) + cloudSyncManager.cloudConversations = [oldConversation] + + // When + try await sut.deleteAll() + let newConversation = Conversation(modelId: "model", updatedAt: Date().addingTimeInterval(1)) + try await sut.save(newConversation) + + // Then + let localIds = try await sut.loadLocal().map(\.id) + XCTAssertEqual(localIds, [newConversation.id]) + XCTAssertTrue(cloudSyncManager.cloudConversations.contains { $0.id == newConversation.id }) + } + + func test_synchronize_corruptLocalConversation_failsAndPreservesOriginalBytes() async throws { + // Given + let corruptData = Data("not-json".utf8) + let conversationsDirectory = directory.appendingPathComponent("Conversations", isDirectory: true) + try FileManager.default.createDirectory(at: conversationsDirectory, withIntermediateDirectories: true) + let corruptURL = conversationsDirectory.appendingPathComponent("\(UUID().uuidString).json") + try corruptData.write(to: corruptURL) + + // When + let result = await sut.synchronize() + + // Then + XCTAssertEqual(result, .failed) + XCTAssertEqual(try Data(contentsOf: corruptURL), corruptData) + } + + func test_synchronize_identityChangesDuringApply_restoresExactLocalBytes() async throws { + // Given + let conversation = Conversation(title: "Local", modelId: "model") + settingsManager.isCloudSyncEnabled = false + try await sut.save(conversation) + let fileURL = directory + .appendingPathComponent("Conversations", isDirectory: true) + .appendingPathComponent("\(conversation.id.uuidString).json") + let originalData = try Data(contentsOf: fileURL) + cloudSyncManager.cloudConversations = [ + Conversation( + id: conversation.id, + title: "Cloud", + modelId: "model", + updatedAt: conversation.updatedAt.addingTimeInterval(1) + ) + ] + cloudSyncManager.syncError = CloudSyncError.containerIdentityChanged + settingsManager.isCloudSyncEnabled = true + + // When + let result = await sut.synchronize() + + // Then + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(try Data(contentsOf: fileURL), originalData) + } + + func test_delete_repeatedWithoutNewRecord_keepsTombstoneUnchanged() async throws { + // Given + let conversation = Conversation(modelId: "model") + try await sut.save(conversation) + try await sut.delete(conversation.id) + let tombstonesURL = directory.appendingPathComponent("ConversationTombstones.json") + let originalData = try Data(contentsOf: tombstonesURL) + let originalCloudDate = try XCTUnwrap(cloudSyncManager.cloudTombstones.first?.deletedAt) + + // When + try await sut.delete(conversation.id) + + // Then + XCTAssertEqual(try Data(contentsOf: tombstonesURL), originalData) + XCTAssertEqual(cloudSyncManager.cloudTombstones.first?.deletedAt, originalCloudDate) + } + + func test_deleteAll_repeatedWithoutNewRecord_keepsMarkerUnchanged() async throws { + // Given + try await sut.save(Conversation(modelId: "model")) + try await sut.deleteAll() + let markerURL = directory.appendingPathComponent("ConversationDeleteAll.json") + let originalData = try Data(contentsOf: markerURL) + let originalCloudDate = try XCTUnwrap(cloudSyncManager.cloudDeleteAllMarker?.deletedAt) + + // When + try await sut.deleteAll() + + // Then + XCTAssertEqual(try Data(contentsOf: markerURL), originalData) + XCTAssertEqual(cloudSyncManager.cloudDeleteAllMarker?.deletedAt, originalCloudDate) + } + + func test_deleteAll_syncDisabled_clearsLocalDeletionMetadataAndRecovery() async throws { + // Given + settingsManager.isCloudSyncEnabled = false + let conversation = Conversation(modelId: "model") + try await sut.save(conversation) + try await sut.delete(conversation.id) + let recoveryURL = directory.appendingPathComponent("ConversationRecovery", isDirectory: true) + try FileManager.default.createDirectory(at: recoveryURL, withIntermediateDirectories: true) + try Data("recovery".utf8).write(to: recoveryURL.appendingPathComponent("recovery.json")) + + // When + try await sut.deleteAll() + + // Then + XCTAssertFalse(FileManager.default.fileExists( + atPath: directory.appendingPathComponent("ConversationTombstones.json").path + )) + XCTAssertFalse(FileManager.default.fileExists( + atPath: directory.appendingPathComponent("ConversationDeleteAll.json").path + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: recoveryURL.path)) + } + + func test_deleteAll_syncDisabled_staleQueuedSaveCannotRecreateConversation() async throws { + // Given + settingsManager.isCloudSyncEnabled = false + let conversation = Conversation(modelId: "model") + try await sut.save(conversation) + var staleSave = conversation + staleSave.messages.append(ChatMessage(role: .user, content: "Stale")) + try await sut.deleteAll() + + // When + do { + try await sut.save(staleSave, expectedBase: conversation) + XCTFail("Expected reset fence to reject the stale save") + } catch { + // Then + XCTAssertEqual(error as? CloudSyncError, .staleConversationRevision) + let local = try await sut.loadLocal() + XCTAssertTrue(local.isEmpty) + } + } + + func test_delete_attachmentRemovalFails_rollsBackPayloadAndTombstone() async throws { + // Given + settingsManager.isCloudSyncEnabled = false + let attachmentRepository = MockAttachmentRepository() + attachmentRepository.deleteAllError = AttachmentRepositoryError.invalidPath + let repository = ConversationRepository( + settingsManager: settingsManager, + cloudSyncManager: cloudSyncManager, + attachmentRepository: attachmentRepository, + baseDirectory: directory + ) + let conversation = Conversation(modelId: "model") + try await repository.save(conversation) + + // When + do { + try await repository.delete(conversation.id) + XCTFail("Expected local deletion failure") + } catch { + // Then + let local = try await repository.loadLocal() + XCTAssertEqual(local.map(\.id), [conversation.id]) + XCTAssertFalse(FileManager.default.fileExists( + atPath: directory.appendingPathComponent("ConversationTombstones.json").path + )) + } + } + } diff --git a/openclient-llm-test/Features/Chat/ConversationSyncCoordinatorTests.swift b/openclient-llm-test/Features/Chat/ConversationSyncCoordinatorTests.swift new file mode 100644 index 00000000..1b186bed --- /dev/null +++ b/openclient-llm-test/Features/Chat/ConversationSyncCoordinatorTests.swift @@ -0,0 +1,284 @@ +// +// ConversationSyncCoordinatorTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class ConversationSyncCoordinatorTests: XCTestCase { + func test_synchronize_concurrentRequests_runsOneCurrentAndOneFollowUp() async throws { + // Given + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let cloudManager = MockCloudSyncManager() + let firstRunStarted = expectation(description: "First synchronization started") + let releaseFirstRun = DispatchSemaphore(value: 0) + cloudManager.loadConversationsHandler = { + guard cloudManager.loadConversationsCallCount == 1 else { return } + firstRunStarted.fulfill() + releaseFirstRun.wait() + } + let storage = ConversationStorage( + cloudSyncManager: cloudManager, + attachmentRepository: MockAttachmentRepository(), + baseDirectory: directory + ) + let sut = ConversationSyncCoordinator(storage: storage) + + // When + let first = Task { await sut.synchronize() } + await fulfillment(of: [firstRunStarted], timeout: 2) + let second = Task { await sut.synchronize() } + let third = Task { await sut.synchronize() } + for _ in 0..<10 { await Task.yield() } + releaseFirstRun.signal() + let firstResult = await first.value + let secondResult = await second.value + let thirdResult = await third.value + + // Then + XCTAssertEqual([firstResult, secondResult, thirdResult], [.synchronized, .synchronized, .synchronized]) + XCTAssertEqual(cloudManager.loadConversationsCallCount, 2) + } + + func test_cancel_activeSynchronization_resumesWaiterWithoutWriting() async throws { + // Given + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let cloudManager = MockCloudSyncManager() + let firstRunStarted = expectation(description: "Synchronization started") + let releaseFirstRun = DispatchSemaphore(value: 0) + cloudManager.loadConversationsHandler = { + firstRunStarted.fulfill() + releaseFirstRun.wait() + } + let storage = ConversationStorage( + cloudSyncManager: cloudManager, + attachmentRepository: MockAttachmentRepository(), + baseDirectory: directory + ) + let sut = ConversationSyncCoordinator(storage: storage) + let synchronization = Task { await sut.synchronize() } + await fulfillment(of: [firstRunStarted], timeout: 2) + + // When + let cancellation = Task { await sut.cancel() } + for _ in 0..<10 { await Task.yield() } + releaseFirstRun.signal() + await cancellation.value + let result = await synchronization.value + _ = try await storage.loadLocal() + + // Then + XCTAssertEqual(result, .unavailable) + XCTAssertTrue(cloudManager.syncedConversations.isEmpty) + } + + func test_cancel_mutationQueuedBehindSynchronization_preventsMutation() async throws { + // Given + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let cloudManager = MockCloudSyncManager() + let conversation = Conversation(title: "Original", modelId: "model") + let storage = ConversationStorage( + cloudSyncManager: cloudManager, + attachmentRepository: MockAttachmentRepository(), + baseDirectory: directory + ) + try await storage.save(conversation) + let runStarted = expectation(description: "Synchronization started") + let releaseRun = DispatchSemaphore(value: 0) + cloudManager.loadConversationsHandler = { + runStarted.fulfill() + releaseRun.wait() + } + let sut = ConversationSyncCoordinator(storage: storage) + let synchronization = Task { await sut.synchronize() } + await fulfillment(of: [runStarted], timeout: 2) + let admissionToken = await sut.admissionToken() + let mutation = Task { + try await sut.rename( + conversation.id, + title: "Renamed", + synchronize: true, + admissionToken: admissionToken + ) + } + + // When + let cancellation = Task { await sut.cancel() } + for _ in 0..<10 { await Task.yield() } + releaseRun.signal() + await cancellation.value + _ = await synchronization.value + + // Then + do { + _ = try await mutation.value + XCTFail("Expected mutation cancellation") + } catch is CancellationError { + let localConversation = try await storage.loadLocal().first + XCTAssertEqual(localConversation?.title, "Original") + } + } + + func test_cancel_saveQueuedBehindSynchronization_persistsUserContentLocally() async throws { + // Given + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let cloudManager = MockCloudSyncManager() + let base = Conversation(modelId: "model") + let storage = ConversationStorage( + cloudSyncManager: cloudManager, + attachmentRepository: MockAttachmentRepository(), + baseDirectory: directory + ) + try await storage.save(base) + let runStarted = expectation(description: "Synchronization started") + let releaseRun = DispatchSemaphore(value: 0) + cloudManager.loadConversationsHandler = { + runStarted.fulfill() + releaseRun.wait() + } + let sut = ConversationSyncCoordinator(storage: storage) + let synchronization = Task { await sut.synchronize() } + await fulfillment(of: [runStarted], timeout: 2) + let admissionToken = await sut.admissionToken() + var updated = base + updated.messages.append(ChatMessage(role: .user, content: "Keep me")) + updated.updatedAt = Date() + let save = Task { + try await sut.save( + updated, + expectedBase: base, + synchronize: true, + admissionToken: admissionToken + ) + } + + // When + let cancellation = Task { await sut.cancel() } + for _ in 0..<10 { await Task.yield() } + releaseRun.signal() + await cancellation.value + _ = await synchronization.value + _ = try await save.value + + // Then + let local = try await storage.loadLocal() + XCTAssertEqual(local.first?.messages.map(\.content), ["Keep me"]) + } + + func test_cancel_concurrentCallersKeepAdmissionClosedUntilSharedDrainCompletes() async throws { + // Given + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let cloudManager = MockCloudSyncManager() + let storage = ConversationStorage( + cloudSyncManager: cloudManager, + attachmentRepository: MockAttachmentRepository(), + baseDirectory: directory + ) + let conversation = Conversation(title: "Original", modelId: "model") + try await storage.save(conversation) + let runStarted = expectation(description: "Synchronization started") + let releaseRun = DispatchSemaphore(value: 0) + cloudManager.loadConversationsHandler = { + runStarted.fulfill() + releaseRun.wait() + } + let sut = ConversationSyncCoordinator(storage: storage) + let synchronization = Task { await sut.synchronize() } + await fulfillment(of: [runStarted], timeout: 2) + let firstCancellation = Task { await sut.cancel() } + let secondCancellation = Task { await sut.cancel() } + let mutation = Task { + let token = await sut.admissionToken() + return try await sut.rename( + conversation.id, + title: "Renamed", + synchronize: false, + admissionToken: token + ) + } + for _ in 0..<10 { await Task.yield() } + + // When + let loadCountBeforeRelease = cloudManager.loadConversationsCallCount + releaseRun.signal() + await firstCancellation.value + await secondCancellation.value + _ = await synchronization.value + _ = try await mutation.value + + // Then + let localConversation = try await storage.loadLocal().first + XCTAssertEqual(loadCountBeforeRelease, 1) + XCTAssertEqual(localConversation?.title, "Renamed") + } + + func test_cancel_saveWithTokenAdmittedAtCancellationBoundary_retriesAfterDrain() async throws { + // Given + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let cloudManager = MockCloudSyncManager() + let base = Conversation(modelId: "model") + let storage = ConversationStorage( + cloudSyncManager: cloudManager, + attachmentRepository: MockAttachmentRepository(), + baseDirectory: directory + ) + try await storage.save(base) + let runStarted = expectation(description: "Synchronization started") + let releaseRun = DispatchSemaphore(value: 0) + cloudManager.loadConversationsHandler = { + runStarted.fulfill() + releaseRun.wait() + } + let sut = ConversationSyncCoordinator(storage: storage) + let token = await sut.admissionToken() + let synchronization = Task { await sut.synchronize() } + await fulfillment(of: [runStarted], timeout: 2) + let cancellation = Task { await sut.cancel() } + for _ in 0..<10 { await Task.yield() } + cloudManager.cloudAvailable = false + var updated = base + updated.messages = [ChatMessage(role: .user, content: "Persist after cancellation")] + updated.updatedAt = Date() + let save = Task { + try await sut.save( + updated, + expectedBase: base, + synchronize: true, + admissionToken: token + ) + } + + // When + releaseRun.signal() + await cancellation.value + _ = await synchronization.value + _ = try await save.value + + // Then + let localConversation = try await storage.loadLocal().first + let pendingBase = try await storage.loadPendingMutationBases()[base.id] + let canonicalBase = try SyncJSONCoding.makeDecoder().decode( + Conversation.self, + from: SyncJSONCoding.makeEncoder().encode(base) + ) + XCTAssertEqual(localConversation?.messages.map(\.content), ["Persist after cancellation"]) + XCTAssertEqual(pendingBase, canonicalBase) + } +} diff --git a/openclient-llm-test/Features/Chat/DeleteConversationUseCaseTests.swift b/openclient-llm-test/Features/Chat/DeleteConversationUseCaseTests.swift index 0910dfc8..3b5cc2f0 100644 --- a/openclient-llm-test/Features/Chat/DeleteConversationUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/DeleteConversationUseCaseTests.swift @@ -34,7 +34,7 @@ final class DeleteConversationUseCaseTests: XCTestCase { // MARK: - Tests — execute - func test_execute_deletesFromRepository() throws { + func test_execute_deletesFromRepository() async throws { // Given let conversationId = UUID() let conversation = Conversation( @@ -45,28 +45,33 @@ final class DeleteConversationUseCaseTests: XCTestCase { mockRepository.conversations = [conversation] // When - try sut.execute(conversationId) + try await sut.execute(conversationId) // Then XCTAssertEqual(mockRepository.deletedIds, [conversationId]) XCTAssertTrue(mockRepository.conversations.isEmpty) } - func test_execute_withRepositoryError_throwsError() { + func test_execute_withRepositoryError_throwsError() async { // Given mockRepository.deleteError = NSError(domain: "test", code: 1) // When / Then - XCTAssertThrowsError(try sut.execute(UUID())) + do { + try await sut.execute(UUID()) + XCTFail("Expected repository error") + } catch { + XCTAssertNotNil(error) + } } - func test_execute_deletingNonexistentId_stillCallsRepository() throws { + func test_execute_deletingNonexistentId_stillCallsRepository() async throws { // Given let conversationId = UUID() mockRepository.conversations = [] // When - try sut.execute(conversationId) + try await sut.execute(conversationId) // Then XCTAssertEqual(mockRepository.deletedIds, [conversationId]) diff --git a/openclient-llm-test/Features/Chat/DeleteMemoryToolTests.swift b/openclient-llm-test/Features/Chat/DeleteMemoryToolTests.swift index d22ca87d..f916e506 100644 --- a/openclient-llm-test/Features/Chat/DeleteMemoryToolTests.swift +++ b/openclient-llm-test/Features/Chat/DeleteMemoryToolTests.swift @@ -166,4 +166,21 @@ final class DeleteMemoryToolTests: XCTestCase { // Then await fulfillment(of: [expectation], timeout: 1) } + + func test_execute_deleteFails_throwsFailure() async { + // Given + let expectedError = NSError(domain: "DeleteMemoryToolTests", code: 1) + let item = MemoryItem(content: "Test memory") + mockMemoryManager.items = [item] + mockMemoryManager.mutationError = expectedError + + // When + do { + _ = try await sut.execute(arguments: #"{"content": "Test memory"}"#) + XCTFail("Expected delete failure") + } catch { + // Then + XCTAssertEqual(error as NSError, expectedError) + } + } } diff --git a/openclient-llm-test/Features/Chat/ExportConversationsUseCaseTests.swift b/openclient-llm-test/Features/Chat/ExportConversationsUseCaseTests.swift index b98faa4d..c7239553 100644 --- a/openclient-llm-test/Features/Chat/ExportConversationsUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/ExportConversationsUseCaseTests.swift @@ -34,7 +34,7 @@ final class ExportConversationsUseCaseTests: XCTestCase { XCTAssertEqual(document.conversations.map(\.conversation), conversations) } - func test_execute_backup_exportsAllStoredConversations() throws { + func test_execute_backup_exportsAllStoredConversations() async throws { // Given let conversations = [Conversation(modelId: "gpt-4"), Conversation(modelId: "llama3")] let loadConversations = MockLoadConversationsUseCase() @@ -42,7 +42,7 @@ final class ExportConversationsUseCaseTests: XCTestCase { let sut = ExportBackupUseCase(loadConversationsUseCase: loadConversations) // When - let data = try sut.execute() + let data = try await sut.execute() let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let document = try decoder.decode(ConversationExportDocument.self, from: data) @@ -85,4 +85,89 @@ final class ExportConversationsUseCaseTests: XCTestCase { XCTAssertEqual(exported.contextSummary, "Summary") XCTAssertEqual(exported.contextSummaryCursorMessageId, message.id) } + + func test_execute_attachmentWithTransientAndStoredData_exportsTransientData() throws { + // Given + let transientData = Data([0x01, 0x02]) + let attachmentRepository = MockAttachmentRepository() + attachmentRepository.loadedData = Data([0x03, 0x04]) + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "image.png", + mimeType: "image/png", + fileRelativePath: "Attachments/image.png", + transientData: transientData + ) + let message = ChatMessage(role: .assistant, content: "Image", attachments: [attachment]) + let sut = ExportConversationsUseCase(attachmentRepository: attachmentRepository) + + // When + let data = try sut.execute([Conversation(modelId: "gpt-4", messages: [message])]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let document = try decoder.decode(ConversationExportDocument.self, from: data) + + // Then + XCTAssertEqual(document.conversations.first?.attachments.first?.data, transientData.base64EncodedString()) + } + + func test_execute_attachmentWithoutReadableSource_omitsAttachmentPayload() throws { + // Given + let attachmentRepository = MockAttachmentRepository() + attachmentRepository.loadError = NSError(domain: "ExportConversationsUseCaseTests", code: 1) + let attachment = ChatMessage.Attachment( + type: .image, + fileName: "missing.png", + mimeType: "image/png", + fileRelativePath: "Attachments/missing.png" + ) + let message = ChatMessage(role: .assistant, content: "Missing", attachments: [attachment]) + let sut = ExportConversationsUseCase(attachmentRepository: attachmentRepository) + + // When + let data = try sut.execute([Conversation(modelId: "gpt-4", messages: [message])]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let document = try decoder.decode(ConversationExportDocument.self, from: data) + + // Then + XCTAssertTrue(document.conversations.first?.attachments.isEmpty == true) + } + + func test_execute_normalBranchBackup_importsNonEmptyConversations() async throws { + // Given + let first = ChatMessage(role: .user, content: "Question") + let second = ChatMessage(role: .assistant, content: "Answer") + let root = Conversation( + modelId: "gpt-4", + contextSummary: "Question and answer", + contextSummaryCursorMessageId: second.id, + messages: [first, second] + ) + let branch = try await BranchConversationUseCase( + saveConversationUseCase: MockSaveConversationUseCase(), + attachmentRepository: MockAttachmentRepository() + ).execute(conversation: root, fromMessageId: second.id) + let backup = try ExportConversationsUseCase().execute([root, branch]) + let saveImport = MockSaveConversationUseCase() + let importUseCase = ImportConversationsUseCase( + saveConversationUseCase: saveImport, + loadConversationsUseCase: MockLoadConversationsUseCase() + ) + + // When + let result = try await importUseCase.execute(backup) + + // Then + XCTAssertEqual(result.importedConversationCount, 2) + XCTAssertEqual(saveImport.savedConversations.map(\.messages.count), [2, 2]) + XCTAssertEqual( + saveImport.savedConversations[1].contextSummaryCursorMessageId, + saveImport.savedConversations[1].messages[1].id + ) + XCTAssertEqual( + saveImport.savedConversations[1].branchedFromMessageId, + saveImport.savedConversations[0].messages[1].id + ) + } } diff --git a/openclient-llm-test/Features/Chat/ImportConversationsUseCaseTests.swift b/openclient-llm-test/Features/Chat/ImportConversationsUseCaseTests.swift index 92527942..75e2f355 100644 --- a/openclient-llm-test/Features/Chat/ImportConversationsUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/ImportConversationsUseCaseTests.swift @@ -14,9 +14,7 @@ final class ImportConversationsUseCaseTests: XCTestCase { // MARK: - Properties var mockSaveConversation: MockSaveConversationUseCase! - var mockDeleteConversation: MockDeleteConversationUseCase! var mockLoadConversations: MockLoadConversationsUseCase! - var mockAttachmentRepository: MockAttachmentRepository! var sut: ImportConversationsUseCase! // MARK: - Setup @@ -24,33 +22,28 @@ final class ImportConversationsUseCaseTests: XCTestCase { override func setUp() async throws { try await super.setUp() mockSaveConversation = MockSaveConversationUseCase() - mockDeleteConversation = MockDeleteConversationUseCase() mockLoadConversations = MockLoadConversationsUseCase() - mockAttachmentRepository = MockAttachmentRepository() sut = ImportConversationsUseCase( saveConversationUseCase: mockSaveConversation, - deleteConversationUseCase: mockDeleteConversation, - loadConversationsUseCase: mockLoadConversations, - attachmentRepository: mockAttachmentRepository + loadConversationsUseCase: mockLoadConversations ) } override func tearDown() async throws { sut = nil - mockAttachmentRepository = nil mockLoadConversations = nil - mockDeleteConversation = nil mockSaveConversation = nil try await super.tearDown() } // MARK: - Tests - func test_execute_validDocument_restoresConversationWithNewIdentifiers() throws { + func test_execute_validDocument_restoresConversationWithNewIdentifiers() async throws { // Given let attachment = makeAttachment() let message = ChatMessage(role: .user, content: "Hello", attachments: [attachment]) - let conversation = Conversation(modelId: "gpt-4", messages: [message]) + let oldDate = Date(timeIntervalSince1970: 1_000_000) + let conversation = Conversation(modelId: "gpt-4", messages: [message], updatedAt: oldDate) let document = ConversationExportDocument(conversations: [ .init( conversation: conversation, @@ -59,7 +52,7 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When - let result = try sut.execute(try encoded(document)) + let result = try await sut.execute(try encoded(document)) // Then let imported = try XCTUnwrap(mockSaveConversation.savedConversations.first) @@ -67,11 +60,12 @@ final class ImportConversationsUseCaseTests: XCTestCase { XCTAssertEqual(result.restoredAttachmentCount, 1) XCTAssertNotEqual(imported.id, conversation.id) XCTAssertNotEqual(imported.messages[0].id, message.id) - XCTAssertEqual(mockAttachmentRepository.savedAttachments.first?.data, Data("hello".utf8)) - XCTAssertFalse(imported.messages[0].attachments[0].fileRelativePath.isEmpty) + XCTAssertEqual(imported.messages[0].attachments[0].transientData, Data("hello".utf8)) + XCTAssertTrue(imported.messages[0].attachments[0].fileRelativePath.isEmpty) + XCTAssertGreaterThan(imported.updatedAt, oldDate) } - func test_execute_invalidAttachmentData_importsConversationWithoutAttachment() throws { + func test_execute_invalidAttachmentData_importsConversationWithoutAttachment() async throws { // Given let attachment = makeAttachment() let message = ChatMessage(role: .user, content: "Hello", attachments: [attachment]) @@ -84,16 +78,15 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When - let result = try sut.execute(try encoded(document)) + let result = try await sut.execute(try encoded(document)) // Then XCTAssertEqual(result.importedConversationCount, 1) XCTAssertEqual(result.skippedAttachmentCount, 1) - XCTAssertTrue(mockAttachmentRepository.savedAttachments.isEmpty) XCTAssertTrue(mockSaveConversation.savedConversations[0].messages[0].attachments.isEmpty) } - func test_execute_invalidAttachmentReference_throwsWithoutPersisting() throws { + func test_execute_invalidAttachmentReference_throwsWithoutPersisting() async throws { // Given let conversation = Conversation(modelId: "gpt-4") let document = ConversationExportDocument(conversations: [ @@ -104,12 +97,11 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When / Then - XCTAssertThrowsError(try sut.execute(try encoded(document))) + await assertImportThrows(try encoded(document)) XCTAssertTrue(mockSaveConversation.savedConversations.isEmpty) - XCTAssertTrue(mockAttachmentRepository.savedAttachments.isEmpty) } - func test_execute_saveConversationFails_deletesRestoredAttachments() throws { + func test_execute_saveConversationFails_leavesNoPersistedConversation() async throws { // Given let attachment = makeAttachment() let message = ChatMessage(role: .user, content: "Hello", attachments: [attachment]) @@ -122,11 +114,11 @@ final class ImportConversationsUseCaseTests: XCTestCase { mockSaveConversation.error = NSError(domain: "test", code: 1) // When / Then - XCTAssertThrowsError(try sut.execute(try encoded(document))) - XCTAssertEqual(mockAttachmentRepository.deletedAttachments.count, 1) + await assertImportThrows(try encoded(document)) + XCTAssertTrue(mockSaveConversation.savedConversations.isEmpty) } - func test_execute_branchedConversations_remapsBranchReferences() throws { + func test_execute_branchedConversations_remapsBranchReferences() async throws { // Given let rootMessage = ChatMessage(role: .user, content: "Root") let root = Conversation(modelId: "gpt-4", messages: [rootMessage]) @@ -141,7 +133,7 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When - _ = try sut.execute(try encoded(document)) + _ = try await sut.execute(try encoded(document)) // Then let importedRoot = mockSaveConversation.savedConversations[0] @@ -150,20 +142,121 @@ final class ImportConversationsUseCaseTests: XCTestCase { XCTAssertEqual(importedBranch.branchedFromMessageId, importedRoot.messages[0].id) } - func test_execute_laterSaveFails_rollsBackPreviouslySavedConversations() throws { + func test_execute_nonEmptyBranchBackup_restoresUniqueMessagesSummaryAndAttachment() async throws { // Given + let rootMessage = ChatMessage(role: .user, content: "Root") + let root = Conversation(modelId: "gpt-4", messages: [rootMessage]) + let attachment = makeAttachment() + let branchMessage = ChatMessage(role: .user, content: "Root", attachments: [attachment]) + let branch = Conversation( + modelId: "gpt-4", + contextSummary: "Root summary", + contextSummaryCursorMessageId: branchMessage.id, + messages: [branchMessage], + parentConversationId: root.id, + branchedFromMessageId: rootMessage.id + ) let document = ConversationExportDocument(conversations: [ - .init(conversation: Conversation(modelId: "gpt-4"), attachments: []), - .init(conversation: Conversation(modelId: "llama3"), attachments: []) + .init(conversation: root, attachments: []), + .init( + conversation: branch, + attachments: [ + .init(messageId: branchMessage.id, attachmentId: attachment.id, data: "branch".base64Encoded) + ] + ) ]) - mockSaveConversation.failureAtCall = 2 - // When / Then - XCTAssertThrowsError(try sut.execute(try encoded(document))) - XCTAssertEqual(mockDeleteConversation.deletedIds.count, 1) + // When + _ = try await sut.execute(try encoded(document)) + + // Then + let importedRoot = mockSaveConversation.savedConversations[0] + let importedBranch = mockSaveConversation.savedConversations[1] + XCTAssertFalse(importedBranch.messages.isEmpty) + XCTAssertNotEqual(importedBranch.messages[0].id, importedRoot.messages[0].id) + XCTAssertEqual(importedBranch.contextSummaryCursorMessageId, importedBranch.messages[0].id) + XCTAssertEqual(importedBranch.branchedFromMessageId, importedRoot.messages[0].id) + XCTAssertEqual(importedBranch.messages[0].attachments[0].transientData, Data("branch".utf8)) + } + + func test_execute_withAttachment_materializesBytesThroughAtomicConversationPersistence() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let attachmentRepository = MockAttachmentRepository() + let repository = ConversationRepository( + settingsManager: MockSettingsManager(), + cloudSyncManager: MockCloudSyncManager(), + attachmentRepository: attachmentRepository, + baseDirectory: documentsURL + ) + sut = ImportConversationsUseCase( + saveConversationUseCase: SaveConversationUseCase(repository: repository), + loadConversationsUseCase: mockLoadConversations + ) + let attachment = makeAttachment() + let message = ChatMessage(role: .user, content: "Document", attachments: [attachment]) + let document = ConversationExportDocument(conversations: [ + .init( + conversation: Conversation(modelId: "gpt-4", messages: [message]), + attachments: [.init(messageId: message.id, attachmentId: attachment.id, data: "bytes".base64Encoded)] + ) + ]) + + // When + _ = try await sut.execute(try encoded(document)) + + // Then + let localConversations = try await repository.loadLocal() + let imported = try XCTUnwrap(localConversations.first) + let restoredAttachment = try XCTUnwrap(imported.messages.first?.attachments.first) + XCTAssertTrue(attachmentRepository.savedAttachments.isEmpty) + XCTAssertEqual( + try Data(contentsOf: documentsURL.appendingPathComponent(restoredAttachment.fileRelativePath)), + Data("bytes".utf8) + ) } - func test_execute_summaryWithoutCursor_throwsWithoutPersisting() throws { + func test_execute_cloudEnabled_publishesOnlyAfterCompleteLocalBatch() async throws { + // Given + let documentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: documentsURL) } + let settings = MockSettingsManager() + settings.isCloudSyncEnabled = true + let cloud = MockCloudSyncManager() + let probe = ImportBatchProbe() + cloud.loadConversationsSendableHandler = { + let directory = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + let count = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" }.count) ?? 0 + probe.record(count) + } + let repository = ConversationRepository( + settingsManager: settings, + cloudSyncManager: cloud, + attachmentRepository: MockAttachmentRepository(), + baseDirectory: documentsURL + ) + sut = ImportConversationsUseCase( + saveConversationUseCase: SaveConversationUseCase(repository: repository), + loadConversationsUseCase: mockLoadConversations + ) + let document = ConversationExportDocument(conversations: [ + .init(conversation: Conversation(modelId: "first"), attachments: []), + .init(conversation: Conversation(modelId: "second"), attachments: []) + ]) + + // When + _ = try await sut.execute(try encoded(document)) + + // Then + XCTAssertEqual(probe.value, 2) + XCTAssertEqual(cloud.cloudConversations.count, 2) + } + + func test_execute_summaryWithoutCursor_throwsWithoutPersisting() async throws { // Given let conversation = Conversation(modelId: "gpt-4", contextSummary: "Summary") let document = ConversationExportDocument(conversations: [ @@ -171,11 +264,11 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When / Then - XCTAssertThrowsError(try sut.execute(try encoded(document))) + await assertImportThrows(try encoded(document)) XCTAssertTrue(mockSaveConversation.savedConversations.isEmpty) } - func test_execute_cursorOutsideConversation_throwsWithoutPersisting() throws { + func test_execute_cursorOutsideConversation_throwsWithoutPersisting() async throws { // Given let conversation = Conversation( modelId: "gpt-4", @@ -188,11 +281,11 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When / Then - XCTAssertThrowsError(try sut.execute(try encoded(document))) + await assertImportThrows(try encoded(document)) XCTAssertTrue(mockSaveConversation.savedConversations.isEmpty) } - func test_execute_nonPositiveContextWindow_throwsWithoutPersisting() throws { + func test_execute_nonPositiveContextWindow_throwsWithoutPersisting() async throws { // Given let conversation = Conversation(modelId: "gpt-4", contextWindowTokens: 0) let document = ConversationExportDocument(conversations: [ @@ -200,11 +293,11 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When / Then - XCTAssertThrowsError(try sut.execute(try encoded(document))) + await assertImportThrows(try encoded(document)) XCTAssertTrue(mockSaveConversation.savedConversations.isEmpty) } - func test_execute_validSummaryAndCursor_remapsCursor() throws { + func test_execute_validSummaryAndCursor_remapsCursor() async throws { // Given let message = ChatMessage(role: .user, content: "Hello") let conversation = Conversation( @@ -218,14 +311,14 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When - _ = try sut.execute(try encoded(document)) + _ = try await sut.execute(try encoded(document)) // Then let imported = try XCTUnwrap(mockSaveConversation.savedConversations.first) XCTAssertEqual(imported.contextSummaryCursorMessageId, imported.messages.first?.id) } - func test_execute_cursorInsideToolRound_throwsWithoutPersisting() throws { + func test_execute_cursorInsideToolRound_throwsWithoutPersisting() async throws { // Given let call = ToolCall( id: "call_1", @@ -245,11 +338,11 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When / Then - XCTAssertThrowsError(try sut.execute(try encoded(document))) + await assertImportThrows(try encoded(document)) XCTAssertTrue(mockSaveConversation.savedConversations.isEmpty) } - func test_execute_tagAlreadyExistsLocally_reusesLocalColor() throws { + func test_execute_tagAlreadyExistsLocally_reusesLocalColor() async throws { // Given mockLoadConversations.result = .success([ Conversation( @@ -266,7 +359,7 @@ final class ImportConversationsUseCaseTests: XCTestCase { ]) // When - _ = try sut.execute(try encoded(document)) + _ = try await sut.execute(try encoded(document)) // Then XCTAssertEqual( @@ -276,9 +369,32 @@ final class ImportConversationsUseCaseTests: XCTestCase { } } +// Safety: All mutable state is protected by `NSLock`. +private final class ImportBatchProbe: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.withLock { count } + } + + func record(_ value: Int) { + lock.withLock { count = value } + } +} + // MARK: - Private private extension ImportConversationsUseCaseTests { + func assertImportThrows(_ data: Data) async { + do { + _ = try await sut.execute(data) + XCTFail("Expected import to throw") + } catch { + return + } + } + func makeAttachment() -> ChatMessage.Attachment { ChatMessage.Attachment( type: .pdf, diff --git a/openclient-llm-test/Features/Chat/LoadConversationsUseCaseTests.swift b/openclient-llm-test/Features/Chat/LoadConversationsUseCaseTests.swift index 2f7849d3..69ebc20a 100644 --- a/openclient-llm-test/Features/Chat/LoadConversationsUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/LoadConversationsUseCaseTests.swift @@ -13,7 +13,7 @@ import XCTest final class LoadConversationsUseCaseTests: XCTestCase { // MARK: - Tests - func test_execute_sameTagWithDifferentColors_usesFirstAssignedColor() throws { + func test_execute_sameTagWithDifferentColors_usesFirstAssignedColor() async throws { // Given let first = Conversation( modelId: "gpt-4", @@ -28,7 +28,7 @@ final class LoadConversationsUseCaseTests: XCTestCase { let sut = LoadConversationsUseCase(repository: repository) // When - let conversations = try sut.execute() + let conversations = try await sut.execute() // Then XCTAssertEqual(conversations.map(\.tags.first?.color), [.blue, .blue]) diff --git a/openclient-llm-test/Features/Chat/PinConversationUseCaseTests.swift b/openclient-llm-test/Features/Chat/PinConversationUseCaseTests.swift index c3223a62..98940759 100644 --- a/openclient-llm-test/Features/Chat/PinConversationUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/PinConversationUseCaseTests.swift @@ -34,13 +34,13 @@ final class PinConversationUseCaseTests: XCTestCase { // MARK: - Tests — execute - func test_execute_pinConversation_setsIsPinnedTrue() throws { + func test_execute_pinConversation_setsIsPinnedTrue() async throws { // Given let conversation = Conversation(id: UUID(), modelId: "gpt-4", messages: [], isPinned: false) mockRepository.conversations = [conversation] // When - try sut.execute(conversation.id, isPinned: true) + try await sut.execute(conversation.id, isPinned: true) // Then let saved = mockRepository.savedConversations.first @@ -48,13 +48,13 @@ final class PinConversationUseCaseTests: XCTestCase { XCTAssertTrue(saved?.isPinned ?? false) } - func test_execute_unpinConversation_setsIsPinnedFalse() throws { + func test_execute_unpinConversation_setsIsPinnedFalse() async throws { // Given let conversation = Conversation(id: UUID(), modelId: "gpt-4", messages: [], isPinned: true) mockRepository.conversations = [conversation] // When - try sut.execute(conversation.id, isPinned: false) + try await sut.execute(conversation.id, isPinned: false) // Then let saved = mockRepository.savedConversations.first @@ -62,7 +62,7 @@ final class PinConversationUseCaseTests: XCTestCase { XCTAssertFalse(saved?.isPinned ?? true) } - func test_execute_pins_updatesUpdatedAt() throws { + func test_execute_pins_updatesUpdatedAt() async throws { // Given let before = Date().addingTimeInterval(-3600) let conversation = Conversation( @@ -74,7 +74,7 @@ final class PinConversationUseCaseTests: XCTestCase { mockRepository.conversations = [conversation] // When - try sut.execute(conversation.id, isPinned: true) + try await sut.execute(conversation.id, isPinned: true) // Then let saved = mockRepository.savedConversations.first @@ -82,33 +82,43 @@ final class PinConversationUseCaseTests: XCTestCase { XCTAssertGreaterThan(saved?.updatedAt ?? .distantPast, before) } - func test_execute_nonexistentId_doesNotSave() throws { + func test_execute_nonexistentId_doesNotSave() async throws { // Given let randomId = UUID() mockRepository.conversations = [] // When - try sut.execute(randomId, isPinned: true) + try await sut.execute(randomId, isPinned: true) // Then XCTAssertTrue(mockRepository.savedConversations.isEmpty) } - func test_execute_repositoryLoadError_throwsError() { + func test_execute_repositoryLoadError_throwsError() async { // Given mockRepository.loadError = NSError(domain: "test", code: 1) // When / Then - XCTAssertThrowsError(try sut.execute(UUID(), isPinned: true)) + do { + try await sut.execute(UUID(), isPinned: true) + XCTFail("Expected load error") + } catch { + XCTAssertNotNil(error) + } } - func test_execute_repositorySaveError_throwsError() throws { + func test_execute_repositorySaveError_throwsError() async throws { // Given let conversation = Conversation(id: UUID(), modelId: "gpt-4", messages: []) mockRepository.conversations = [conversation] mockRepository.saveError = NSError(domain: "test", code: 2) // When / Then - XCTAssertThrowsError(try sut.execute(conversation.id, isPinned: true)) + do { + try await sut.execute(conversation.id, isPinned: true) + XCTFail("Expected save error") + } catch { + XCTAssertNotNil(error) + } } } diff --git a/openclient-llm-test/Features/Chat/SaveMemoryToolTests.swift b/openclient-llm-test/Features/Chat/SaveMemoryToolTests.swift index 45aa9e25..275c27b4 100644 --- a/openclient-llm-test/Features/Chat/SaveMemoryToolTests.swift +++ b/openclient-llm-test/Features/Chat/SaveMemoryToolTests.swift @@ -105,4 +105,19 @@ final class SaveMemoryToolTests: XCTestCase { // Then XCTAssertEqual(mockMemoryManager.addedItem?.content, "trimmed content") } + + func test_execute_persistenceFails_throwsFailure() async { + // Given + let expectedError = NSError(domain: "SaveMemoryToolTests", code: 1) + mockMemoryManager.mutationError = expectedError + + // When + do { + _ = try await sut.execute(arguments: #"{"content": "Remember this"}"#) + XCTFail("Expected persistence failure") + } catch { + // Then + XCTAssertEqual(error as NSError, expectedError) + } + } } diff --git a/openclient-llm-test/Features/Chat/UpdateConversationTagsUseCaseTests.swift b/openclient-llm-test/Features/Chat/UpdateConversationTagsUseCaseTests.swift index bf99e767..8a7fb1ca 100644 --- a/openclient-llm-test/Features/Chat/UpdateConversationTagsUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/UpdateConversationTagsUseCaseTests.swift @@ -13,7 +13,7 @@ import XCTest final class UpdateConversationTagsUseCaseTests: XCTestCase { // MARK: - Tests - func test_execute_existingTagName_reusesAssignedColor() throws { + func test_execute_existingTagName_reusesAssignedColor() async throws { // Given let existing = Conversation( modelId: "gpt-4", @@ -25,7 +25,7 @@ final class UpdateConversationTagsUseCaseTests: XCTestCase { let sut = UpdateConversationTagsUseCase(repository: repository) // When - let tags = try sut.execute( + let tags = try await sut.execute( target.id, tags: [ConversationTag(name: "swift", color: .red)] ) diff --git a/openclient-llm-test/Features/Chat/WidgetSnapshotTests.swift b/openclient-llm-test/Features/Chat/WidgetSnapshotTests.swift index 02e7149d..fbf9fb55 100644 --- a/openclient-llm-test/Features/Chat/WidgetSnapshotTests.swift +++ b/openclient-llm-test/Features/Chat/WidgetSnapshotTests.swift @@ -44,7 +44,7 @@ final class WidgetSnapshotTests: XCTestCase { // MARK: - Tests - func test_loadAll_existingLocalConversations_rebuildsWidgetSnapshot() throws { + func test_loadAll_existingLocalConversations_rebuildsWidgetSnapshot() async throws { // Given let conversations = (0..<7).map { index in Conversation( @@ -57,7 +57,7 @@ final class WidgetSnapshotTests: XCTestCase { try conversations.forEach { try saveLocally($0) } // When - _ = try sut.loadAll() + _ = try await sut.loadAll() // Then let snapshot = AppGroupStore.loadConversations() diff --git a/openclient-llm-test/Features/Launch/AttachmentMigrationUseCaseTests.swift b/openclient-llm-test/Features/Launch/AttachmentMigrationUseCaseTests.swift index af8fbfb4..8886e7e4 100644 --- a/openclient-llm-test/Features/Launch/AttachmentMigrationUseCaseTests.swift +++ b/openclient-llm-test/Features/Launch/AttachmentMigrationUseCaseTests.swift @@ -120,14 +120,6 @@ final class AttachmentMigrationUseCaseTests: XCTestCase { mockAttachmentRepository.saveResult = .success("Attachments/\(conversationId)/\(attachmentId).jpg") - // Inject a custom sut that reads from our test directory - sut = AttachmentMigrationUseCase( - fileManager: .default, - attachmentRepository: mockAttachmentRepository, - userDefaults: testUserDefaults, - baseDirectory: testDirectory - ) - // When sut.execute() @@ -136,6 +128,7 @@ final class AttachmentMigrationUseCaseTests: XCTestCase { XCTAssertEqual(mockAttachmentRepository.savedAttachments.first?.data, imageData) XCTAssertEqual(mockAttachmentRepository.savedAttachments.first?.attachment.fileName, "photo.jpg") XCTAssertTrue(testUserDefaults.bool(forKey: "attachmentMigrationV1Done")) + try assertRecoveryContains(fileData) // The written JSON should no longer have "data" in attachments let updatedData = try Data(contentsOf: fileURL) @@ -155,7 +148,201 @@ final class AttachmentMigrationUseCaseTests: XCTestCase { // but the flag prevents the second run entirely) XCTAssertEqual(mockAttachmentRepository.savedAttachments.count, 0) } + + func test_execute_attachmentWriteFails_keepsMigrationRetryable() throws { + // Given + let conversationId = UUID() + let legacyJSON: [String: Any] = [ + "id": conversationId.uuidString, + "messages": [[ + "attachments": [[ + "id": UUID().uuidString, + "type": "image", + "fileName": "photo.jpg", + "data": Data([0x01]).base64EncodedString() + ]] + ]] + ] + let conversationsURL = testDirectory.appendingPathComponent("Conversations", isDirectory: true) + try FileManager.default.createDirectory(at: conversationsURL, withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: legacyJSON).write( + to: conversationsURL.appendingPathComponent("\(conversationId.uuidString).json") + ) + mockAttachmentRepository.saveResult = .failure(AttachmentRepositoryError.invalidPath) + + // When + sut.execute() + + // Then + XCTAssertFalse(testUserDefaults.bool(forKey: "attachmentMigrationV1Done")) + } + + func test_execute_conversationsPathIsFile_keepsMigrationRetryable() throws { + // Given + try Data("not a directory".utf8).write( + to: testDirectory.appendingPathComponent("Conversations") + ) + + // When + sut.execute() + + // Then + XCTAssertFalse(testUserDefaults.bool(forKey: "attachmentMigrationV1Done")) + } + + func test_execute_sameDestinationWithDifferentBytes_doesNotWriteOrReplaceRawJSON() throws { + // Given + let conversationId = UUID() + let attachmentId = UUID() + let legacyJSON = legacyConversationJSON( + conversationId: conversationId, + attachments: [ + legacyAttachment(id: attachmentId, data: Data([0x01])), + legacyAttachment(id: attachmentId, data: Data([0x02])) + ] + ) + let fileURL = try writeConversationFixture(legacyJSON, conversationId: conversationId) + let rawData = try Data(contentsOf: fileURL) + + // When + sut.execute() + + // Then + XCTAssertTrue(mockAttachmentRepository.savedAttachments.isEmpty) + XCTAssertEqual(try Data(contentsOf: fileURL), rawData) + XCTAssertFalse(testUserDefaults.bool(forKey: "attachmentMigrationV1Done")) + try assertRecoveryContains(rawData) + } + + func test_execute_existingDestinationWithDifferentBytes_doesNotOverwriteFile() throws { + // Given + let conversationId = UUID() + let attachmentId = UUID() + let existingData = Data("existing".utf8) + let incomingData = Data("incoming".utf8) + let legacyJSON = legacyConversationJSON( + conversationId: conversationId, + attachments: [legacyAttachment(id: attachmentId, data: incomingData)] + ) + let conversationURL = try writeConversationFixture(legacyJSON, conversationId: conversationId) + let attachmentURL = testDirectory + .appendingPathComponent("Attachments/\(conversationId.uuidString)", isDirectory: true) + .appendingPathComponent("\(attachmentId.uuidString).jpg") + try FileManager.default.createDirectory( + at: attachmentURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try existingData.write(to: attachmentURL) + + // When + sut.execute() + + // Then + XCTAssertEqual(try Data(contentsOf: attachmentURL), existingData) + XCTAssertTrue(try JSONFixture(data: Data(contentsOf: conversationURL)).containsLegacyAttachmentData) + XCTAssertTrue(mockAttachmentRepository.savedAttachments.isEmpty) + XCTAssertFalse(testUserDefaults.bool(forKey: "attachmentMigrationV1Done")) + } + + func test_execute_finalAttachmentVerificationFails_restoresRawJSONAndRemainsRetryable() throws { + // Given + let conversationId = UUID() + let firstId = UUID() + let secondId = UUID() + let firstData = Data([0x01]) + let secondData = Data([0x02]) + let legacyJSON = legacyConversationJSON( + conversationId: conversationId, + attachments: [ + legacyAttachment(id: firstId, data: firstData), + legacyAttachment(id: secondId, data: secondData) + ] + ) + let fileURL = try writeConversationFixture(legacyJSON, conversationId: conversationId) + let rawData = try Data(contentsOf: fileURL) + mockAttachmentRepository.saveHandler = { attachment, conversationId in + ConversationAttachmentPath.relativePath(for: attachment, conversationId: conversationId) + } + var loadCounts: [UUID: Int] = [:] + mockAttachmentRepository.loadHandler = { attachment in + loadCounts[attachment.id, default: 0] += 1 + if attachment.id == firstId, loadCounts[attachment.id] == 2 { + return Data("changed".utf8) + } + return attachment.id == firstId ? firstData : secondData + } + + // When + sut.execute() + + // Then + XCTAssertEqual(try Data(contentsOf: fileURL), rawData) + XCTAssertFalse(testUserDefaults.bool(forKey: "attachmentMigrationV1Done")) + try assertRecoveryContains(rawData) + } } // MARK: - Helpers -// (No helpers needed — testDirectory is injected directly via baseDirectory parameter) + +private extension AttachmentMigrationUseCaseTests { + struct JSONFixture { + let data: Data + + var containsLegacyAttachmentData: Bool { + get throws { + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let messages = try XCTUnwrap(root["messages"] as? [[String: Any]]) + let attachments = try XCTUnwrap(messages.first?["attachments"] as? [[String: Any]]) + return attachments.first?["data"] != nil + } + } + } + + func legacyAttachment(id: UUID, data: Data) -> [String: Any] { + [ + "id": id.uuidString, + "type": "image", + "fileName": "photo.jpg", + "data": data.base64EncodedString() + ] + } + + func legacyConversationJSON( + conversationId: UUID, + attachments: [[String: Any]] + ) -> [String: Any] { + [ + "id": conversationId.uuidString, + "modelId": "model", + "title": "Migration fixture", + "createdAt": "2026-08-11T10:00:00Z", + "updatedAt": "2026-08-11T10:00:00Z", + "isPinned": false, + "messages": [[ + "id": UUID().uuidString, + "role": "user", + "content": "Attachments", + "timestamp": "2026-08-11T10:00:00Z", + "attachments": attachments + ]] + ] + } + + func writeConversationFixture(_ json: [String: Any], conversationId: UUID) throws -> URL { + let directory = testDirectory.appendingPathComponent("Conversations", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("\(conversationId.uuidString).json") + try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]).write(to: url) + return url + } + + func assertRecoveryContains(_ data: Data) throws { + let recoveryURL = testDirectory + .appendingPathComponent("ConversationRecovery/Migrations/Attachments", isDirectory: true) + let recoveryFiles = try FileManager.default.contentsOfDirectory( + at: recoveryURL, + includingPropertiesForKeys: nil + ) + XCTAssertTrue(try recoveryFiles.contains { try Data(contentsOf: $0) == data }) + } +} diff --git a/openclient-llm-test/Features/Launch/LaunchRemoteBannerTests.swift b/openclient-llm-test/Features/Launch/LaunchRemoteBannerTests.swift index 58ca8dac..f4df19a4 100644 --- a/openclient-llm-test/Features/Launch/LaunchRemoteBannerTests.swift +++ b/openclient-llm-test/Features/Launch/LaunchRemoteBannerTests.swift @@ -32,7 +32,7 @@ final class LaunchRemoteBannerTests: XCTestCase { attachmentMigrationUseCase: MockAttachmentMigrationUseCase(), remoteConfigManager: mockRemoteConfigManager, settingsManager: mockSettingsManager, - currentVersion: "1.6.10", + currentVersion: "1.6.15", localeIdentifier: "es-ES", launchDelay: .zero ) diff --git a/openclient-llm-test/Features/Launch/LaunchViewModelTests.swift b/openclient-llm-test/Features/Launch/LaunchViewModelTests.swift index 6fb06184..d5ef3136 100644 --- a/openclient-llm-test/Features/Launch/LaunchViewModelTests.swift +++ b/openclient-llm-test/Features/Launch/LaunchViewModelTests.swift @@ -33,7 +33,7 @@ final class LaunchViewModelTests: XCTestCase { resetAppDataUseCase: mockResetAppData, attachmentMigrationUseCase: mockAttachmentMigration, remoteConfigManager: mockRemoteConfigManager, - currentVersion: "1.6.10", + currentVersion: "1.6.15", launchDelay: .zero ) } @@ -67,17 +67,47 @@ final class LaunchViewModelTests: XCTestCase { XCTAssertEqual(sut.state, .onboarding) } - func test_send_viewAppeared_onboardingNotCompleted_resetsAppData() { + func test_send_viewAppeared_onboardingNotCompleted_resetsAppData() async { // Given mockUseCase.result = false // When sut.send(.viewAppeared) + await waitForLaunch() // Then XCTAssertTrue(mockResetAppData.executeCalled) } + func test_send_viewAppeared_initialResetFails_setsResetFailedState() async { + // Given + mockUseCase.result = false + mockResetAppData.executeError = NSError(domain: "LaunchViewModelTests", code: 1) + + // When + sut.send(.viewAppeared) + await waitForLaunch() + + // Then + XCTAssertEqual(sut.state, .resetFailed) + } + + func test_send_resetRetried_afterFailure_retriesReset() async { + // Given + mockUseCase.result = false + mockResetAppData.executeError = NSError(domain: "LaunchViewModelTests", code: 1) + sut.send(.viewAppeared) + await waitForLaunch() + mockResetAppData.executeError = nil + + // When + sut.send(.resetRetried) + await waitForLaunch() + + // Then + XCTAssertEqual(sut.state, .onboarding) + } + func test_send_viewAppeared_onboardingCompleted_setsHomeState() async { // Given mockUseCase.result = true @@ -98,7 +128,7 @@ final class LaunchViewModelTests: XCTestCase { resetAppDataUseCase: mockResetAppData, attachmentMigrationUseCase: mockAttachmentMigration, remoteConfigManager: mockRemoteConfigManager, - currentVersion: "1.6.10", + currentVersion: "1.6.15", launchDelay: .milliseconds(500) ) @@ -128,7 +158,7 @@ final class LaunchViewModelTests: XCTestCase { resetAppDataUseCase: mockResetAppData, attachmentMigrationUseCase: mockAttachmentMigration, remoteConfigManager: mockRemoteConfigManager, - currentVersion: "1.6.10", + currentVersion: "1.6.15", launchDelay: .zero ) diff --git a/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift b/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift index 81e7c4bf..0c1dd4e9 100644 --- a/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift +++ b/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift @@ -17,6 +17,8 @@ final class ResetAppDataUseCaseTests: XCTestCase { private var mockSettingsManager: MockSettingsManager! private var mockConversationRepository: MockConversationRepository! private var mockUserProfileManager: MockUserProfileManager! + private var mockMemoryManager: MockMemoryManager! + private var categoryOperationGate: CloudCategoryOperationGate! // MARK: - Setup @@ -26,10 +28,14 @@ final class ResetAppDataUseCaseTests: XCTestCase { mockSettingsManager = MockSettingsManager() mockConversationRepository = MockConversationRepository() mockUserProfileManager = MockUserProfileManager() + mockMemoryManager = MockMemoryManager() + categoryOperationGate = CloudCategoryOperationGate() sut = ResetAppDataUseCase( settingsManager: mockSettingsManager, conversationRepository: mockConversationRepository, - userProfileManager: mockUserProfileManager + userProfileManager: mockUserProfileManager, + memoryManager: mockMemoryManager, + categoryOperationGate: categoryOperationGate ) } @@ -38,44 +44,105 @@ final class ResetAppDataUseCaseTests: XCTestCase { mockSettingsManager = nil mockConversationRepository = nil mockUserProfileManager = nil + mockMemoryManager = nil + categoryOperationGate = nil try await super.tearDown() } // MARK: - Tests - func test_execute_callsDeleteAll() { + func test_execute_callsDeleteAll() async throws { // Given mockSettingsManager.serverBaseURL = "https://example.com" mockSettingsManager.apiKey = "sk-test" // When - sut.execute() + try await sut.execute() // Then XCTAssertTrue(mockSettingsManager.deleteAllCalled) } - func test_execute_deletesAllConversations() { + func test_execute_deletesAllConversations() async throws { // Given let conversation = Conversation(modelId: "gpt-4") mockConversationRepository.conversations = [conversation] // When - sut.execute() + try await sut.execute() // Then XCTAssertTrue(mockConversationRepository.conversations.isEmpty) + XCTAssertEqual(mockConversationRepository.cancelAndDeleteAllCallCount, 1) } - func test_execute_deletesLocalProfile() { + func test_execute_deletesLocalProfile() async throws { // Given mockUserProfileManager.localProfile = UserProfile(name: "Test", profileDescription: "", extraInfo: "") // When - sut.execute() + try await sut.execute() // Then XCTAssertTrue(mockUserProfileManager.localProfile.isEmpty) } + + func test_execute_conversationDeletionFails_throwsWithoutDeletingOtherConversationData() async { + // Given + let expectedError = NSError(domain: "ResetAppDataUseCaseTests", code: 1) + mockConversationRepository.deleteAllError = expectedError + mockUserProfileManager.localProfile = UserProfile(name: "Test", profileDescription: "", extraInfo: "") + mockMemoryManager.items = [MemoryItem(content: "Keep")] + + // When + do { + try await sut.execute() + XCTFail("Expected conversation deletion failure") + } catch { + // Then + XCTAssertEqual(error as NSError, expectedError) + XCTAssertFalse(mockUserProfileManager.localProfile.isEmpty) + XCTAssertEqual(mockMemoryManager.items.map(\.content), ["Keep"]) + } + } + + func test_execute_memoryDeletionFails_throwsInsteadOfReportingSuccess() async { + // Given + let expectedError = NSError(domain: "ResetAppDataUseCaseTests", code: 2) + mockMemoryManager.deleteAllError = expectedError + + // When + do { + try await sut.execute() + XCTFail("Expected memory deletion failure") + } catch { + // Then + XCTAssertEqual(error as NSError, expectedError) + } + } + + func test_execute_profileCloudOperationInFlight_waitsBeforeResettingData() async throws { + // Given + let operationStarted = TestAsyncGate() + let releaseOperation = TestAsyncGate() + let inFlightOperation = Task { + try await categoryOperationGate.perform { + await operationStarted.open() + await releaseOperation.wait() + } + } + await operationStarted.wait() + + // When + let reset = Task { try await sut.execute() } + await Task.yield() + + // Then + XCTAssertFalse(mockSettingsManager.deleteAllCalled) + await releaseOperation.open() + try await inFlightOperation.value + try await reset.value + XCTAssertTrue(mockSettingsManager.deleteAllCalled) + } } diff --git a/openclient-llm-test/Features/PromptTemplates/PromptTemplateRepositoryCloudTests.swift b/openclient-llm-test/Features/PromptTemplates/PromptTemplateRepositoryCloudTests.swift new file mode 100644 index 00000000..a7cd5e73 --- /dev/null +++ b/openclient-llm-test/Features/PromptTemplates/PromptTemplateRepositoryCloudTests.swift @@ -0,0 +1,206 @@ +// +// PromptTemplateRepositoryCloudTests.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class PromptTemplateRepositoryCloudTests: XCTestCase { + func test_loadAll_legacyTemplate_migratesUpdatedAtFromCreatedAt() async throws { + // Given + let context = try makeContext(cloudEnabled: false) + defer { try? FileManager.default.removeItem(at: context.rootURL) } + let createdAt = Date(timeIntervalSince1970: 1_000) + let template = PromptTemplate(id: UUID(), title: "Legacy", content: "Body", createdAt: createdAt) + let legacyData = try legacyData(for: template) + try legacyData.write(to: templateURL(template.id, in: context.directoryURL), options: .atomic) + + // When + let loaded = try await context.repository.loadAll() + + // Then + XCTAssertEqual(loaded.first { $0.id == template.id }?.updatedAt, createdAt) + let migratedData = try Data(contentsOf: templateURL(template.id, in: context.directoryURL)) + XCTAssertNotNil(try JSONSerialization.jsonObject(with: migratedData) as? [String: Any]) + let migratedJSON = try XCTUnwrap(String(data: migratedData, encoding: .utf8)) + XCTAssertTrue(migratedJSON.contains("updatedAt")) + } + + func test_loadAll_equalRevisionConflict_usesDeterministicWinnerAndPreservesLoser() async throws { + // Given + let context = try makeContext(cloudEnabled: true) + defer { try? FileManager.default.removeItem(at: context.rootURL) } + let id = UUID() + let createdAt = Date(timeIntervalSince1970: 1_000) + let revision = Date(timeIntervalSince1970: 2_000) + let local = PromptTemplate( + id: id, + title: "Local", + content: "Body", + createdAt: createdAt, + updatedAt: revision + ) + let cloud = PromptTemplate( + id: id, + title: "Cloud", + content: "Body", + createdAt: createdAt, + updatedAt: revision + ) + let localData = try encode(local) + let cloudData = try encode(cloud) + try localData.write(to: templateURL(id, in: context.directoryURL), options: .atomic) + context.cloud.cloudTemplates = [cloud] + let expected = cloudData.lexicographicallyPrecedes(localData) ? local : cloud + + // When + let loaded = try await context.repository.loadAll() + + // Then + XCTAssertEqual(loaded.first { $0.id == id }, expected) + let recoveryURL = context.rootURL.appendingPathComponent("PromptTemplateRecovery", isDirectory: true) + let recoveryFiles = try FileManager.default.contentsOfDirectory( + at: recoveryURL, + includingPropertiesForKeys: nil + ) + XCTAssertEqual(recoveryFiles.filter { $0.pathExtension == "json" }.count, 1) + } + + func test_loadAll_cloudTombstoneNewerThanLocal_removesStaleLocalCopy() async throws { + // Given + let context = try makeContext(cloudEnabled: true) + defer { try? FileManager.default.removeItem(at: context.rootURL) } + let template = PromptTemplate( + id: UUID(), + title: "Stale", + content: "Body", + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + try encode(template).write(to: templateURL(template.id, in: context.directoryURL), options: .atomic) + context.cloud.cloudTemplateDeletionMarkers[template.id] = CloudDeletionMarker( + id: template.id, + deletedAt: Date(timeIntervalSince1970: 2_000) + ) + + // When + let loaded = try await context.repository.loadAll() + + // Then + XCTAssertFalse(loaded.contains { $0.id == template.id }) + XCTAssertFalse(FileManager.default.fileExists(atPath: templateURL(template.id, in: context.directoryURL).path)) + } + + func test_loadAll_localRevisionNewerThanCloudTombstone_recreatesTemplate() async throws { + // Given + let context = try makeContext(cloudEnabled: true) + defer { try? FileManager.default.removeItem(at: context.rootURL) } + let template = PromptTemplate( + id: UUID(), + title: "Recreated", + content: "Body", + createdAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 3_000) + ) + try encode(template).write(to: templateURL(template.id, in: context.directoryURL), options: .atomic) + context.cloud.cloudTemplateDeletionMarkers[template.id] = CloudDeletionMarker( + id: template.id, + deletedAt: Date(timeIntervalSince1970: 2_000) + ) + + // When + let loaded = try await context.repository.loadAll() + + // Then + XCTAssertEqual(loaded.first { $0.id == template.id }, template) + XCTAssertEqual(context.cloud.cloudTemplates, [template]) + XCTAssertNil(context.cloud.cloudTemplateDeletionMarkers[template.id]) + } + + func test_delete_cloudFailure_retainsIntentAndRetriesBeforeCloudLoad() async throws { + // Given + let context = try makeContext(cloudEnabled: true) + defer { try? FileManager.default.removeItem(at: context.rootURL) } + let template = PromptTemplate(title: "Deleted", content: "Body") + context.cloud.cloudTemplates = [template] + try await context.repository.save(template) + context.cloud.syncError = CloudSyncError.containerUnavailable + + // When + do { + try await context.repository.delete(template.id) + XCTFail("Expected cloud delete failure") + } catch { + XCTAssertEqual(error as? CloudSyncError, .containerUnavailable) + } + do { + _ = try await context.repository.loadAll() + XCTFail("Expected retained deletion retry to fail") + } catch { + XCTAssertEqual(error as? CloudSyncError, .containerUnavailable) + } + let markerURL = context.directoryURL + .appendingPathComponent(".DeletionMetadata/\(template.id.uuidString).json") + XCTAssertTrue(FileManager.default.fileExists(atPath: markerURL.path)) + context.cloud.syncError = nil + let loaded = try await context.repository.loadAll() + + // Then + XCTAssertFalse(loaded.contains { $0.id == template.id }) + XCTAssertGreaterThanOrEqual(context.cloud.deletedTemplateIds.filter { $0 == template.id }.count, 1) + } + + // MARK: - Private + + private struct TestContext { + let rootURL: URL + let directoryURL: URL + let cloud: MockCloudSyncManager + let repository: PromptTemplateRepository + } + + private func makeContext(cloudEnabled: Bool) throws -> TestContext { + let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directoryURL = rootURL.appendingPathComponent("PromptTemplates", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let settings = MockSettingsManager() + settings.isCloudSyncEnabled = cloudEnabled + let cloud = MockCloudSyncManager() + return TestContext( + rootURL: rootURL, + directoryURL: directoryURL, + cloud: cloud, + repository: PromptTemplateRepository( + settingsManager: settings, + cloudSyncManager: cloud, + directoryURL: directoryURL + ) + ) + } + + private func templateURL(_ id: UUID, in directoryURL: URL) -> URL { + directoryURL.appendingPathComponent("\(id.uuidString).json") + } + + private func encode(_ template: PromptTemplate) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(template) + } + + private func legacyData(for template: PromptTemplate) throws -> Data { + let formatter = ISO8601DateFormatter() + return try JSONSerialization.data(withJSONObject: [ + "id": template.id.uuidString, + "title": template.title, + "content": template.content, + "isBuiltIn": template.isBuiltIn, + "createdAt": formatter.string(from: template.createdAt) + ]) + } +} diff --git a/openclient-llm-test/Features/PromptTemplates/PromptTemplatesViewModelTests.swift b/openclient-llm-test/Features/PromptTemplates/PromptTemplatesViewModelTests.swift index 64cf5a82..a424c49e 100644 --- a/openclient-llm-test/Features/PromptTemplates/PromptTemplatesViewModelTests.swift +++ b/openclient-llm-test/Features/PromptTemplates/PromptTemplatesViewModelTests.swift @@ -54,7 +54,7 @@ final class PromptTemplatesViewModelTests: XCTestCase { // MARK: - Tests — viewAppeared - func test_send_viewAppeared_loadsBuiltInsAndCustomSeparately() { + func test_send_viewAppeared_loadsBuiltInsAndCustomSeparately() async { // Given let builtIn = PromptTemplate(id: UUID(), title: "Coding", content: "You are...", isBuiltIn: true) let custom = PromptTemplate(id: UUID(), title: "My Template", content: "Custom prompt", isBuiltIn: false) @@ -62,6 +62,7 @@ final class PromptTemplatesViewModelTests: XCTestCase { // When sut.send(.viewAppeared) + await waitUntil { self.mockLoadTemplates.executeCallCount == 1 } // Then guard case .loaded(let loadedState) = sut.state else { @@ -72,13 +73,17 @@ final class PromptTemplatesViewModelTests: XCTestCase { XCTAssertNil(loadedState.errorMessage) } - func test_send_viewAppeared_whenLoadFails_setsErrorMessage() { + func test_send_viewAppeared_whenLoadFails_setsErrorMessage() async { // Given let loadError = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Load failed"]) mockLoadTemplates.result = .failure(loadError) // When sut.send(.viewAppeared) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.errorMessage != nil + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -89,12 +94,13 @@ final class PromptTemplatesViewModelTests: XCTestCase { XCTAssertEqual(loadedState.errorMessage, "Load failed") } - func test_send_viewAppeared_incrementsLoadCallCount() { + func test_send_viewAppeared_incrementsLoadCallCount() async { // Given mockLoadTemplates.result = .success([]) // When sut.send(.viewAppeared) + await waitUntil { self.mockLoadTemplates.executeCallCount == 1 } // Then XCTAssertEqual(mockLoadTemplates.executeCallCount, 1) @@ -102,7 +108,7 @@ final class PromptTemplatesViewModelTests: XCTestCase { // MARK: - Tests — saveTapped (create) - func test_send_saveTapped_newTemplate_savesAndReloads() { + func test_send_saveTapped_newTemplate_savesAndReloads() async { // Given mockLoadTemplates.result = .success([]) sut.send(.viewAppeared) @@ -112,6 +118,7 @@ final class PromptTemplatesViewModelTests: XCTestCase { // When sut.send(.saveTapped(title: "New", content: "Content", editingTemplate: nil)) + await waitUntil { self.mockSaveTemplate.savedTemplates.count == 1 } // Then XCTAssertEqual(mockSaveTemplate.savedTemplates.count, 1) @@ -120,12 +127,13 @@ final class PromptTemplatesViewModelTests: XCTestCase { XCTAssertEqual(mockAppReviewManager.requestReviewCallCount, 1) } - func test_send_saveTapped_newTemplate_reloadsAfterSave() { + func test_send_saveTapped_newTemplate_reloadsAfterSave() async { // Given mockLoadTemplates.result = .success([]) // When sut.send(.saveTapped(title: "Title", content: "Body", editingTemplate: nil)) + await waitUntil { self.mockLoadTemplates.executeCallCount == 1 } // Then XCTAssertEqual(mockLoadTemplates.executeCallCount, 1) @@ -133,22 +141,24 @@ final class PromptTemplatesViewModelTests: XCTestCase { // MARK: - Tests — saveTapped (edit) - func test_send_saveTapped_editingTemplate_preservesIdAndCreatedAt() { + func test_send_saveTapped_editingTemplate_preservesIdAndCreatedAt() async { // Given let existing = PromptTemplate(id: UUID(), title: "Old", content: "Old content", isBuiltIn: false) mockLoadTemplates.result = .success([]) // When sut.send(.saveTapped(title: "Updated", content: "Updated content", editingTemplate: existing)) + await waitUntil { self.mockSaveTemplate.savedTemplates.count == 1 } // Then XCTAssertEqual(mockSaveTemplate.savedTemplates.first?.id, existing.id) XCTAssertEqual(mockSaveTemplate.savedTemplates.first?.createdAt, existing.createdAt) + XCTAssertGreaterThan(mockSaveTemplate.savedTemplates.first?.updatedAt ?? .distantPast, existing.updatedAt) XCTAssertEqual(mockSaveTemplate.savedTemplates.first?.title, "Updated") XCTAssertEqual(mockAppReviewManager.requestReviewCallCount, 0) } - func test_send_saveTapped_whenSaveFails_setsErrorMessage() { + func test_send_saveTapped_whenSaveFails_setsErrorMessage() async { // Given mockLoadTemplates.result = .success([]) sut.send(.viewAppeared) @@ -156,6 +166,10 @@ final class PromptTemplatesViewModelTests: XCTestCase { // When sut.send(.saveTapped(title: "T", content: "C", editingTemplate: nil)) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.errorMessage == "Save failed" + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -167,13 +181,17 @@ final class PromptTemplatesViewModelTests: XCTestCase { // MARK: - Tests — deleteTapped - func test_send_deleteTapped_customTemplate_deletesAndReloads() { + func test_send_deleteTapped_customTemplate_deletesAndReloads() async { // Given let template = PromptTemplate(id: UUID(), title: "Custom", content: "Body", isBuiltIn: false) mockLoadTemplates.result = .success([]) // When sut.send(.deleteTapped(template)) + await waitUntil { + self.mockDeleteTemplate.deletedIds == [template.id] + && self.mockLoadTemplates.executeCallCount == 1 + } // Then XCTAssertEqual(mockDeleteTemplate.deletedIds, [template.id]) @@ -192,7 +210,7 @@ final class PromptTemplatesViewModelTests: XCTestCase { XCTAssertTrue(mockDeleteTemplate.deletedIds.isEmpty) } - func test_send_deleteTapped_whenDeleteFails_setsErrorMessage() { + func test_send_deleteTapped_whenDeleteFails_setsErrorMessage() async { // Given let template = PromptTemplate(id: UUID(), title: "Custom", content: "Body", isBuiltIn: false) mockLoadTemplates.result = .success([]) @@ -202,6 +220,10 @@ final class PromptTemplatesViewModelTests: XCTestCase { // When sut.send(.deleteTapped(template)) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.errorMessage == "Delete failed" + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -209,4 +231,12 @@ final class PromptTemplatesViewModelTests: XCTestCase { } XCTAssertEqual(loadedState.errorMessage, "Delete failed") } + + private func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<100 { + if condition() { return } + await Task.yield() + } + XCTFail("Condition was not satisfied") + } } diff --git a/openclient-llm-test/Features/Settings/MemoryViewModelTests.swift b/openclient-llm-test/Features/Settings/MemoryViewModelTests.swift index ccdad235..18c0e210 100644 --- a/openclient-llm-test/Features/Settings/MemoryViewModelTests.swift +++ b/openclient-llm-test/Features/Settings/MemoryViewModelTests.swift @@ -74,14 +74,33 @@ final class MemoryViewModelTests: XCTestCase { XCTAssertEqual(loadedState.items.first?.content, "User likes Swift") } + func test_send_viewAppeared_synchronizationFails_retainsVisibleError() async { + // Given + mockMemoryManager.synchronizeError = CloudSyncError.requiredDownloadPending + + // When + sut.send(.viewAppeared) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.errorMessage != nil + } + + // Then + guard case .loaded(let loadedState) = sut.state else { + return XCTFail("Expected loaded state") + } + XCTAssertNotNil(loadedState.errorMessage) + } + // MARK: - Tests — addItem - func test_send_addItem_savesItemWithUserSource() { + func test_send_addItem_savesItemWithUserSource() async { // Given sut.send(.viewAppeared) // When sut.send(.addItem(content: "Prefers dark mode")) + await waitUntil { self.mockMemoryManager.addedItem != nil } // Then XCTAssertEqual(mockMemoryManager.addedItem?.content, "Prefers dark mode") @@ -102,23 +121,28 @@ final class MemoryViewModelTests: XCTestCase { XCTAssertEqual(mockAppReviewManager.requestReviewCallCount, 0) } - func test_send_addItem_trimsWhitespace() { + func test_send_addItem_trimsWhitespace() async { // Given sut.send(.viewAppeared) // When sut.send(.addItem(content: " Swift developer ")) + await waitUntil { self.mockMemoryManager.addedItem != nil } // Then XCTAssertEqual(mockMemoryManager.addedItem?.content, "Swift developer") } - func test_send_addItem_updatesLoadedState() { + func test_send_addItem_updatesLoadedState() async { // Given sut.send(.viewAppeared) // When sut.send(.addItem(content: "New item")) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.items.count == 1 + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -128,9 +152,28 @@ final class MemoryViewModelTests: XCTestCase { XCTAssertEqual(loadedState.items.count, 1) } + func test_send_addItem_persistenceFails_retainsVisibleError() async { + // Given + mockMemoryManager.mutationError = NSError(domain: "MemoryViewModelTests", code: 1) + sut.send(.viewAppeared) + + // When + sut.send(.addItem(content: "New item")) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.errorMessage != nil + } + + // Then + guard case .loaded(let loadedState) = sut.state else { + return XCTFail("Expected loaded state") + } + XCTAssertNotNil(loadedState.errorMessage) + } + // MARK: - Tests — editItem - func test_send_editItem_updatesExistingItem() { + func test_send_editItem_updatesExistingItem() async { // Given let item = MemoryItem(content: "Original", source: .user) mockMemoryManager.items = [item] @@ -138,6 +181,7 @@ final class MemoryViewModelTests: XCTestCase { // When sut.send(.editItem(id: item.id, content: "Updated")) + await waitUntil { self.mockMemoryManager.updatedItem != nil } // Then XCTAssertEqual(mockMemoryManager.updatedItem?.content, "Updated") @@ -159,7 +203,7 @@ final class MemoryViewModelTests: XCTestCase { // MARK: - Tests — toggleItem - func test_send_toggleItem_flipsIsEnabled() { + func test_send_toggleItem_flipsIsEnabled() async { // Given let item = MemoryItem(content: "Test", isEnabled: true, source: .user) mockMemoryManager.items = [item] @@ -167,6 +211,7 @@ final class MemoryViewModelTests: XCTestCase { // When sut.send(.toggleItem(id: item.id)) + await waitUntil { self.mockMemoryManager.updatedItem != nil } // Then XCTAssertEqual(mockMemoryManager.updatedItem?.id, item.id) @@ -175,7 +220,7 @@ final class MemoryViewModelTests: XCTestCase { // MARK: - Tests — deleteItem - func test_send_deleteItem_removesItem() { + func test_send_deleteItem_removesItem() async { // Given let item = MemoryItem(content: "To delete", source: .user) mockMemoryManager.items = [item] @@ -183,12 +228,13 @@ final class MemoryViewModelTests: XCTestCase { // When sut.send(.deleteItem(id: item.id)) + await waitUntil { self.mockMemoryManager.deletedId != nil } // Then XCTAssertEqual(mockMemoryManager.deletedId, item.id) } - func test_send_deleteItem_updatesLoadedState() { + func test_send_deleteItem_updatesLoadedState() async { // Given let item = MemoryItem(content: "To delete", source: .user) mockMemoryManager.items = [item] @@ -196,6 +242,10 @@ final class MemoryViewModelTests: XCTestCase { // When sut.send(.deleteItem(id: item.id)) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.items.isEmpty + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -204,4 +254,12 @@ final class MemoryViewModelTests: XCTestCase { } XCTAssertTrue(loadedState.items.isEmpty) } + + private func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<100 { + if condition() { return } + await Task.yield() + } + XCTFail("Condition was not satisfied") + } } diff --git a/openclient-llm-test/Features/Settings/SettingsViewModelTests+CloudSync.swift b/openclient-llm-test/Features/Settings/SettingsViewModelTests+CloudSync.swift index 93222c1b..ca81958e 100644 --- a/openclient-llm-test/Features/Settings/SettingsViewModelTests+CloudSync.swift +++ b/openclient-llm-test/Features/Settings/SettingsViewModelTests+CloudSync.swift @@ -11,20 +11,117 @@ import XCTest @MainActor extension SettingsViewModelTests { - func test_send_syncConversationsTapped_updatesSyncResult() { + // MARK: - Toggle + + func test_send_cloudSyncToggled_enablesSync() async { + // Given + sut.send(.viewAppeared) + + // When + sut.send(.cloudSyncToggled(true)) + await waitUntil { self.mockSynchronizeAppData.executeCallCount == 1 } + + // Then + guard case .loaded(let loadedState) = sut.state else { + XCTFail("Expected loaded state") + return + } + XCTAssertTrue(loadedState.isCloudSyncEnabled) + XCTAssertTrue(mockSettingsManager.isCloudSyncEnabled) + XCTAssertEqual(mockSynchronizeAppData.executeCallCount, 1) + } + + func test_send_cloudSyncToggled_disablesSyncAfterCancellationQuiesces() async { // Given mockSettingsManager.isCloudSyncEnabled = true - mockSyncConversations.result = .pendingDownload + sut.send(.viewAppeared) + let gate = TestAsyncGate() + mockSynchronizeAppData.cancelHandler = { await gate.wait() } + + // When + sut.send(.cloudSyncToggled(false)) + await waitUntil { self.mockSynchronizeAppData.cancelCallCount == 1 } + + // Then + guard case .loaded(let loadedState) = sut.state else { + XCTFail("Expected loaded state") + return + } + XCTAssertFalse(loadedState.isCloudSyncEnabled) + XCTAssertFalse(mockSettingsManager.isCloudSyncEnabled) + await gate.open() + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return !state.isCloudSyncEnabled + } + } + + func test_send_cloudSyncToggled_profileDownloadPending_keepsIntentDisabledAndShowsPending() async { + // Given + mockUserProfileManager.cloudError = CloudSyncError.requiredDownloadPending sut.send(.viewAppeared) // When - sut.send(.syncConversationsTapped) + sut.send(.cloudSyncToggled(true)) + await waitUntil { self.mockUserProfileManager.getCloudProfileCallCount == 1 } + + // Then + guard case .loaded(let loadedState) = sut.state else { + XCTFail("Expected loaded state") + return + } + XCTAssertFalse(loadedState.isCloudSyncEnabled) + XCTAssertFalse(mockSettingsManager.isCloudSyncEnabled) + XCTAssertNil(mockUserProfileManager.resolvedKeepLocal) + XCTAssertEqual(mockSynchronizeAppData.executeCallCount, 0) + } + + func test_send_cloudSyncToggled_true_enablesDirectly_whenBothMatch() async { + // Given + let sameProfile = UserProfile(name: "Same", profileDescription: "Desc", extraInfo: "Info") + mockUserProfileManager.localProfile = sameProfile + mockUserProfileManager.cloudProfile = sameProfile + sut.send(.viewAppeared) + + // When + sut.send(.cloudSyncToggled(true)) + await waitUntil { self.mockSettingsManager.isCloudSyncEnabled } + + // Then + guard case .loaded(let loadedState) = sut.state else { + return XCTFail("Expected loaded state") + } + XCTAssertFalse(loadedState.showCloudSyncConflictAlert) + XCTAssertTrue(loadedState.isCloudSyncEnabled) + } + + // MARK: - Manual Sync + + func test_send_syncNowTapped_partialFailure_retainsCategoryResult() async { + // Given + mockSettingsManager.isCloudSyncEnabled = true + mockSynchronizeAppData.result = AppSynchronizationResult(outcomes: [ + .conversations: .synchronized, + .profile: .synchronized, + .memory: .failed, + .promptTemplates: .pendingDownload + ]) + sut.send(.viewAppeared) + + // When + sut.send(.syncNowTapped) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.synchronizationResult == self.mockSynchronizeAppData.result + } // Then guard case .loaded(let loadedState) = sut.state else { XCTFail("Expected loaded state") return } - XCTAssertEqual(loadedState.conversationSyncResult, .pendingDownload) + XCTAssertEqual(loadedState.synchronizationResult?.categories(with: .failed), [.memory]) + XCTAssertEqual(loadedState.synchronizationResult?.categories(with: .pendingDownload), [.promptTemplates]) + XCTAssertFalse(loadedState.synchronizationResult?.isSuccessful ?? true) } } diff --git a/openclient-llm-test/Features/Settings/SettingsViewModelTests+Helpers.swift b/openclient-llm-test/Features/Settings/SettingsViewModelTests+Helpers.swift new file mode 100644 index 00000000..f04469d3 --- /dev/null +++ b/openclient-llm-test/Features/Settings/SettingsViewModelTests+Helpers.swift @@ -0,0 +1,25 @@ +// +// SettingsViewModelTests+Helpers.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest + +// MARK: - Helpers + +extension SettingsViewModelTests { + func waitUntil( + _ condition: @MainActor () -> Bool, + file: StaticString = #filePath, + line: UInt = #line + ) async { + for _ in 0..<100 { + if condition() { return } + await Task.yield() + } + XCTFail("Condition was not satisfied", file: file, line: line) + } +} diff --git a/openclient-llm-test/Features/Settings/SettingsViewModelTests.swift b/openclient-llm-test/Features/Settings/SettingsViewModelTests.swift index 79ef2530..bc326fbd 100644 --- a/openclient-llm-test/Features/Settings/SettingsViewModelTests.swift +++ b/openclient-llm-test/Features/Settings/SettingsViewModelTests.swift @@ -19,10 +19,10 @@ final class SettingsViewModelTests: XCTestCase { private var mockCheckLiteLLMHealth: MockCheckLiteLLMHealthUseCase! var mockSettingsManager: MockSettingsManager! private var mockCloudSyncManager: MockCloudSyncManager! - private var mockUserProfileManager: MockUserProfileManager! + var mockUserProfileManager: MockUserProfileManager! private var mockResetUseCase: MockResetAppDataUseCase! var mockFetchSearchTools: MockFetchSearchToolsUseCase! - var mockSyncConversations: MockSyncConversationsUseCase! + var mockSynchronizeAppData: MockSynchronizeAppDataUseCase! // MARK: - Setup @@ -37,7 +37,7 @@ final class SettingsViewModelTests: XCTestCase { mockUserProfileManager = MockUserProfileManager() mockResetUseCase = MockResetAppDataUseCase() mockFetchSearchTools = MockFetchSearchToolsUseCase() - mockSyncConversations = MockSyncConversationsUseCase() + mockSynchronizeAppData = MockSynchronizeAppDataUseCase() sut = SettingsViewModel( saveServerConfigurationUseCase: mockSaveServerConfig, testServerConnectionUseCase: mockTestConnection, @@ -45,7 +45,7 @@ final class SettingsViewModelTests: XCTestCase { fetchSearchToolsUseCase: mockFetchSearchTools, settingsManager: mockSettingsManager, cloudSyncManager: mockCloudSyncManager, - syncConversationsUseCase: mockSyncConversations, + synchronizeAppDataUseCase: mockSynchronizeAppData, userProfileManager: mockUserProfileManager, resetAppUseCase: mockResetUseCase ) @@ -61,7 +61,7 @@ final class SettingsViewModelTests: XCTestCase { mockUserProfileManager = nil mockResetUseCase = nil mockFetchSearchTools = nil - mockSyncConversations = nil + mockSynchronizeAppData = nil try await super.tearDown() } @@ -190,48 +190,16 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertTrue(loadedState.isSaved) } - // MARK: - Tests — cloudSyncToggled - - func test_send_cloudSyncToggled_enablesSync() { - // Given - sut.send(.viewAppeared) - - // When - sut.send(.cloudSyncToggled(true)) - - // Then - guard case .loaded(let loadedState) = sut.state else { - XCTFail("Expected loaded state") - return - } - XCTAssertTrue(loadedState.isCloudSyncEnabled) - XCTAssertTrue(mockSettingsManager.isCloudSyncEnabled) - XCTAssertEqual(mockSyncConversations.executeCallCount, 1) - } - - func test_send_cloudSyncToggled_disablesSync() { - // Given - mockSettingsManager.isCloudSyncEnabled = true - sut.send(.viewAppeared) - - // When - sut.send(.cloudSyncToggled(false)) - - // Then - guard case .loaded(let loadedState) = sut.state else { - XCTFail("Expected loaded state") - return - } - XCTAssertFalse(loadedState.isCloudSyncEnabled) - XCTAssertFalse(mockSettingsManager.isCloudSyncEnabled) - } - - func test_send_viewAppeared_loadsCloudAvailability() { + func test_send_viewAppeared_loadsCloudAvailability() async { // Given mockCloudSyncManager.cloudAvailable = true // When sut.send(.viewAppeared) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.isCloudAvailable + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -241,12 +209,13 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertTrue(loadedState.isCloudAvailable) } - func test_send_viewAppeared_cloudNotAvailable() { + func test_send_viewAppeared_cloudNotAvailable() async { // Given mockCloudSyncManager.cloudAvailable = false // When sut.send(.viewAppeared) + await waitUntil { self.mockCloudSyncManager.checkCloudAvailabilityCallCount == 1 } // Then guard case .loaded(let loadedState) = sut.state else { @@ -309,7 +278,7 @@ final class SettingsViewModelTests: XCTestCase { // MARK: - Tests — cloudSyncToggled conflict - func test_send_cloudSyncToggled_true_showsConflictAlert_whenBothHaveData() { + func test_send_cloudSyncToggled_true_showsConflictAlert_whenBothHaveData() async { // Given mockUserProfileManager.localProfile = UserProfile(name: "Local", profileDescription: "", extraInfo: "") mockUserProfileManager.cloudProfile = UserProfile(name: "Cloud", profileDescription: "", extraInfo: "") @@ -317,6 +286,10 @@ final class SettingsViewModelTests: XCTestCase { // When sut.send(.cloudSyncToggled(true)) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.showCloudSyncConflictAlert + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -327,7 +300,7 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertFalse(loadedState.isCloudSyncEnabled) } - func test_send_cloudSyncToggled_true_enablesDirectly_whenNoConflict() { + func test_send_cloudSyncToggled_true_enablesDirectly_whenNoConflict() async { // Given mockUserProfileManager.localProfile = UserProfile(name: "Local", profileDescription: "", extraInfo: "") mockUserProfileManager.cloudProfile = nil @@ -335,6 +308,7 @@ final class SettingsViewModelTests: XCTestCase { // When sut.send(.cloudSyncToggled(true)) + await waitUntil { self.mockSettingsManager.isCloudSyncEnabled } // Then guard case .loaded(let loadedState) = sut.state else { @@ -346,7 +320,7 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertTrue(mockSettingsManager.isCloudSyncEnabled) } - func test_send_cloudSyncToggled_true_pushesLocalToCloud_whenCloudEmpty() { + func test_send_cloudSyncToggled_true_pushesLocalToCloud_whenCloudEmpty() async { // Given mockUserProfileManager.localProfile = UserProfile(name: "Local", profileDescription: "", extraInfo: "") mockUserProfileManager.cloudProfile = nil @@ -354,20 +328,26 @@ final class SettingsViewModelTests: XCTestCase { // When sut.send(.cloudSyncToggled(true)) + await waitUntil { self.mockUserProfileManager.resolvedKeepLocal != nil } // Then XCTAssertEqual(mockUserProfileManager.resolvedKeepLocal, true) } - func test_send_cloudSyncConflictResolved_keepLocal_enablesSyncAndResolvesConflict() { + func test_send_cloudSyncConflictResolved_keepLocal_enablesSyncAndResolvesConflict() async { // Given mockUserProfileManager.localProfile = UserProfile(name: "Local", profileDescription: "", extraInfo: "") mockUserProfileManager.cloudProfile = UserProfile(name: "Cloud", profileDescription: "", extraInfo: "") sut.send(.viewAppeared) sut.send(.cloudSyncToggled(true)) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.showCloudSyncConflictAlert + } // When sut.send(.cloudSyncConflictResolved(keepLocal: true)) + await waitUntil { self.mockSettingsManager.isCloudSyncEnabled } // Then guard case .loaded(let loadedState) = sut.state else { @@ -380,15 +360,20 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertEqual(mockUserProfileManager.resolvedKeepLocal, true) } - func test_send_cloudSyncConflictResolved_keepCloud_enablesSyncAndResolvesConflict() { + func test_send_cloudSyncConflictResolved_keepCloud_enablesSyncAndResolvesConflict() async { // Given mockUserProfileManager.localProfile = UserProfile(name: "Local", profileDescription: "", extraInfo: "") mockUserProfileManager.cloudProfile = UserProfile(name: "Cloud", profileDescription: "", extraInfo: "") sut.send(.viewAppeared) sut.send(.cloudSyncToggled(true)) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.showCloudSyncConflictAlert + } // When sut.send(.cloudSyncConflictResolved(keepLocal: false)) + await waitUntil { self.mockSettingsManager.isCloudSyncEnabled } // Then guard case .loaded(let loadedState) = sut.state else { @@ -400,12 +385,16 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertEqual(mockUserProfileManager.resolvedKeepLocal, false) } - func test_send_cloudSyncConflictCancelled_dismissesAlertWithoutEnablingSync() { + func test_send_cloudSyncConflictCancelled_dismissesAlertWithoutEnablingSync() async { // Given mockUserProfileManager.localProfile = UserProfile(name: "Local", profileDescription: "", extraInfo: "") mockUserProfileManager.cloudProfile = UserProfile(name: "Cloud", profileDescription: "", extraInfo: "") sut.send(.viewAppeared) sut.send(.cloudSyncToggled(true)) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.showCloudSyncConflictAlert + } // When sut.send(.cloudSyncConflictCancelled) @@ -421,18 +410,19 @@ final class SettingsViewModelTests: XCTestCase { // MARK: - Tests — resetConfirmed - func test_send_resetConfirmed_executesReset() { + func test_send_resetConfirmed_executesReset() async { // Given sut.send(.viewAppeared) // When sut.send(.resetConfirmed) + await waitUntil { self.mockResetUseCase.executeCalled } // Then XCTAssertTrue(mockResetUseCase.executeCalled) } - func test_send_resetConfirmed_reloadsSettingsFromManager() { + func test_send_resetConfirmed_reloadsSettingsFromManager() async { // Given mockSettingsManager.serverBaseURL = "https://example.com" sut.send(.viewAppeared) @@ -442,6 +432,10 @@ final class SettingsViewModelTests: XCTestCase { // When sut.send(.resetConfirmed) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.serverURL == "https://after-reset.local" + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -451,24 +445,21 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertEqual(loadedState.serverURL, "https://after-reset.local") } - // MARK: - Tests — cloudSyncToggled both match - - func test_send_cloudSyncToggled_true_enablesDirectly_whenBothMatch() { + func test_send_resetConfirmed_failure_retainsVisibleError() async { // Given - let sameProfile = UserProfile(name: "Same", profileDescription: "Desc", extraInfo: "Info") - mockUserProfileManager.localProfile = sameProfile - mockUserProfileManager.cloudProfile = sameProfile + mockResetUseCase.executeError = NSError(domain: "SettingsViewModelTests", code: 1) sut.send(.viewAppeared) // When - sut.send(.cloudSyncToggled(true)) + sut.send(.resetConfirmed) + await waitUntil { + guard case .loaded(let loadedState) = self.sut.state else { return false } + return loadedState.resetErrorMessage != nil + } // Then - guard case .loaded(let loadedState) = sut.state else { - XCTFail("Expected loaded state") - return - } - XCTAssertFalse(loadedState.showCloudSyncConflictAlert) - XCTAssertTrue(loadedState.isCloudSyncEnabled) + guard case .loaded(let loadedState) = sut.state else { return XCTFail("Expected loaded state") } + XCTAssertNotNil(loadedState.resetErrorMessage) } + } diff --git a/openclient-llm-test/Features/Settings/SynchronizeAppDataUseCaseTests.swift b/openclient-llm-test/Features/Settings/SynchronizeAppDataUseCaseTests.swift new file mode 100644 index 00000000..47d2da36 --- /dev/null +++ b/openclient-llm-test/Features/Settings/SynchronizeAppDataUseCaseTests.swift @@ -0,0 +1,105 @@ +// +// SynchronizeAppDataUseCaseTests.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +final class SynchronizeAppDataUseCaseTests: XCTestCase { + // MARK: - Tests + + func test_execute_allCategoriesSucceed_returnsSuccessfulResult() async { + // Given + let dependencies = makeDependencies() + let sut = makeSUT(dependencies) + + // When + let result = await sut.execute() + + // Then + XCTAssertTrue(result.isSuccessful) + XCTAssertEqual(dependencies.conversations.executeCallCount, 1) + XCTAssertEqual(dependencies.profile.getCloudProfileCallCount, 1) + XCTAssertTrue(dependencies.memory.synchronizeCalled) + XCTAssertEqual(dependencies.templates.loadCallCount, 1) + } + + func test_execute_categoryFailures_returnsEveryAffectedCategory() async { + // Given + let dependencies = makeDependencies() + dependencies.conversations.result = .synchronized + dependencies.memory.synchronizeError = CloudSyncError.requiredDownloadPending + dependencies.templates.loadError = NSError(domain: "SynchronizeAppDataUseCaseTests", code: 1) + let sut = makeSUT(dependencies) + + // When + let result = await sut.execute() + + // Then + XCTAssertEqual(result.categories(with: .pendingDownload), [.memory]) + XCTAssertEqual(result.categories(with: .failed), [.promptTemplates]) + XCTAssertEqual(dependencies.templates.loadCallCount, 1) + XCTAssertFalse(result.isSuccessful) + } + + func test_execute_divergentProfile_returnsConflictWithoutSkippingLaterCategories() async { + // Given + let dependencies = makeDependencies() + let modifiedAt = Date() + dependencies.profile.localProfile = UserProfile( + name: "Local", + profileDescription: "", + extraInfo: "", + modifiedAt: modifiedAt + ) + dependencies.profile.cloudProfile = UserProfile( + name: "Cloud", + profileDescription: "", + extraInfo: "", + modifiedAt: modifiedAt + ) + let sut = makeSUT(dependencies) + + // When + let result = await sut.execute() + + // Then + XCTAssertEqual(result.categories(with: .conflict), [.profile]) + XCTAssertTrue(dependencies.memory.synchronizeCalled) + XCTAssertEqual(dependencies.templates.loadCallCount, 1) + } +} + +// MARK: - Helpers + +private extension SynchronizeAppDataUseCaseTests { + struct Dependencies { + let conversations: MockSyncConversationsUseCase + let profile: MockUserProfileManager + let memory: MockMemoryManager + let templates: MockPromptTemplateRepository + } + + func makeDependencies() -> Dependencies { + Dependencies( + conversations: MockSyncConversationsUseCase(), + profile: MockUserProfileManager(), + memory: MockMemoryManager(), + templates: MockPromptTemplateRepository() + ) + } + + func makeSUT(_ dependencies: Dependencies) -> SynchronizeAppDataUseCase { + SynchronizeAppDataUseCase( + syncConversationsUseCase: dependencies.conversations, + userProfileManager: dependencies.profile, + memoryManager: dependencies.memory, + promptTemplateRepository: dependencies.templates + ) + } +} diff --git a/openclient-llm-test/Features/Settings/UserProfileTests.swift b/openclient-llm-test/Features/Settings/UserProfileTests.swift index 3ce80cb7..0cbc3f4b 100644 --- a/openclient-llm-test/Features/Settings/UserProfileTests.swift +++ b/openclient-llm-test/Features/Settings/UserProfileTests.swift @@ -25,6 +25,17 @@ final class UserProfileTests: XCTestCase { XCTAssertFalse(UserProfile(profileDescription: "Developer").isEmpty) } + func test_decode_legacyProfileWithoutModificationDate_usesDistantPastRevision() throws { + // Given + let data = Data(#"{"name":"Alice","profileDescription":"Developer","extraInfo":"Swift"}"#.utf8) + + // When + let profile = try JSONDecoder().decode(UserProfile.self, from: data) + + // Then + XCTAssertEqual(profile.modifiedAt, .distantPast) + } + // MARK: - Tests — systemPromptContext func test_systemPromptContext_emptyProfileReturnsEmptyString() { diff --git a/openclient-llm-test/Features/Settings/UserProfileViewModelTests.swift b/openclient-llm-test/Features/Settings/UserProfileViewModelTests.swift index dc1464ea..a3da259b 100644 --- a/openclient-llm-test/Features/Settings/UserProfileViewModelTests.swift +++ b/openclient-llm-test/Features/Settings/UserProfileViewModelTests.swift @@ -83,12 +83,13 @@ final class UserProfileViewModelTests: XCTestCase { // MARK: - Tests — save - func test_send_save_persistsProfile() { + func test_send_save_persistsProfile() async { // Given sut.send(.viewAppeared) // When sut.send(.save(name: "Bob", description: "Engineer", extraInfo: "Swift enthusiast")) + await waitUntil { self.mockUserProfileManager.savedProfile != nil } // Then XCTAssertEqual(mockUserProfileManager.savedProfile?.name, "Bob") @@ -96,12 +97,16 @@ final class UserProfileViewModelTests: XCTestCase { XCTAssertEqual(mockUserProfileManager.savedProfile?.extraInfo, "Swift enthusiast") } - func test_send_save_updatesLoadedState() { + func test_send_save_updatesLoadedState() async { // Given sut.send(.viewAppeared) // When sut.send(.save(name: "Alice", description: "Designer", extraInfo: "Loves colors")) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.name == "Alice" + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -113,12 +118,16 @@ final class UserProfileViewModelTests: XCTestCase { XCTAssertEqual(loadedState.extraInfo, "Loves colors") } - func test_send_save_updatesOriginalValues() { + func test_send_save_updatesOriginalValues() async { // Given sut.send(.viewAppeared) // When sut.send(.save(name: "Alice", description: "Designer", extraInfo: "Loves colors")) + await waitUntil { + guard case .loaded(let state) = self.sut.state else { return false } + return state.originalName == "Alice" + } // Then guard case .loaded(let loadedState) = sut.state else { @@ -129,4 +138,12 @@ final class UserProfileViewModelTests: XCTestCase { XCTAssertEqual(loadedState.originalDescription, "Designer") XCTAssertEqual(loadedState.originalExtraInfo, "Loves colors") } + + private func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<100 { + if condition() { return } + await Task.yield() + } + XCTFail("Condition was not satisfied") + } } diff --git a/openclient-llm-test/Mocks/MockAttachmentRepository.swift b/openclient-llm-test/Mocks/MockAttachmentRepository.swift index f24f7968..fca19674 100644 --- a/openclient-llm-test/Mocks/MockAttachmentRepository.swift +++ b/openclient-llm-test/Mocks/MockAttachmentRepository.swift @@ -9,7 +9,8 @@ import Foundation @testable import openclient_llm -// Safety: Only used within serialized @MainActor test methods. +// Safety: Repository tests access an injected instance serially through one ConversationStorage actor. +// Other tests access their instance only from serialized @MainActor test methods. final class MockAttachmentRepository: AttachmentRepositoryProtocol, @unchecked Sendable { // MARK: - Supporting Types @@ -23,11 +24,14 @@ final class MockAttachmentRepository: AttachmentRepositoryProtocol, @unchecked S var savedAttachments: [SaveRecord] = [] var saveResult: Result = .success("Attachments/test-conv/test-att.jpg") + var saveHandler: ((ChatMessage.Attachment, UUID) throws -> String)? var loadedData: Data = Data() + var loadHandler: ((ChatMessage.Attachment) throws -> Data)? var loadError: Error? var deletedAttachments: [ChatMessage.Attachment] = [] var deleteError: Error? var deleteAllConversationIds: [UUID] = [] + var deleteAllError: Error? var deleteAllCalled = false // MARK: - Save @@ -38,6 +42,10 @@ final class MockAttachmentRepository: AttachmentRepositoryProtocol, @unchecked S conversationId: UUID ) throws -> String { savedAttachments.append(SaveRecord(data: data, attachment: attachment, conversationId: conversationId)) + loadedData = data + if let saveHandler { + return try saveHandler(attachment, conversationId) + } switch saveResult { case .success(let path): return path case .failure(let error): throw error @@ -48,6 +56,7 @@ final class MockAttachmentRepository: AttachmentRepositoryProtocol, @unchecked S func load(attachment: ChatMessage.Attachment) throws -> Data { if let error = loadError { throw error } + if let loadHandler { return try loadHandler(attachment) } return loadedData } @@ -59,6 +68,7 @@ final class MockAttachmentRepository: AttachmentRepositoryProtocol, @unchecked S } func deleteAll(forConversationId conversationId: UUID) throws { + if let deleteAllError { throw deleteAllError } deleteAllConversationIds.append(conversationId) } diff --git a/openclient-llm-test/Mocks/MockBranchConversationUseCase.swift b/openclient-llm-test/Mocks/MockBranchConversationUseCase.swift index 0b71ff58..25440b5a 100644 --- a/openclient-llm-test/Mocks/MockBranchConversationUseCase.swift +++ b/openclient-llm-test/Mocks/MockBranchConversationUseCase.swift @@ -20,7 +20,7 @@ final class MockBranchConversationUseCase: BranchConversationUseCaseProtocol, @u // MARK: - Execute - func execute(conversation: Conversation, fromMessageId: UUID) throws -> Conversation { + func execute(conversation: Conversation, fromMessageId: UUID) async throws -> Conversation { executedConversations.append(conversation) executedConversationIds.append(conversation.id) executedFromMessageIds.append(fromMessageId) diff --git a/openclient-llm-test/Mocks/MockCloudSyncManager.swift b/openclient-llm-test/Mocks/MockCloudSyncManager.swift index a2e418e5..5d667ac8 100644 --- a/openclient-llm-test/Mocks/MockCloudSyncManager.swift +++ b/openclient-llm-test/Mocks/MockCloudSyncManager.swift @@ -9,32 +9,43 @@ import Foundation @testable import openclient_llm -// Safety: Only used within serialized @MainActor test methods. +// Safety: Repository tests inject one instance into one ConversationStorage actor and inspect it only after +// awaited calls. +// Other tests access their instance only from serialized @MainActor test methods. final class MockCloudSyncManager: CloudSyncManagerProtocol, @unchecked Sendable { // MARK: - Properties var cloudAvailable: Bool = true + var checkCloudAvailabilityCallCount = 0 var cloudConversations: [Conversation] = [] var cloudIds: Set? var syncedConversations: [Conversation] = [] var deletedIds: [UUID] = [] var deleteAllCalled: Bool = false var syncError: Error? + var validationError: Error? var loadError: Error? var cloudProfile: UserProfile? + var cloudProfileDeletionMarker: CloudDeletionMarker? var savedProfile: UserProfile? var deleteProfileCalled: Bool = false var cloudTemplates: [PromptTemplate] = [] var cloudTemplateIds: Set? + var cloudTemplateDeletionMarkers: [UUID: CloudDeletionMarker] = [:] var syncedTemplates: [PromptTemplate] = [] var deletedTemplateIds: [UUID] = [] var cloudMemoryItems: [MemoryItem]? + var cloudMemoryDeletionMarkers: [CloudDeletionMarker] = [] var savedMemoryItems: [MemoryItem]? var deleteMemoryCalled: Bool = false var pendingConversationDownloads: Bool = false var cloudTombstones: [ConversationTombstone] = [] var cloudDeleteAllMarker: ConversationDeleteAllMarker? + var cloudAttachmentData: [CloudAttachmentKey: Data] = [:] var materializedConversationIds: [UUID] = [] + var loadConversationsCallCount = 0 + var loadConversationsHandler: (() -> Void)? + var loadConversationsSendableHandler: (@Sendable () -> Void)? // MARK: - Public @@ -42,6 +53,67 @@ final class MockCloudSyncManager: CloudSyncManagerProtocol, @unchecked Sendable cloudAvailable } + func checkCloudAvailability() async -> Bool { + checkCloudAvailabilityCallCount += 1 + return cloudAvailable + } + + func loadConversationSyncSnapshot() throws -> ConversationCloudSyncSnapshot { + loadConversationsCallCount += 1 + loadConversationsHandler?() + loadConversationsSendableHandler?() + if pendingConversationDownloads { throw CloudSyncError.requiredDownloadPending } + if let loadError { throw loadError } + let encoder = SyncJSONCoding.makeEncoder() + let conversationData = try Dictionary(uniqueKeysWithValues: cloudConversations.map { + ($0.id, try encoder.encode($0)) + }) + let decoder = SyncJSONCoding.makeDecoder() + let canonicalConversations = try Dictionary(uniqueKeysWithValues: conversationData.map { id, data in + (id, try decoder.decode(Conversation.self, from: data)) + }) + let tombstoneData = try Dictionary(uniqueKeysWithValues: cloudTombstones.map { + ($0.conversationId, try encoder.encode($0)) + }) + return ConversationCloudSyncSnapshot( + session: CloudSyncSession( + containerURL: URL(fileURLWithPath: "/mock-cloud"), + identity: Data("mock-cloud".utf8) + ), + manifestData: nil, + conversations: canonicalConversations, + conversationData: conversationData, + tombstones: cloudTombstones, + tombstoneData: tombstoneData, + legacyTombstoneData: nil, + deleteAllMarker: cloudDeleteAllMarker, + deleteAllMarkerData: try cloudDeleteAllMarker.map { try encoder.encode($0) }, + attachmentData: cloudAttachmentData, + attachmentPlaceholders: [] + ) + } + + func applyConversationSyncOutput( + _ output: ConversationCloudSyncOutput, + basedOn snapshot: ConversationCloudSyncSnapshot + ) throws { + if let syncError { throw syncError } + let outputIds = Set(output.conversations.map(\.id)) + deletedIds.append(contentsOf: Set(cloudConversations.map(\.id)).subtracting(outputIds)) + syncedConversations.append(contentsOf: output.conversations) + cloudConversations = output.conversations + cloudTombstones = output.tombstones + cloudDeleteAllMarker = output.deleteAllMarker + cloudAttachmentData = output.attachments + } + + func validateConversationSyncOutput( + _ output: ConversationCloudSyncOutput, + basedOn snapshot: ConversationCloudSyncSnapshot + ) throws { + if let validationError { throw validationError } + } + func syncConversationsToCloud(_ conversations: [Conversation]) throws { if let syncError { throw syncError } syncedConversations.append(contentsOf: conversations) @@ -52,11 +124,13 @@ final class MockCloudSyncManager: CloudSyncManagerProtocol, @unchecked Sendable } func loadConversationsFromCloud() throws -> [Conversation] { + loadConversationsCallCount += 1 + loadConversationsHandler?() if let loadError { throw loadError } return cloudConversations } - func allCloudConversationIds() -> Set? { + func allCloudConversationIds() throws -> Set? { cloudIds } @@ -96,47 +170,111 @@ final class MockCloudSyncManager: CloudSyncManagerProtocol, @unchecked Sendable cloudDeleteAllMarker = marker } - func saveProfileToCloud(_ profile: UserProfile) throws { + func saveProfileToCloud(_ profile: UserProfile) async throws { + if let syncError { throw syncError } savedProfile = profile + cloudProfile = profile + cloudProfileDeletionMarker = nil } - func loadProfileFromCloud() throws -> UserProfile? { - cloudProfile + func loadProfileFromCloud() async throws -> UserProfile? { + if let loadError { throw loadError } + guard case .profile(let profile) = try await loadProfileStateFromCloud() else { return nil } + return profile } - func deleteProfileFromCloud() throws { + func loadProfileStateFromCloud() async throws -> CloudUserProfileState { + if let loadError { throw loadError } + if let marker = cloudProfileDeletionMarker { + guard let cloudProfile, cloudProfile.modifiedAt > marker.deletedAt else { return .deleted(marker) } + return .profile(cloudProfile) + } + return cloudProfile.map(CloudUserProfileState.profile) ?? .missing + } + + func deleteProfileFromCloud() async throws { + if let syncError { throw syncError } deleteProfileCalled = true cloudProfile = nil + cloudProfileDeletionMarker = CloudDeletionMarker(id: CloudSyncManager.profileMarkerId, deletedAt: Date()) } - func syncTemplatesToCloud(_ templates: [PromptTemplate]) throws { + func syncTemplatesToCloud(_ templates: [PromptTemplate]) async throws { if let syncError { throw syncError } - syncedTemplates.append(contentsOf: templates) + for template in templates { + if let marker = cloudTemplateDeletionMarkers[template.id], template.updatedAt <= marker.deletedAt { + cloudTemplates.removeAll { $0.id == template.id && $0.updatedAt <= marker.deletedAt } + continue + } + syncedTemplates.append(template) + cloudTemplates.removeAll { $0.id == template.id } + cloudTemplates.append(template) + cloudTemplateDeletionMarkers.removeValue(forKey: template.id) + } + cloudTemplateIds = Set(cloudTemplates.map(\.id)) } - func loadTemplatesFromCloud() throws -> [PromptTemplate] { + func loadTemplatesFromCloud() async throws -> PromptTemplateCloudSnapshot { if let loadError { throw loadError } - return cloudTemplates - } - - func allCloudTemplateIds() -> Set? { - cloudTemplateIds + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let templates = cloudTemplates.filter { template in + guard let marker = cloudTemplateDeletionMarkers[template.id] else { return true } + return template.updatedAt > marker.deletedAt + } + let data = try Dictionary(uniqueKeysWithValues: templates.map { + ($0.id, try encoder.encode($0)) + }) + return PromptTemplateCloudSnapshot( + templates: templates, + templateData: data, + deletionMarkers: cloudTemplateDeletionMarkers + ) } - func deleteTemplateFromCloud(_ templateId: UUID) throws { + func deleteTemplateFromCloud(_ templateId: UUID, deletedAt: Date) async throws { + if let syncError { throw syncError } deletedTemplateIds.append(templateId) + let existingDate = cloudTemplateDeletionMarkers[templateId]?.deletedAt ?? .distantPast + let effectiveDate = max(existingDate, deletedAt) + cloudTemplateDeletionMarkers[templateId] = CloudDeletionMarker(id: templateId, deletedAt: effectiveDate) + cloudTemplates.removeAll { $0.id == templateId && $0.updatedAt <= effectiveDate } + if !cloudTemplates.contains(where: { $0.id == templateId }) { + cloudTemplateIds?.remove(templateId) + } } - func saveMemoryToCloud(_ items: [MemoryItem]) throws { + func saveMemoryToCloud(_ items: [MemoryItem]) async throws { + if let syncError { throw syncError } savedMemoryItems = items + cloudMemoryItems = items.filter { item in + guard let marker = cloudMemoryDeletionMarkers.first(where: { $0.id == item.id }) else { return true } + return item.updatedAt > marker.deletedAt + } } - func loadMemoryFromCloud() throws -> [MemoryItem]? { - cloudMemoryItems + func loadMemorySyncSnapshot() async throws -> MemoryCloudSyncSnapshot { + if let loadError { throw loadError } + let eligibleItems = cloudMemoryItems?.filter { item in + guard let marker = cloudMemoryDeletionMarkers.first(where: { $0.id == item.id }) else { return true } + return item.updatedAt > marker.deletedAt + } + return MemoryCloudSyncSnapshot( + items: eligibleItems, + deletionMarkers: cloudMemoryDeletionMarkers + ) } - func deleteMemoryFromCloud() throws { - deleteMemoryCalled = true - cloudMemoryItems = nil + func deleteMemoryItemFromCloud(_ itemId: UUID, deletedAt: Date) async throws { + if let syncError { throw syncError } + let existingDate = cloudMemoryDeletionMarkers + .first(where: { $0.id == itemId })? + .deletedAt ?? .distantPast + let effectiveDate = max(existingDate, deletedAt) + cloudMemoryDeletionMarkers.removeAll { $0.id == itemId } + cloudMemoryDeletionMarkers.append(CloudDeletionMarker(id: itemId, deletedAt: effectiveDate)) + cloudMemoryItems?.removeAll { $0.id == itemId && $0.updatedAt <= effectiveDate } } + } diff --git a/openclient-llm-test/Mocks/MockConversationCloudObserver.swift b/openclient-llm-test/Mocks/MockConversationCloudObserver.swift deleted file mode 100644 index 5b46feb9..00000000 --- a/openclient-llm-test/Mocks/MockConversationCloudObserver.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MockConversationCloudObserver.swift -// openclient-llm -// -// Created by Arturo Carretero Calvo on 09/08/2026. -// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. -// - -import Foundation -@testable import openclient_llm - -// Safety: Only used within serialized @MainActor test methods. -final class MockConversationCloudObserver: ConversationCloudObserving, @unchecked Sendable { - var startCallCount = 0 - - func start() { - startCallCount += 1 - } -} diff --git a/openclient-llm-test/Mocks/MockConversationRepository.swift b/openclient-llm-test/Mocks/MockConversationRepository.swift index 4d52b0ec..a614b2dc 100644 --- a/openclient-llm-test/Mocks/MockConversationRepository.swift +++ b/openclient-llm-test/Mocks/MockConversationRepository.swift @@ -16,24 +16,28 @@ final class MockConversationRepository: ConversationRepositoryProtocol, @uncheck var conversations: [Conversation] = [] var saveError: Error? var deleteError: Error? + var deleteAllError: Error? var loadError: Error? var savedConversations: [Conversation] = [] var deletedIds: [UUID] = [] var synchronizeResult: ConversationSyncResult = .synchronized + var cancelSynchronizationCallCount = 0 + var cancelAndDeleteAllCallCount = 0 // MARK: - Public - func loadAll() throws -> [Conversation] { + func loadAll() async throws -> [Conversation] { if let loadError { throw loadError } return conversations } - func loadLocal() throws -> [Conversation] { + func loadLocal() async throws -> [Conversation] { if let loadError { throw loadError } return conversations } - func save(_ conversation: Conversation) throws { + @discardableResult + func save(_ conversation: Conversation, expectedBase: Conversation?) async throws -> Conversation { if let saveError { throw saveError } savedConversations.append(conversation) if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { @@ -41,19 +45,69 @@ final class MockConversationRepository: ConversationRepositoryProtocol, @uncheck } else { conversations.append(conversation) } + return conversation } - func delete(_ conversationId: UUID) throws { + func importBatch(_ conversations: [Conversation]) async throws -> [Conversation] { + if let saveError { throw saveError } + savedConversations.append(contentsOf: conversations) + self.conversations.append(contentsOf: conversations) + return conversations + } + + func setPinned(_ isPinned: Bool, conversationId: UUID) async throws -> Conversation? { + if let loadError { throw loadError } + guard var conversation = conversations.first(where: { $0.id == conversationId }) else { return nil } + conversation.isPinned = isPinned + conversation.updatedAt = Date() + try await save(conversation) + return conversation + } + + func rename(_ conversationId: UUID, title: String) async throws -> Conversation? { + if let loadError { throw loadError } + guard var conversation = conversations.first(where: { $0.id == conversationId }) else { return nil } + conversation.title = title.trimmingCharacters(in: .whitespacesAndNewlines) + conversation.updatedAt = Date() + try await save(conversation) + return conversation + } + + func updateTags(_ conversationId: UUID, tags: [ConversationTag]) async throws -> Conversation? { + if let loadError { throw loadError } + guard var conversation = conversations.first(where: { $0.id == conversationId }) else { return nil } + let colorsByName = conversations.flatMap(\.tags).reduce(into: [String: TagColor]()) { colors, tag in + if colors[tag.name] == nil { + colors[tag.name] = tag.color + } + } + conversation.tags = tags.map { ConversationTag(name: $0.name, color: colorsByName[$0.name] ?? $0.color) } + conversation.updatedAt = Date() + try await save(conversation) + return conversation + } + + func delete(_ conversationId: UUID) async throws { if let deleteError { throw deleteError } deletedIds.append(conversationId) conversations.removeAll { $0.id == conversationId } } - func deleteAll() throws { + func deleteAll() async throws { + if let deleteAllError { throw deleteAllError } conversations.removeAll() } - func synchronize() -> ConversationSyncResult { + func synchronize() async -> ConversationSyncResult { synchronizeResult } + + func cancelSynchronization() async { + cancelSynchronizationCallCount += 1 + } + + func cancelSynchronizationAndDeleteAll() async throws { + cancelAndDeleteAllCallCount += 1 + try await deleteAll() + } } diff --git a/openclient-llm-test/Mocks/MockDeleteConversationUseCase.swift b/openclient-llm-test/Mocks/MockDeleteConversationUseCase.swift index 1f5f101a..447f556e 100644 --- a/openclient-llm-test/Mocks/MockDeleteConversationUseCase.swift +++ b/openclient-llm-test/Mocks/MockDeleteConversationUseCase.swift @@ -18,7 +18,7 @@ final class MockDeleteConversationUseCase: DeleteConversationUseCaseProtocol, @u // MARK: - Execute - func execute(_ conversationId: UUID) throws { + func execute(_ conversationId: UUID) async throws { if let error { throw error } deletedIds.append(conversationId) } diff --git a/openclient-llm-test/Mocks/MockDeletePromptTemplateUseCase.swift b/openclient-llm-test/Mocks/MockDeletePromptTemplateUseCase.swift index 6160e4c1..8501f21d 100644 --- a/openclient-llm-test/Mocks/MockDeletePromptTemplateUseCase.swift +++ b/openclient-llm-test/Mocks/MockDeletePromptTemplateUseCase.swift @@ -18,7 +18,7 @@ final class MockDeletePromptTemplateUseCase: DeletePromptTemplateUseCaseProtocol // MARK: - Execute - func execute(_ templateId: UUID) throws { + func execute(_ templateId: UUID) async throws { if let error { throw error } deletedIds.append(templateId) } diff --git a/openclient-llm-test/Mocks/MockExportBackupUseCase.swift b/openclient-llm-test/Mocks/MockExportBackupUseCase.swift index fedce75c..ef2f6e32 100644 --- a/openclient-llm-test/Mocks/MockExportBackupUseCase.swift +++ b/openclient-llm-test/Mocks/MockExportBackupUseCase.swift @@ -18,7 +18,7 @@ final class MockExportBackupUseCase: ExportBackupUseCaseProtocol, @unchecked Sen // MARK: - Execute - func execute() throws -> Data { + func execute() async throws -> Data { executeCallCount += 1 return try result.get() } diff --git a/openclient-llm-test/Mocks/MockImportConversationsUseCase.swift b/openclient-llm-test/Mocks/MockImportConversationsUseCase.swift index 8cb744cf..53c0d188 100644 --- a/openclient-llm-test/Mocks/MockImportConversationsUseCase.swift +++ b/openclient-llm-test/Mocks/MockImportConversationsUseCase.swift @@ -24,7 +24,7 @@ final class MockImportConversationsUseCase: ImportConversationsUseCaseProtocol, // MARK: - Execute - func execute(_ data: Data) throws -> ImportConversationsResult { + func execute(_ data: Data) async throws -> ImportConversationsResult { importedData.append(data) return try result.get() } diff --git a/openclient-llm-test/Mocks/MockLoadConversationsUseCase.swift b/openclient-llm-test/Mocks/MockLoadConversationsUseCase.swift index 650c536d..16aa2267 100644 --- a/openclient-llm-test/Mocks/MockLoadConversationsUseCase.swift +++ b/openclient-llm-test/Mocks/MockLoadConversationsUseCase.swift @@ -18,11 +18,11 @@ final class MockLoadConversationsUseCase: LoadConversationsUseCaseProtocol, @unc // MARK: - Execute - func execute() throws -> [Conversation] { + func execute() async throws -> [Conversation] { try result.get() } - func executeLocally() throws -> [Conversation] { + func executeLocally() async throws -> [Conversation] { executeLocallyCallCount += 1 return try result.get() } diff --git a/openclient-llm-test/Mocks/MockLoadPromptTemplatesUseCase.swift b/openclient-llm-test/Mocks/MockLoadPromptTemplatesUseCase.swift index fbefacba..c2fd6fba 100644 --- a/openclient-llm-test/Mocks/MockLoadPromptTemplatesUseCase.swift +++ b/openclient-llm-test/Mocks/MockLoadPromptTemplatesUseCase.swift @@ -18,7 +18,7 @@ final class MockLoadPromptTemplatesUseCase: LoadPromptTemplatesUseCaseProtocol, // MARK: - Execute - func execute() throws -> [PromptTemplate] { + func execute() async throws -> [PromptTemplate] { executeCallCount += 1 return try result.get() } diff --git a/openclient-llm-test/Mocks/MockMemoryManager.swift b/openclient-llm-test/Mocks/MockMemoryManager.swift index 4f9b87c4..f1f0fdbf 100644 --- a/openclient-llm-test/Mocks/MockMemoryManager.swift +++ b/openclient-llm-test/Mocks/MockMemoryManager.swift @@ -19,6 +19,10 @@ final class MockMemoryManager: MemoryManagerProtocol, @unchecked Sendable { var updatedItem: MemoryItem? var deletedId: UUID? var deleteAllCalled: Bool = false + var synchronizeCalled: Bool = false + var deleteAllError: Error? + var synchronizeError: Error? + var mutationError: Error? // MARK: - MemoryManagerProtocol @@ -26,24 +30,33 @@ final class MockMemoryManager: MemoryManagerProtocol, @unchecked Sendable { items } - func add(_ item: MemoryItem) { + func synchronize() async throws { + synchronizeCalled = true + if let synchronizeError { throw synchronizeError } + } + + func add(_ item: MemoryItem) async throws { + if let mutationError { throw mutationError } addedItem = item items.append(item) } - func update(_ item: MemoryItem) { + func update(_ item: MemoryItem) async throws { + if let mutationError { throw mutationError } updatedItem = item if let index = items.firstIndex(where: { $0.id == item.id }) { items[index] = item } } - func delete(id: UUID) { + func delete(id: UUID) async throws { + if let mutationError { throw mutationError } deletedId = id items.removeAll { $0.id == id } } - func deleteAll() { + func deleteAll() async throws { + if let deleteAllError { throw deleteAllError } deleteAllCalled = true items.removeAll() } diff --git a/openclient-llm-test/Mocks/MockPinConversationUseCase.swift b/openclient-llm-test/Mocks/MockPinConversationUseCase.swift index 3dbb8156..9614b926 100644 --- a/openclient-llm-test/Mocks/MockPinConversationUseCase.swift +++ b/openclient-llm-test/Mocks/MockPinConversationUseCase.swift @@ -19,7 +19,7 @@ final class MockPinConversationUseCase: PinConversationUseCaseProtocol, @uncheck // MARK: - Public - func execute(_ conversationId: UUID, isPinned: Bool) throws { + func execute(_ conversationId: UUID, isPinned: Bool) async throws { if let error { throw error } executedId = conversationId executedIsPinned = isPinned diff --git a/openclient-llm-test/Mocks/MockPromptTemplateRepository.swift b/openclient-llm-test/Mocks/MockPromptTemplateRepository.swift index 6c99b219..f306cbc1 100644 --- a/openclient-llm-test/Mocks/MockPromptTemplateRepository.swift +++ b/openclient-llm-test/Mocks/MockPromptTemplateRepository.swift @@ -19,15 +19,17 @@ final class MockPromptTemplateRepository: PromptTemplateRepositoryProtocol, @unc var deleteError: Error? var savedTemplates: [PromptTemplate] = [] var deletedIds: [UUID] = [] + var loadCallCount = 0 // MARK: - Public - func loadAll() throws -> [PromptTemplate] { + func loadAll() async throws -> [PromptTemplate] { + loadCallCount += 1 if let loadError { throw loadError } return templates } - func save(_ template: PromptTemplate) throws { + func save(_ template: PromptTemplate) async throws { if let saveError { throw saveError } savedTemplates.append(template) if let index = templates.firstIndex(where: { $0.id == template.id }) { @@ -37,7 +39,7 @@ final class MockPromptTemplateRepository: PromptTemplateRepositoryProtocol, @unc } } - func delete(_ templateId: UUID) throws { + func delete(_ templateId: UUID) async throws { if let deleteError { throw deleteError } deletedIds.append(templateId) templates.removeAll { $0.id == templateId } diff --git a/openclient-llm-test/Mocks/MockRemoteConfigManager.swift b/openclient-llm-test/Mocks/MockRemoteConfigManager.swift index 035111f5..b13b7f0b 100644 --- a/openclient-llm-test/Mocks/MockRemoteConfigManager.swift +++ b/openclient-llm-test/Mocks/MockRemoteConfigManager.swift @@ -23,7 +23,7 @@ extension RemoteConfig { isMaintenanceEnabled: Bool = false, isUpdateEnabled: Bool = true, isForceUpdate: Bool = false, - latestVersion: String = "1.6.10", + latestVersion: String = "1.6.15", banner: Banner? = nil ) -> RemoteConfig { let update = PlatformUpdate( diff --git a/openclient-llm-test/Mocks/MockRenameConversationUseCase.swift b/openclient-llm-test/Mocks/MockRenameConversationUseCase.swift index 025f5c10..b849349a 100644 --- a/openclient-llm-test/Mocks/MockRenameConversationUseCase.swift +++ b/openclient-llm-test/Mocks/MockRenameConversationUseCase.swift @@ -19,7 +19,7 @@ final class MockRenameConversationUseCase: RenameConversationUseCaseProtocol, @u // MARK: - Public - func execute(_ conversationId: UUID, newTitle: String) throws { + func execute(_ conversationId: UUID, newTitle: String) async throws { if let error { throw error } capturedId = conversationId capturedTitle = newTitle diff --git a/openclient-llm-test/Mocks/MockResetAppDataUseCase.swift b/openclient-llm-test/Mocks/MockResetAppDataUseCase.swift index 0941a81e..bde015dd 100644 --- a/openclient-llm-test/Mocks/MockResetAppDataUseCase.swift +++ b/openclient-llm-test/Mocks/MockResetAppDataUseCase.swift @@ -14,10 +14,12 @@ final class MockResetAppDataUseCase: ResetAppDataUseCaseProtocol, @unchecked Sen // MARK: - Properties var executeCalled: Bool = false + var executeError: Error? // MARK: - Execute - func execute() { + func execute() async throws { executeCalled = true + if let executeError { throw executeError } } } diff --git a/openclient-llm-test/Mocks/MockSaveConversationUseCase.swift b/openclient-llm-test/Mocks/MockSaveConversationUseCase.swift index 2f2d1620..2617d59c 100644 --- a/openclient-llm-test/Mocks/MockSaveConversationUseCase.swift +++ b/openclient-llm-test/Mocks/MockSaveConversationUseCase.swift @@ -14,18 +14,39 @@ final class MockSaveConversationUseCase: SaveConversationUseCaseProtocol, @unche // MARK: - Properties var savedConversations: [Conversation] = [] + var expectedBases: [Conversation?] = [] var error: Error? var failureAtCall: Int? + var result: Conversation? + var executeHandler: ((Conversation, Conversation?) throws -> Conversation)? + var asyncExecuteHandler: ((Conversation, Conversation?, Int) async throws -> Conversation)? var executeCallCount = 0 + var importBatches: [[Conversation]] = [] // MARK: - Execute - func execute(_ conversation: Conversation) throws { + @discardableResult + func execute(_ conversation: Conversation, expectedBase: Conversation?) async throws -> Conversation { executeCallCount += 1 if let error { throw error } if failureAtCall == executeCallCount { throw NSError(domain: "MockSaveConversationUseCase", code: 1) } savedConversations.append(conversation) + expectedBases.append(expectedBase) + if let asyncExecuteHandler { + return try await asyncExecuteHandler(conversation, expectedBase, executeCallCount) + } + if let executeHandler { + return try executeHandler(conversation, expectedBase) + } + return result ?? conversation + } + + func executeImportBatch(_ conversations: [Conversation]) async throws -> [Conversation] { + if let error { throw error } + importBatches.append(conversations) + savedConversations.append(contentsOf: conversations) + return conversations } } diff --git a/openclient-llm-test/Mocks/MockSavePromptTemplateUseCase.swift b/openclient-llm-test/Mocks/MockSavePromptTemplateUseCase.swift index 47befc0e..0eed679a 100644 --- a/openclient-llm-test/Mocks/MockSavePromptTemplateUseCase.swift +++ b/openclient-llm-test/Mocks/MockSavePromptTemplateUseCase.swift @@ -18,7 +18,7 @@ final class MockSavePromptTemplateUseCase: SavePromptTemplateUseCaseProtocol, @u // MARK: - Execute - func execute(_ template: PromptTemplate) throws { + func execute(_ template: PromptTemplate) async throws { if let error { throw error } savedTemplates.append(template) } diff --git a/openclient-llm-test/Mocks/MockSyncConversationsUseCase.swift b/openclient-llm-test/Mocks/MockSyncConversationsUseCase.swift index 8a64e0ab..462595aa 100644 --- a/openclient-llm-test/Mocks/MockSyncConversationsUseCase.swift +++ b/openclient-llm-test/Mocks/MockSyncConversationsUseCase.swift @@ -14,12 +14,19 @@ final class MockSyncConversationsUseCase: SyncConversationsUseCaseProtocol, @unc var result: ConversationSyncResult = .synchronized var results: [ConversationSyncResult] = [] var executeCallCount = 0 + var cancelCallCount = 0 + var cancelHandler: (@Sendable () async -> Void)? - func execute() -> ConversationSyncResult { + func execute() async -> ConversationSyncResult { executeCallCount += 1 if !results.isEmpty { return results.removeFirst() } return result } + + func cancel() async { + cancelCallCount += 1 + await cancelHandler?() + } } diff --git a/openclient-llm-test/Mocks/MockSynchronizeAppDataUseCase.swift b/openclient-llm-test/Mocks/MockSynchronizeAppDataUseCase.swift new file mode 100644 index 00000000..768591af --- /dev/null +++ b/openclient-llm-test/Mocks/MockSynchronizeAppDataUseCase.swift @@ -0,0 +1,33 @@ +// +// MockSynchronizeAppDataUseCase.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation +@testable import openclient_llm + +// Safety: Only used within serialized @MainActor test methods. +final class MockSynchronizeAppDataUseCase: SynchronizeAppDataUseCaseProtocol, @unchecked Sendable { + var result = AppSynchronizationResult(outcomes: [ + .conversations: .synchronized, + .profile: .synchronized, + .memory: .synchronized, + .promptTemplates: .synchronized + ]) + var executeCallCount = 0 + var cancelCallCount = 0 + var cancelHandler: (@Sendable () async -> Void)? + + func execute() async -> AppSynchronizationResult { + executeCallCount += 1 + return result + } + + func cancel() async { + cancelCallCount += 1 + await cancelHandler?() + } +} diff --git a/openclient-llm-test/Mocks/MockUpdateConversationTagsUseCase.swift b/openclient-llm-test/Mocks/MockUpdateConversationTagsUseCase.swift index 7d4cde19..13c1c37b 100644 --- a/openclient-llm-test/Mocks/MockUpdateConversationTagsUseCase.swift +++ b/openclient-llm-test/Mocks/MockUpdateConversationTagsUseCase.swift @@ -19,7 +19,7 @@ final class MockUpdateConversationTagsUseCase: UpdateConversationTagsUseCaseProt // MARK: - Public - func execute(_ conversationId: UUID, tags: [ConversationTag]) throws -> [ConversationTag] { + func execute(_ conversationId: UUID, tags: [ConversationTag]) async throws -> [ConversationTag] { if let error { throw error } executedId = conversationId executedTags = tags diff --git a/openclient-llm-test/Mocks/MockUserProfileManager.swift b/openclient-llm-test/Mocks/MockUserProfileManager.swift index da8be6ca..d01fccc6 100644 --- a/openclient-llm-test/Mocks/MockUserProfileManager.swift +++ b/openclient-llm-test/Mocks/MockUserProfileManager.swift @@ -18,7 +18,11 @@ final class MockUserProfileManager: UserProfileManagerProtocol, @unchecked Senda var savedProfile: UserProfile? var localProfile: UserProfile = UserProfile() var cloudProfile: UserProfile? + var cloudProfileDeletionMarker: CloudDeletionMarker? var resolvedKeepLocal: Bool? + var cloudError: Error? + var getCloudProfileCallCount = 0 + var deleteLocalProfileError: Error? // MARK: - Public @@ -26,7 +30,8 @@ final class MockUserProfileManager: UserProfileManagerProtocol, @unchecked Senda profile } - func saveProfile(_ profile: UserProfile) { + func saveProfile(_ profile: UserProfile) async throws { + if let cloudError { throw cloudError } savedProfile = profile self.profile = profile } @@ -35,15 +40,26 @@ final class MockUserProfileManager: UserProfileManagerProtocol, @unchecked Senda localProfile } - func getCloudProfile() -> UserProfile? { - cloudProfile + func getCloudProfile() async throws -> UserProfile? { + getCloudProfileCallCount += 1 + if let cloudError { throw cloudError } + return cloudProfile } - func resolveCloudSyncConflict(keepLocal: Bool) { + func getCloudProfileState() async throws -> CloudUserProfileState { + getCloudProfileCallCount += 1 + if let cloudError { throw cloudError } + if let cloudProfileDeletionMarker { return .deleted(cloudProfileDeletionMarker) } + return cloudProfile.map(CloudUserProfileState.profile) ?? .missing + } + + func resolveCloudSyncConflict(keepLocal: Bool) async throws { + if let cloudError { throw cloudError } resolvedKeepLocal = keepLocal } - func deleteLocalProfile() { + func deleteLocalProfile() throws { + if let deleteLocalProfileError { throw deleteLocalProfileError } profile = UserProfile() localProfile = UserProfile() } diff --git a/openclient-llm-test/Mocks/TestAsyncGate.swift b/openclient-llm-test/Mocks/TestAsyncGate.swift new file mode 100644 index 00000000..f1932c18 --- /dev/null +++ b/openclient-llm-test/Mocks/TestAsyncGate.swift @@ -0,0 +1,34 @@ +// +// TestAsyncGate.swift +// openclient-llm-test +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +actor TestAsyncGate { + // MARK: - Properties + + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + // MARK: - Public + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + isOpen = true + let pendingWaiters = waiters + waiters = [] + for waiter in pendingWaiters { + waiter.resume() + } + } +} diff --git a/openclient-llm/App/AppDelegate.swift b/openclient-llm/App/AppDelegate.swift index 583186f7..cd362ffb 100644 --- a/openclient-llm/App/AppDelegate.swift +++ b/openclient-llm/App/AppDelegate.swift @@ -14,6 +14,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { // MARK: - Properties private var transactionObserverTask: Task? + private let conversationCloudObserver = ConversationCloudObserver() // MARK: - UIApplication @@ -49,10 +50,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } } } + conversationCloudObserver.start() return true } + func applicationDidBecomeActive(_ application: UIApplication) { + conversationCloudObserver.start() + } + // MARK: - Scene Configuration func application( diff --git a/openclient-llm/Shared/Core/Extensions/Foundation/Notification.Name.swift b/openclient-llm/Shared/Core/Extensions/Foundation/Notification.Name.swift index f93cd006..7d824acb 100644 --- a/openclient-llm/Shared/Core/Extensions/Foundation/Notification.Name.swift +++ b/openclient-llm/Shared/Core/Extensions/Foundation/Notification.Name.swift @@ -15,5 +15,5 @@ extension Notification.Name { /// Posted whenever a conversation is persisted (message sent, updated, etc.). /// ConversationListViewModel observes this to reload the list without recreating views. static let conversationDidUpdate = Notification.Name("openclient.conversationDidUpdate") - nonisolated static let conversationCloudDidChange = Notification.Name("openclient.conversationCloudDidChange") + nonisolated static let cloudSyncIntentDidChange = Notification.Name("openclient.cloudSyncIntentDidChange") } diff --git a/openclient-llm/Shared/Core/Managers/CloudCategoryOperationGate.swift b/openclient-llm/Shared/Core/Managers/CloudCategoryOperationGate.swift new file mode 100644 index 00000000..2b90858a --- /dev/null +++ b/openclient-llm/Shared/Core/Managers/CloudCategoryOperationGate.swift @@ -0,0 +1,84 @@ +// +// CloudCategoryOperationGate.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +actor CloudCategoryOperationGate { + // MARK: - Properties + + static let shared = CloudCategoryOperationGate() + + private var isOccupied = false + private var isFenceRequested = false + private var waiters: [CheckedContinuation] = [] + + // MARK: - Public + + func perform( + _ operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + try await acquireOperation() + do { + let value = try await operation() + release() + return value + } catch { + release() + throw error + } + } + + func fence( + _ operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + await acquireFence() + do { + let value = try await operation() + isFenceRequested = false + release() + return value + } catch { + isFenceRequested = false + release() + throw error + } + } +} + +// MARK: - Private + +private extension CloudCategoryOperationGate { + func acquireOperation() async throws { + guard !isFenceRequested else { throw CloudSyncError.operationFenced } + await acquire() + } + + func acquireFence() async { + isFenceRequested = true + await acquire() + } + + func acquire() async { + guard isOccupied else { + isOccupied = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + guard !waiters.isEmpty else { + isOccupied = false + return + } + let waiter = waiters.removeFirst() + waiter.resume() + } +} diff --git a/openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift b/openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift new file mode 100644 index 00000000..cdb64ff6 --- /dev/null +++ b/openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift @@ -0,0 +1,111 @@ +// +// CloudContainerProvider.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated protocol CloudContainerProviding: Sendable { + func isAvailable() -> Bool + func isMetadataReady(for session: CloudSyncSession) -> Bool + func containerURL() -> URL? + func identityData() -> Data? + func currentSession() -> CloudSyncSession? +} + +extension CloudContainerProviding { + func currentSession() -> CloudSyncSession? { + guard isAvailable(), + let firstIdentity = identityData(), + let containerURL = containerURL()?.standardizedFileURL, + let secondIdentity = identityData(), + firstIdentity == secondIdentity else { + return nil + } + return CloudSyncSession(containerURL: containerURL, identity: firstIdentity) + } +} + +// Safety: FileManager is thread-safe per Apple documentation. All stored properties are immutable (`let`). +nonisolated struct UbiquityCloudContainerProvider: CloudContainerProviding, @unchecked Sendable { + private let fileManager: FileManager + private let metadataReadiness: CloudMetadataReadiness + + init( + fileManager: FileManager, + metadataReadiness: CloudMetadataReadiness = .shared + ) { + self.fileManager = fileManager + self.metadataReadiness = metadataReadiness + } + + func isAvailable() -> Bool { + fileManager.ubiquityIdentityToken != nil && containerURL() != nil + } + + func isMetadataReady(for session: CloudSyncSession) -> Bool { + metadataReadiness.isReady(for: session) + } + + func containerURL() -> URL? { + fileManager.url(forUbiquityContainerIdentifier: nil) + } + + func identityData() -> Data? { + guard let token = fileManager.ubiquityIdentityToken else { return nil } + return try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false) + } + + func currentSession() -> CloudSyncSession? { + guard let firstIdentity = identityData(), + let containerURL = containerURL()?.standardizedFileURL, + let secondIdentity = identityData(), + firstIdentity == secondIdentity else { + return nil + } + return CloudSyncSession(containerURL: containerURL, identity: firstIdentity) + } +} + +nonisolated struct FixedCloudContainerProvider: CloudContainerProviding { + private let url: URL? + private let available: Bool + private let metadataReady: Bool + private let identity: Data + + init( + url: URL?, + available: Bool = true, + metadataReady: Bool = true, + identity: Data = Data("fixed-cloud-container".utf8) + ) { + self.url = url + self.available = available + self.metadataReady = metadataReady + self.identity = identity + } + + func isAvailable() -> Bool { + available && url != nil + } + + func isMetadataReady(for session: CloudSyncSession) -> Bool { + metadataReady + } + + func containerURL() -> URL? { + url + } + + func identityData() -> Data? { + available ? identity : nil + } + + func currentSession() -> CloudSyncSession? { + guard available, let url else { return nil } + return CloudSyncSession(containerURL: url.standardizedFileURL, identity: identity) + } +} diff --git a/openclient-llm/Shared/Core/Managers/CloudFileCoordinator.swift b/openclient-llm/Shared/Core/Managers/CloudFileCoordinator.swift new file mode 100644 index 00000000..094326fa --- /dev/null +++ b/openclient-llm/Shared/Core/Managers/CloudFileCoordinator.swift @@ -0,0 +1,84 @@ +// +// CloudFileCoordinator.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct CloudFileCoordinator: Sendable { + enum CoordinationError: Error { + case missingResult + } + + func perform( + _ operation: @escaping @Sendable () throws -> Value + ) async throws -> Value { + try await runInBackground(operation) + } + + func read( + at url: URL, + accessor: @escaping @Sendable (URL) throws -> Value + ) async throws -> Value { + try await runInBackground { + try read(at: url, accessor: accessor) + } + } + + func write( + at url: URL, + options: NSFileCoordinator.WritingOptions = .forReplacing, + accessor: @escaping @Sendable (URL) throws -> Value + ) async throws -> Value { + try await runInBackground { + try write(at: url, options: options, accessor: accessor) + } + } + + func read(at url: URL, accessor: (URL) throws -> Value) throws -> Value { + var coordinationError: NSError? + var result: Result? + NSFileCoordinator().coordinate( + readingItemAt: url, + options: [], + error: &coordinationError + ) { coordinatedURL in + result = Result { try accessor(coordinatedURL) } + } + if let coordinationError { throw coordinationError } + guard let result else { throw CoordinationError.missingResult } + return try result.get() + } + + func write( + at url: URL, + options: NSFileCoordinator.WritingOptions = .forReplacing, + accessor: (URL) throws -> Value + ) throws -> Value { + var coordinationError: NSError? + var result: Result? + NSFileCoordinator().coordinate( + writingItemAt: url, + options: options, + error: &coordinationError + ) { coordinatedURL in + result = Result { try accessor(coordinatedURL) } + } + if let coordinationError { throw coordinationError } + guard let result else { throw CoordinationError.missingResult } + return try result.get() + } + + private func runInBackground( + _ operation: @escaping @Sendable () throws -> Value + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(with: Result(catching: operation)) + } + } + } +} diff --git a/openclient-llm/Shared/Core/Managers/CloudMetadataReadiness.swift b/openclient-llm/Shared/Core/Managers/CloudMetadataReadiness.swift new file mode 100644 index 00000000..4c911749 --- /dev/null +++ b/openclient-llm/Shared/Core/Managers/CloudMetadataReadiness.swift @@ -0,0 +1,36 @@ +// +// CloudMetadataReadiness.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +// Safety: `readySession` is read and written only while `lock` is held. The lock is immutable. +nonisolated final class CloudMetadataReadiness: @unchecked Sendable { + static let shared = CloudMetadataReadiness() + + private let lock = NSLock() + private var readySession: CloudSyncSession? + + func isReady(for session: CloudSyncSession) -> Bool { + lock.withLock { readySession == session } + } + + func setReady(for session: CloudSyncSession) { + lock.withLock { readySession = session } + } + + func reset() { + lock.withLock { readySession = nil } + } + + func reset(for session: CloudSyncSession) { + lock.withLock { + guard readySession == session else { return } + readySession = nil + } + } +} diff --git a/openclient-llm/Shared/Core/Managers/CloudSyncError.swift b/openclient-llm/Shared/Core/Managers/CloudSyncError.swift new file mode 100644 index 00000000..a29e1553 --- /dev/null +++ b/openclient-llm/Shared/Core/Managers/CloudSyncError.swift @@ -0,0 +1,50 @@ +// +// CloudSyncError.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated enum CloudSyncError: LocalizedError, Equatable { + case containerUnavailable + case containerIdentityChanged + case cloudContentChanged + case requiredDownloadPending + case missingAttachment + case invalidAttachmentPath + case invalidConversationData + case staleConversationRevision + case staleProfileRevision + case conflictingProfileRevision + case operationFenced + + var errorDescription: String? { + switch self { + case .containerUnavailable: + String(localized: "The iCloud container is unavailable.") + case .containerIdentityChanged: + String(localized: "The iCloud account changed during synchronization.") + case .cloudContentChanged: + String(localized: "iCloud data changed during synchronization.") + case .requiredDownloadPending: + String(localized: "Required iCloud data is still downloading.") + case .missingAttachment: + String(localized: "A synchronized conversation attachment is missing.") + case .invalidAttachmentPath: + String(localized: "A synchronized conversation attachment has an invalid path.") + case .invalidConversationData: + String(localized: "A synchronized conversation contains invalid data.") + case .staleConversationRevision: + String(localized: "The conversation changed or was deleted before this save completed.") + case .staleProfileRevision: + String(localized: "The profile changed or was deleted before this save completed.") + case .conflictingProfileRevision: + String(localized: "The profile has conflicting changes with the same revision.") + case .operationFenced: + String(localized: "The cloud operation was cancelled by an app data reset.") + } + } +} diff --git a/openclient-llm/Shared/Core/Managers/CloudSyncManager+Availability.swift b/openclient-llm/Shared/Core/Managers/CloudSyncManager+Availability.swift new file mode 100644 index 00000000..748147ed --- /dev/null +++ b/openclient-llm/Shared/Core/Managers/CloudSyncManager+Availability.swift @@ -0,0 +1,21 @@ +// +// CloudSyncManager+Availability.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +extension CloudSyncManager { + func isCloudAvailable() -> Bool { + containerProvider.isAvailable() && cloudDocumentsDirectory() != nil + } + + func checkCloudAvailability() async -> Bool { + (try? await fileCoordinator.perform { + containerProvider.isAvailable() && containerProvider.containerURL() != nil + }) ?? false + } +} diff --git a/openclient-llm/Shared/Core/Managers/CloudSyncManager+ConversationSync.swift b/openclient-llm/Shared/Core/Managers/CloudSyncManager+ConversationSync.swift new file mode 100644 index 00000000..70416d8e --- /dev/null +++ b/openclient-llm/Shared/Core/Managers/CloudSyncManager+ConversationSync.swift @@ -0,0 +1,493 @@ +// +// CloudSyncManager+ConversationSync.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +extension CloudSyncManager { + func loadConversationSyncSnapshot() throws -> ConversationCloudSyncSnapshot { + let session = try makeSyncSession() + guard containerProvider.isMetadataReady(for: session) else { + throw CloudSyncError.requiredDownloadPending + } + return try fileCoordinator.read(at: session.containerURL) { containerURL in + try validate(session) + return try makeConversationSnapshot(session: session, containerURL: containerURL) + } + } + + func validateConversationSyncOutput( + _ output: ConversationCloudSyncOutput, + basedOn snapshot: ConversationCloudSyncSnapshot + ) throws { + try validate(snapshot.session) + try fileCoordinator.read(at: snapshot.session.containerURL) { containerURL in + try validate(snapshot.session) + let current = try makeConversationSnapshot( + session: snapshot.session, + containerURL: containerURL + ) + try validateUnchanged(current, comparedTo: snapshot, output: output) + try validateOutput(output) + try validate(snapshot.session) + } + } + + func applyConversationSyncOutput( + _ output: ConversationCloudSyncOutput, + basedOn snapshot: ConversationCloudSyncSnapshot + ) throws { + try validate(snapshot.session) + try fileCoordinator.write(at: snapshot.session.containerURL, options: []) { containerURL in + try validate(snapshot.session) + let current = try makeConversationSnapshot( + session: snapshot.session, + containerURL: containerURL + ) + try validateUnchanged(current, comparedTo: snapshot, output: output) + try apply(output, snapshot: snapshot, containerURL: containerURL) + } + } +} + +// MARK: - Snapshot + +private extension CloudSyncManager { + func makeSyncSession() throws -> CloudSyncSession { + guard let session = containerProvider.currentSession() else { + throw CloudSyncError.containerUnavailable + } + return session + } + + func validate(_ session: CloudSyncSession) throws { + guard let currentSession = containerProvider.currentSession() else { + throw CloudSyncError.containerUnavailable + } + guard currentSession == session else { + throw CloudSyncError.containerIdentityChanged + } + guard containerProvider.isMetadataReady(for: session) else { + throw CloudSyncError.requiredDownloadPending + } + } + + func makeConversationSnapshot( + session: CloudSyncSession, + containerURL: URL + ) throws -> ConversationCloudSyncSnapshot { + let documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true) + let manifestURL = documentsURL.appendingPathComponent("SyncManifest.json") + try requireDownloadedFile(at: manifestURL) + let manifestData = try dataIfPresent(at: manifestURL) + _ = try CloudSyncManifest.decode(manifestData) + + let conversationResult = try loadConversations(in: documentsURL) + let tombstoneResult = try loadTombstones(in: documentsURL) + let markerResult = try loadDeleteAllMarker(in: documentsURL) + let attachmentResult = try loadAttachments(in: documentsURL) + + return ConversationCloudSyncSnapshot( + session: session, + manifestData: manifestData, + conversations: conversationResult.values, + conversationData: conversationResult.data, + tombstones: tombstoneResult.values, + tombstoneData: tombstoneResult.data, + legacyTombstoneData: tombstoneResult.legacyData, + deleteAllMarker: markerResult.value, + deleteAllMarkerData: markerResult.data, + attachmentData: attachmentResult.data, + attachmentPlaceholders: attachmentResult.placeholders + ) + } + + func loadConversations(in documentsURL: URL) throws -> RecordFiles { + let directory = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + guard fileManager.fileExists(atPath: directory.path) else { return RecordFiles() } + let files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + try requireNoPlaceholders(in: files) + let decoder = SyncJSONCoding.makeDecoder() + var values: [UUID: Conversation] = [:] + var dataById: [UUID: Data] = [:] + for url in files where url.pathExtension == "json" { + try requireDownloadedFile(at: url) + let data = try Data(contentsOf: url) + let conversation = try decoder.decode(Conversation.self, from: data) + try conversation.validateContextMetadata() + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) == conversation.id else { + throw CloudSyncError.invalidConversationData + } + values[conversation.id] = conversation + dataById[conversation.id] = data + } + return RecordFiles(values: values, data: dataById) + } + + func loadTombstones(in documentsURL: URL) throws -> TombstoneFiles { + let legacyURL = documentsURL.appendingPathComponent("ConversationTombstones.json") + try requireDownloadedFile(at: legacyURL) + let legacyData = try dataIfPresent(at: legacyURL) + let decoder = SyncJSONCoding.makeDecoder() + var tombstones = try legacyData.map { try decoder.decode([ConversationTombstone].self, from: $0) } ?? [] + let directory = documentsURL.appendingPathComponent("ConversationTombstones", isDirectory: true) + guard fileManager.fileExists(atPath: directory.path) else { + return TombstoneFiles(values: tombstones, data: [:], legacyData: legacyData) + } + let files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + try requireNoPlaceholders(in: files) + var dataById: [UUID: Data] = [:] + for url in files where url.pathExtension == "json" { + try requireDownloadedFile(at: url) + let data = try Data(contentsOf: url) + let tombstone = try decoder.decode(ConversationTombstone.self, from: data) + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) == tombstone.conversationId else { + throw CloudSyncError.cloudContentChanged + } + tombstones.append(tombstone) + dataById[tombstone.conversationId] = data + } + return TombstoneFiles(values: tombstones, data: dataById, legacyData: legacyData) + } + + func loadDeleteAllMarker(in documentsURL: URL) throws -> MarkerFile { + let url = documentsURL.appendingPathComponent("ConversationDeleteAll.json") + try requireDownloadedFile(at: url) + guard let data = try dataIfPresent(at: url) else { return MarkerFile() } + let marker = try SyncJSONCoding.makeDecoder().decode(ConversationDeleteAllMarker.self, from: data) + return MarkerFile(value: marker, data: data) + } + + func loadAttachments(in documentsURL: URL) throws -> AttachmentFiles { + let resolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + let directory = try resolver.attachmentRoot() + guard fileManager.fileExists(atPath: directory.path) else { return AttachmentFiles() } + let folders = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + var dataByKey: [CloudAttachmentKey: Data] = [:] + var placeholders = Set() + for folder in folders { + let folderValues = try folder.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard folderValues.isDirectory == true, + folderValues.isSymbolicLink != true, + let conversationId = UUID(uuidString: folder.lastPathComponent) else { continue } + _ = try resolver.conversationDirectory(conversationId) + let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) + for url in files { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory != true else { continue } + guard values.isSymbolicLink != true else { throw CloudSyncError.invalidAttachmentPath } + if let fileName = placeholderFileName(for: url) { + let key = CloudAttachmentKey(conversationId: conversationId, fileName: fileName) + placeholders.insert(key) + try? fileManager.startDownloadingUbiquitousItem(at: url) + continue + } + let key = CloudAttachmentKey(conversationId: conversationId, fileName: url.lastPathComponent) + let resolvedURL = try resolver.resolve( + relativePath: ConversationAttachmentPath.relativePath(for: key) + ) + if try requiresDownload(at: resolvedURL) { + placeholders.insert(key) + } else { + dataByKey[key] = try Data(contentsOf: resolvedURL) + } + } + } + return AttachmentFiles(data: dataByKey, placeholders: placeholders) + } + + func requireNoPlaceholders(in files: [URL]) throws { + let placeholders = files.filter { placeholderFileName(for: $0) != nil } + for placeholder in placeholders { + try? fileManager.startDownloadingUbiquitousItem(at: placeholder) + } + if !placeholders.isEmpty { + throw CloudSyncError.requiredDownloadPending + } + } + + func requireDownloadedFile(at url: URL) throws { + let placeholder = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).icloud") + if fileManager.fileExists(atPath: placeholder.path) { + try? fileManager.startDownloadingUbiquitousItem(at: placeholder) + throw CloudSyncError.requiredDownloadPending + } + if fileManager.fileExists(atPath: url.path), try requiresDownload(at: url) { + throw CloudSyncError.requiredDownloadPending + } + } + + func dataIfPresent(at url: URL) throws -> Data? { + guard fileManager.fileExists(atPath: url.path) else { return nil } + return try Data(contentsOf: url) + } +} + +// MARK: - Apply + +private extension CloudSyncManager { + func validateUnchanged( + _ current: ConversationCloudSyncSnapshot, + comparedTo snapshot: ConversationCloudSyncSnapshot, + output: ConversationCloudSyncOutput + ) throws { + guard current.manifestData == snapshot.manifestData, + current.conversationData == snapshot.conversationData, + current.tombstoneData == snapshot.tombstoneData, + current.legacyTombstoneData == snapshot.legacyTombstoneData, + current.deleteAllMarkerData == snapshot.deleteAllMarkerData else { + throw CloudSyncError.cloudContentChanged + } + guard current.attachmentData == snapshot.attachmentData, + current.attachmentPlaceholders == snapshot.attachmentPlaceholders else { + throw CloudSyncError.cloudContentChanged + } + } + + func apply( + _ output: ConversationCloudSyncOutput, + snapshot: ConversationCloudSyncSnapshot, + containerURL: URL + ) throws { + try Task.checkCancellation() + try validateOutput(output) + try validate(snapshot.session) + let documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true) + try fileManager.createDirectory(at: documentsURL, withIntermediateDirectories: true) + try writeManifestIfNeeded(snapshot: snapshot, documentsURL: documentsURL) + try validate(snapshot.session) + try writeAttachments(output.attachments, session: snapshot.session, documentsURL: documentsURL) + try validate(snapshot.session) + try writeTombstones(output.tombstones, session: snapshot.session, documentsURL: documentsURL) + try validate(snapshot.session) + try writeMarker(output.deleteAllMarker, session: snapshot.session, documentsURL: documentsURL) + try validate(snapshot.session) + try writeConversations(output, session: snapshot.session, documentsURL: documentsURL) + try validate(snapshot.session) + try deleteUnreferencedAttachments(output: output, snapshot: snapshot, documentsURL: documentsURL) + try validate(snapshot.session) + try deleteRemovedConversations(output: output, snapshot: snapshot, documentsURL: documentsURL) + try validate(snapshot.session) + } + + func validateOutput(_ output: ConversationCloudSyncOutput) throws { + let conversationIds = Set(output.conversations.map(\.id)) + guard conversationIds.count == output.conversations.count, + Set(output.conversationData.keys) == conversationIds else { + throw CloudSyncError.invalidConversationData + } + let decoder = SyncJSONCoding.makeDecoder() + for conversation in output.conversations { + guard let data = output.conversationData[conversation.id], + try decoder.decode(Conversation.self, from: data) == conversation else { + throw CloudSyncError.invalidConversationData + } + try conversation.validateContextMetadata() + } + let requiredAttachmentKeys = try referencedAttachmentKeys(in: output.conversations) + guard requiredAttachmentKeys == Set(output.attachments.keys) else { + throw CloudSyncError.missingAttachment + } + } + + func writeManifestIfNeeded( + snapshot: ConversationCloudSyncSnapshot, + documentsURL: URL + ) throws { + let url = documentsURL.appendingPathComponent("SyncManifest.json") + guard snapshot.manifestData == nil else { return } + let data = try JSONEncoder().encode(CloudSyncManifest.current) + try writeAndVerify(data, to: url) + _ = try CloudSyncManifest.decode(Data(contentsOf: url)) + } + + func writeAttachments( + _ attachments: [CloudAttachmentKey: Data], + session: CloudSyncSession, + documentsURL: URL + ) throws { + let resolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + _ = try resolver.attachmentRoot() + for (key, data) in attachments { + try Task.checkCancellation() + try validate(session) + let folder = try resolver.conversationDirectory(key.conversationId) + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + let fileURL = try resolver.resolve(relativePath: ConversationAttachmentPath.relativePath(for: key)) + try writeAndVerify(data, to: fileURL) + try validate(session) + } + } + + func writeTombstones( + _ tombstones: [ConversationTombstone], + session: CloudSyncSession, + documentsURL: URL + ) throws { + let directory = documentsURL.appendingPathComponent("ConversationTombstones", isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let encoder = SyncJSONCoding.makeEncoder() + let decoder = SyncJSONCoding.makeDecoder() + for tombstone in tombstones { + try Task.checkCancellation() + try validate(session) + let url = directory.appendingPathComponent("\(tombstone.conversationId.uuidString).json") + let data = try encoder.encode(tombstone) + try writeAndVerify(data, to: url) + _ = try decoder.decode(ConversationTombstone.self, from: Data(contentsOf: url)) + try validate(session) + } + } + + func writeMarker( + _ marker: ConversationDeleteAllMarker?, + session: CloudSyncSession, + documentsURL: URL + ) throws { + guard let marker else { return } + try validate(session) + let url = documentsURL.appendingPathComponent("ConversationDeleteAll.json") + let data = try SyncJSONCoding.makeEncoder().encode(marker) + try writeAndVerify(data, to: url) + _ = try SyncJSONCoding.makeDecoder().decode( + ConversationDeleteAllMarker.self, + from: Data(contentsOf: url) + ) + try validate(session) + } + + func writeConversations( + _ output: ConversationCloudSyncOutput, + session: CloudSyncSession, + documentsURL: URL + ) throws { + let directory = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let decoder = SyncJSONCoding.makeDecoder() + for conversation in output.conversations { + try Task.checkCancellation() + try validate(session) + let url = directory.appendingPathComponent("\(conversation.id.uuidString).json") + guard let data = output.conversationData[conversation.id] else { + throw CloudSyncError.invalidConversationData + } + try writeAndVerify(data, to: url) + guard try decoder.decode(Conversation.self, from: Data(contentsOf: url)) == conversation else { + throw CloudSyncError.invalidConversationData + } + try validate(session) + } + } + + func deleteRemovedConversations( + output: ConversationCloudSyncOutput, + snapshot: ConversationCloudSyncSnapshot, + documentsURL: URL + ) throws { + let survivingIds = Set(output.conversations.map(\.id)) + let removedIds = Set(snapshot.conversations.keys).subtracting(survivingIds) + let conversationsURL = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + let resolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + for id in removedIds { + try Task.checkCancellation() + try validate(snapshot.session) + try removeDirectlyIfPresent(at: conversationsURL.appendingPathComponent("\(id.uuidString).json")) + try removeDirectlyIfPresent(at: resolver.conversationDirectory(id)) + try validate(snapshot.session) + } + } + + func deleteUnreferencedAttachments( + output: ConversationCloudSyncOutput, + snapshot: ConversationCloudSyncSnapshot, + documentsURL: URL + ) throws { + let referencedKeys = Set(output.attachments.keys) + let existingKeys = Set(snapshot.attachmentData.keys).union(snapshot.attachmentPlaceholders) + let resolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + for key in existingKeys.subtracting(referencedKeys) { + try Task.checkCancellation() + try validate(snapshot.session) + let folder = try resolver.conversationDirectory(key.conversationId) + let fileURL = try resolver.resolve(relativePath: ConversationAttachmentPath.relativePath(for: key)) + let placeholderPath = "Attachments/\(key.conversationId.uuidString)/.\(key.fileName).icloud" + let placeholderURL = try resolver.resolve(relativePath: placeholderPath) + try removeDirectlyIfPresent(at: fileURL) + try removeDirectlyIfPresent(at: placeholderURL) + if let contents = try? fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil), + contents.isEmpty { + try removeDirectlyIfPresent(at: folder) + } + try validate(snapshot.session) + } + } + + func writeAndVerify(_ data: Data, to url: URL) throws { + if try dataIfPresent(at: url) != data { + try data.write(to: url, options: .atomic) + } + guard try Data(contentsOf: url) == data else { + throw CloudSyncError.cloudContentChanged + } + } + + func removeDirectlyIfPresent(at url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + guard !fileManager.fileExists(atPath: url.path) else { + throw CloudSyncError.cloudContentChanged + } + } +} + +// MARK: - Helpers + +private extension CloudSyncManager { + nonisolated struct RecordFiles: Sendable { + var values: [UUID: Value] = [:] + var data: [UUID: Data] = [:] + } + + nonisolated struct TombstoneFiles: Sendable { + var values: [ConversationTombstone] = [] + var data: [UUID: Data] = [:] + var legacyData: Data? + } + + nonisolated struct MarkerFile: Sendable { + var value: ConversationDeleteAllMarker? + var data: Data? + } + + nonisolated struct AttachmentFiles: Sendable { + var data: [CloudAttachmentKey: Data] = [:] + var placeholders: Set = [] + } + + func referencedAttachmentKeys(in conversations: [Conversation]) throws -> Set { + var keys = Set() + for conversation in conversations { + for attachment in conversation.messages.flatMap(\.attachments) { + if let key = try ConversationAttachmentPath.key(for: attachment, conversationId: conversation.id) { + keys.insert(key) + } + } + } + return keys + } + + func placeholderFileName(for url: URL) -> String? { + let name = url.lastPathComponent + guard name.hasPrefix("."), name.hasSuffix(".icloud") else { return nil } + return String(name.dropFirst().dropLast(".icloud".count)) + } + + func isDirectory(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} diff --git a/openclient-llm/Shared/Core/Managers/CloudSyncManager+DeleteAllMarker.swift b/openclient-llm/Shared/Core/Managers/CloudSyncManager+DeleteAllMarker.swift deleted file mode 100644 index 0e7a1d6e..00000000 --- a/openclient-llm/Shared/Core/Managers/CloudSyncManager+DeleteAllMarker.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// CloudSyncManager+DeleteAllMarker.swift -// openclient-llm -// -// Created by Arturo Carretero Calvo on 12/07/2026. -// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. -// - -import Foundation - -extension CloudSyncManager { - func loadConversationDeleteAllMarkerFromCloud() throws -> ConversationDeleteAllMarker? { - guard let url = cloudDocumentsDirectory()?.appendingPathComponent("ConversationDeleteAll.json"), - fileManager.fileExists(atPath: url.path) else { return nil } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(ConversationDeleteAllMarker.self, from: Data(contentsOf: url)) - } - - func saveConversationDeleteAllMarkerToCloud(_ marker: ConversationDeleteAllMarker) throws { - guard let url = cloudDocumentsDirectory()?.appendingPathComponent("ConversationDeleteAll.json") else { return } - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - try writeIfChanged(encoder.encode(marker), to: url) - } -} diff --git a/openclient-llm/Shared/Core/Managers/CloudSyncManager+Functions.swift b/openclient-llm/Shared/Core/Managers/CloudSyncManager+Functions.swift index f6381389..f649f5b8 100644 --- a/openclient-llm/Shared/Core/Managers/CloudSyncManager+Functions.swift +++ b/openclient-llm/Shared/Core/Managers/CloudSyncManager+Functions.swift @@ -6,44 +6,13 @@ // Copyright © 2026 Arturo Carretero Calvo. All rights reserved. // -import SwiftUI +import Foundation extension CloudSyncManager { - func cloudConversationsDirectory() -> URL? { - cloudDocumentsDirectory()? - .appendingPathComponent("Conversations", isDirectory: true) - } - - func cloudAttachmentsDirectory() -> URL? { - cloudDocumentsDirectory()? - .appendingPathComponent("Attachments", isDirectory: true) - } - - func cloudConversationTombstonesFileURL() -> URL? { - cloudDocumentsDirectory()?.appendingPathComponent("ConversationTombstones.json") - } - - func cloudConversationTombstonesDirectory() -> URL? { - cloudDocumentsDirectory()?.appendingPathComponent("ConversationTombstones", isDirectory: true) - } - - func cloudProfileFileURL() -> URL? { - cloudDocumentsDirectory()? - .appendingPathComponent("UserProfile.json") - } - - func cloudTemplatesDirectory() -> URL? { - cloudDocumentsDirectory()? - .appendingPathComponent("PromptTemplates", isDirectory: true) - } - - func cloudMemoryFileURL() -> URL? { - cloudDocumentsDirectory()? - .appendingPathComponent("Memory.json") - } + static let profileMarkerId = UUID(uuidString: "00000000-0000-0000-0000-000000000000") ?? UUID() func cloudDocumentsDirectory() -> URL? { - fileManager.url(forUbiquityContainerIdentifier: nil)? + containerProvider.containerURL()? .appendingPathComponent("Documents", isDirectory: true) } @@ -52,80 +21,168 @@ extension CloudSyncManager { try fileManager.createDirectory(at: url, withIntermediateDirectories: true) } - func writeIfChanged(_ data: Data, to url: URL) throws { - if let existing = try? Data(contentsOf: url), existing == data { return } - try data.write(to: url, options: .atomic) - } - func requiresDownload(at url: URL) throws -> Bool { let values = try url.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]) - guard values.ubiquitousItemDownloadingStatus != .current else { return false } + guard let status = values.ubiquitousItemDownloadingStatus, status != .current else { return false } try? fileManager.startDownloadingUbiquitousItem(at: url) return true } - func tombstonesRequireDownload() throws -> Bool { - if let legacyURL = cloudConversationTombstonesFileURL() { - if try placeholderRequiresDownload(for: legacyURL) { return true } - if fileManager.fileExists(atPath: legacyURL.path), try requiresDownload(at: legacyURL) { - return true - } - } - guard let directory = cloudConversationTombstonesDirectory(), - fileManager.fileExists(atPath: directory.path) else { - return false - } - let files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - let placeholders = files.filter { $0.lastPathComponent.hasPrefix(".") && $0.pathExtension == "icloud" } - for placeholder in placeholders { - try? fileManager.startDownloadingUbiquitousItem(at: placeholder) + func readCategory( + _ operation: @escaping @Sendable (CloudSyncManager, URL) throws -> Value + ) async throws -> Value { + let session = try makeCategorySession() + return try await fileCoordinator.read(at: session.containerURL) { containerURL in + try validateCategorySession(session) + let documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true) + try validateCategoryManifest(in: documentsURL) + let value = try operation(self, documentsURL) + try validateCategorySession(session) + return value } - guard placeholders.isEmpty else { return true } - return try files.filter { $0.pathExtension == "json" }.contains { try requiresDownload(at: $0) } } - func placeholderRequiresDownload(for url: URL) throws -> Bool { - let placeholder = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).icloud") - guard fileManager.fileExists(atPath: placeholder.path) else { return false } - try? fileManager.startDownloadingUbiquitousItem(at: placeholder) - return true + func mutateCategory( + _ operation: @escaping @Sendable (CloudSyncManager, URL) throws -> Value + ) async throws -> Value { + let session = try makeCategorySession() + return try await fileCoordinator.write(at: session.containerURL, options: []) { containerURL in + try validateCategorySession(session) + let documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true) + try validateCategoryManifest(in: documentsURL) + try ensureDirectoryExists(at: documentsURL) + try writeCategoryManifestIfNeeded(in: documentsURL) + let value = try operation(self, documentsURL) + try validateCategorySession(session) + return value + } } - func decodeTombstones(at url: URL) throws -> [ConversationTombstone] { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode([ConversationTombstone].self, from: Data(contentsOf: url)) + func writeEncoded(_ value: Value, to url: URL) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(value) + if fileManager.fileExists(atPath: url.path), try Data(contentsOf: url) == data { return } + try ensureDirectoryExists(at: url.deletingLastPathComponent()) + try data.write(to: url, options: .atomic) + guard try Data(contentsOf: url) == data else { throw CloudSyncError.cloudContentChanged } } - func decodeTombstone(at url: URL) throws -> ConversationTombstone { + func decode(_ type: Value.Type, at url: URL) throws -> Value { + try requireCategoryFileReady(at: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(ConversationTombstone.self, from: Data(contentsOf: url)) + return try decoder.decode(type, from: Data(contentsOf: url)) } - /// Copies attachment files referenced by `conversation` from local storage to iCloud. - func syncAttachmentFiles(for conversation: Conversation, localDocuments: URL) throws { - guard let cloudAttachments = cloudAttachmentsDirectory() else { return } + func decodeIfPresent(_ type: Value.Type, at url: URL) throws -> Value? { + try requireCategoryFileReady(at: url) + guard fileManager.fileExists(atPath: url.path) else { return nil } + return try decode(type, at: url) + } - // Collect all attachments from all messages - let attachments = conversation.messages.flatMap { $0.attachments } - guard !attachments.isEmpty else { return } + func categoryContents(of directory: URL) throws -> [URL] { + let urls = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + for url in urls where categoryPlaceholderName(for: url) != nil { + try? fileManager.startDownloadingUbiquitousItem(at: url) + } + guard !urls.contains(where: { categoryPlaceholderName(for: $0) != nil }) else { + throw CloudSyncError.requiredDownloadPending + } + for url in urls where try requiresDownload(at: url) { + throw CloudSyncError.requiredDownloadPending + } + return urls + } + + func loadTemplateDeletionIds(in documentsURL: URL) throws -> Set { + let directory = documentsURL.appendingPathComponent("PromptTemplateTombstones", isDirectory: true) + guard fileManager.fileExists(atPath: directory.path) else { return [] } + return Set(try categoryContents(of: directory).compactMap { url in + guard url.pathExtension == "json" else { return nil } + return try decode(CloudDeletionMarker.self, at: url).id + }) + } + + func loadMemoryDeletionMarkers(in documentsURL: URL) throws -> [CloudDeletionMarker] { + let markers = try decodeIfPresent( + [CloudDeletionMarker].self, + at: documentsURL.appendingPathComponent("MemoryTombstones.json") + ) ?? [] + var newestById: [UUID: CloudDeletionMarker] = [:] + for marker in markers { + if let current = newestById[marker.id], current.deletedAt >= marker.deletedAt { continue } + newestById[marker.id] = marker + } + return newestById.values.sorted { $0.id.uuidString < $1.id.uuidString } + } - let cloudConvFolder = cloudAttachments - .appendingPathComponent(conversation.id.uuidString, isDirectory: true) - try ensureDirectoryExists(at: cloudConvFolder) + func loadProfileState(in documentsURL: URL) throws -> CloudUserProfileState { + let profile = try decodeIfPresent( + UserProfile.self, + at: documentsURL.appendingPathComponent("UserProfile.json") + ) + let marker = try decodeIfPresent( + CloudDeletionMarker.self, + at: documentsURL.appendingPathComponent("UserProfileDeletion.json") + ) + guard let marker else { return profile.map(CloudUserProfileState.profile) ?? .missing } + guard marker.id == Self.profileMarkerId else { throw CloudSyncError.cloudContentChanged } + guard let profile, profile.modifiedAt > marker.deletedAt else { return .deleted(marker) } + return .profile(profile) + } - for attachment in attachments where !attachment.fileRelativePath.isEmpty { - let localFile = localDocuments.appendingPathComponent(attachment.fileRelativePath) - guard fileManager.fileExists(atPath: localFile.path) else { continue } + func requireCategoryFileReady(at url: URL) throws { + let placeholder = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).icloud") + if fileManager.fileExists(atPath: placeholder.path) { + try? fileManager.startDownloadingUbiquitousItem(at: placeholder) + throw CloudSyncError.requiredDownloadPending + } + if fileManager.fileExists(atPath: url.path), try requiresDownload(at: url) { + throw CloudSyncError.requiredDownloadPending + } + } - let fileName = localFile.lastPathComponent - let cloudFile = cloudConvFolder.appendingPathComponent(fileName) + func removeCategoryItemIfPresent(at url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + guard !fileManager.fileExists(atPath: url.path) else { throw CloudSyncError.cloudContentChanged } + } - // Skip if already synced and same size (avoid unnecessary writes) - if fileManager.fileExists(atPath: cloudFile.path) { continue } + private func makeCategorySession() throws -> CloudSyncSession { + guard let session = containerProvider.currentSession() else { throw CloudSyncError.containerUnavailable } + guard containerProvider.isMetadataReady(for: session) else { + throw CloudSyncError.requiredDownloadPending + } + return session + } - try fileManager.copyItem(at: localFile, to: cloudFile) + private func validateCategorySession(_ session: CloudSyncSession) throws { + guard let current = containerProvider.currentSession() else { throw CloudSyncError.containerUnavailable } + guard current == session else { throw CloudSyncError.containerIdentityChanged } + guard containerProvider.isMetadataReady(for: session) else { + throw CloudSyncError.requiredDownloadPending } } + + private func validateCategoryManifest(in documentsURL: URL) throws { + let url = documentsURL.appendingPathComponent("SyncManifest.json") + try requireCategoryFileReady(at: url) + let data = fileManager.fileExists(atPath: url.path) ? try Data(contentsOf: url) : nil + _ = try CloudSyncManifest.decode(data) + } + + private func writeCategoryManifestIfNeeded(in documentsURL: URL) throws { + let url = documentsURL.appendingPathComponent("SyncManifest.json") + guard !fileManager.fileExists(atPath: url.path) else { return } + try writeEncoded(CloudSyncManifest.current, to: url) + } + + private func categoryPlaceholderName(for url: URL) -> String? { + let name = url.lastPathComponent + guard name.hasPrefix("."), name.hasSuffix(".icloud") else { return nil } + return String(name.dropFirst().dropLast(".icloud".count)) + } + } diff --git a/openclient-llm/Shared/Core/Managers/CloudSyncManager.swift b/openclient-llm/Shared/Core/Managers/CloudSyncManager.swift index 334abe9d..85272117 100644 --- a/openclient-llm/Shared/Core/Managers/CloudSyncManager.swift +++ b/openclient-llm/Shared/Core/Managers/CloudSyncManager.swift @@ -7,380 +7,278 @@ // import Foundation -protocol CloudSyncManagerProtocol: Sendable { +nonisolated protocol CloudSyncManagerProtocol: Sendable { func isCloudAvailable() -> Bool - func syncConversationsToCloud(_ conversations: [Conversation]) throws - func loadConversationsFromCloud() throws -> [Conversation] - func allCloudConversationIds() -> Set? - func deleteConversationFromCloud(_ conversationId: UUID) throws - func deleteAllFromCloud() throws - func hasPendingConversationDownloads() throws -> Bool - func materializeAttachmentsFromCloud(for conversation: Conversation) throws -> Bool - func loadConversationTombstonesFromCloud() throws -> [ConversationTombstone] - func saveConversationTombstonesToCloud(_ tombstones: [ConversationTombstone]) throws - func loadConversationDeleteAllMarkerFromCloud() throws -> ConversationDeleteAllMarker? - func saveConversationDeleteAllMarkerToCloud(_ marker: ConversationDeleteAllMarker) throws - func saveProfileToCloud(_ profile: UserProfile) throws - func loadProfileFromCloud() throws -> UserProfile? - func deleteProfileFromCloud() throws - func syncTemplatesToCloud(_ templates: [PromptTemplate]) throws - func loadTemplatesFromCloud() throws -> [PromptTemplate] - func allCloudTemplateIds() -> Set? - func deleteTemplateFromCloud(_ templateId: UUID) throws - func saveMemoryToCloud(_ items: [MemoryItem]) throws - func loadMemoryFromCloud() throws -> [MemoryItem]? - func deleteMemoryFromCloud() throws + func checkCloudAvailability() async -> Bool + func loadConversationSyncSnapshot() throws -> ConversationCloudSyncSnapshot + func validateConversationSyncOutput( + _ output: ConversationCloudSyncOutput, + basedOn snapshot: ConversationCloudSyncSnapshot + ) throws + func applyConversationSyncOutput( + _ output: ConversationCloudSyncOutput, + basedOn snapshot: ConversationCloudSyncSnapshot + ) throws + func saveProfileToCloud(_ profile: UserProfile) async throws + func loadProfileStateFromCloud() async throws -> CloudUserProfileState + func loadProfileFromCloud() async throws -> UserProfile? + func deleteProfileFromCloud() async throws + func syncTemplatesToCloud(_ templates: [PromptTemplate]) async throws + func loadTemplatesFromCloud() async throws -> PromptTemplateCloudSnapshot + func deleteTemplateFromCloud(_ templateId: UUID, deletedAt: Date) async throws + func saveMemoryToCloud(_ items: [MemoryItem]) async throws + func loadMemorySyncSnapshot() async throws -> MemoryCloudSyncSnapshot + func deleteMemoryItemFromCloud(_ itemId: UUID, deletedAt: Date) async throws } -struct CloudSyncManager: CloudSyncManagerProtocol, Sendable { +// Safety: FileManager is thread-safe per Apple documentation. All stored properties are immutable (`let`). +nonisolated struct CloudSyncManager: CloudSyncManagerProtocol, @unchecked Sendable { // MARK: - Properties let fileManager: FileManager + let containerProvider: CloudContainerProviding + let fileCoordinator: CloudFileCoordinator + let categoryOperationGate: CloudCategoryOperationGate // MARK: - Init - init(fileManager: FileManager = .default) { + init( + fileManager: FileManager = .default, + containerProvider: CloudContainerProviding? = nil, + fileCoordinator: CloudFileCoordinator = CloudFileCoordinator(), + categoryOperationGate: CloudCategoryOperationGate = .shared + ) { self.fileManager = fileManager + self.containerProvider = containerProvider ?? UbiquityCloudContainerProvider(fileManager: fileManager) + self.fileCoordinator = fileCoordinator + self.categoryOperationGate = categoryOperationGate } // MARK: - Public - func isCloudAvailable() -> Bool { - fileManager.ubiquityIdentityToken != nil && cloudDocumentsDirectory() != nil - } - - func syncConversationsToCloud(_ conversations: [Conversation]) throws { - guard let cloudURL = cloudConversationsDirectory() else { return } - try ensureDirectoryExists(at: cloudURL) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let localDocuments = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - for conversation in conversations { - let fileURL = cloudURL.appendingPathComponent("\(conversation.id.uuidString).json") - let data = try encoder.encode(conversation) - try writeIfChanged(data, to: fileURL) - // Sync attachment files for this conversation - try syncAttachmentFiles(for: conversation, localDocuments: localDocuments) - } - } - - func loadConversationsFromCloud() throws -> [Conversation] { - guard let cloudURL = cloudConversationsDirectory() else { return [] } - guard fileManager.fileExists(atPath: cloudURL.path) else { return [] } - // Do NOT skip hidden files: iCloud placeholders are named `.UUID.json.icloud` - // (leading dot = hidden). We need to see them to trigger their download. - let fileURLs = try fileManager.contentsOfDirectory( - at: cloudURL, - includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey], - options: [] - ) - // Trigger download of any cloud-only placeholder files so they are available - // on the next refresh cycle (download is asynchronous). - for url in fileURLs where url.lastPathComponent.hasPrefix(".") && url.pathExtension == "icloud" { - try? fileManager.startDownloadingUbiquitousItem(at: url) - } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - var conversations: [Conversation] = [] - for url in fileURLs where url.pathExtension == "json" { - do { - let data = try Data(contentsOf: url) - let conversation = try decoder.decode(Conversation.self, from: data) - conversations.append(conversation) - } catch { - LogManager.error("Failed to decode cloud conversation \(url.lastPathComponent): \(error)") - continue - } - } - return conversations.sorted { $0.updatedAt > $1.updatedAt } - } - - func allCloudConversationIds() -> Set? { - guard let cloudURL = cloudConversationsDirectory() else { return nil } - guard fileManager.fileExists(atPath: cloudURL.path) else { return nil } - guard let fileURLs = try? fileManager.contentsOfDirectory( - at: cloudURL, - includingPropertiesForKeys: nil, - options: [] - ) else { return nil } - - var ids = Set() - for url in fileURLs { - let name = url.lastPathComponent - if url.pathExtension == "json", - let uuid = UUID(uuidString: url.deletingPathExtension().lastPathComponent) { - ids.insert(uuid) - } else if name.hasPrefix(".") && name.hasSuffix(".json.icloud") { - let stripped = String(name.dropFirst()) - let uuidString = stripped.replacingOccurrences(of: ".json.icloud", with: "") - if let uuid = UUID(uuidString: uuidString) { - ids.insert(uuid) + func saveProfileToCloud(_ profile: UserProfile) async throws { + try await categoryOperationGate.perform { + try await mutateCategory { manager, documentsURL in + let profileURL = documentsURL.appendingPathComponent("UserProfile.json") + let markerURL = documentsURL.appendingPathComponent("UserProfileDeletion.json") + let existingProfile = try manager.decodeIfPresent(UserProfile.self, at: profileURL) + let marker = try manager.decodeIfPresent(CloudDeletionMarker.self, at: markerURL) + if let marker, profile.modifiedAt <= marker.deletedAt { + throw CloudSyncError.staleProfileRevision + } + if let existingProfile { + guard existingProfile.modifiedAt <= profile.modifiedAt else { + throw CloudSyncError.staleProfileRevision + } + if existingProfile.modifiedAt == profile.modifiedAt, existingProfile != profile { + throw CloudSyncError.conflictingProfileRevision + } } + try manager.writeEncoded(profile, to: profileURL) + try manager.removeCategoryItemIfPresent(at: markerURL) } } - return ids } - func deleteConversationFromCloud(_ conversationId: UUID) throws { - guard let cloudURL = cloudConversationsDirectory() else { return } - let fileURL = cloudURL.appendingPathComponent("\(conversationId.uuidString).json") - if fileManager.fileExists(atPath: fileURL.path) { - try fileManager.removeItem(at: fileURL) - } - - let placeholderURL = cloudURL.appendingPathComponent(".\(conversationId.uuidString).json.icloud") - if fileManager.fileExists(atPath: placeholderURL.path) { - try fileManager.removeItem(at: placeholderURL) - } - - // Remove cloud attachment folder for this conversation - if let cloudAttachments = cloudAttachmentsDirectory() { - let convAttachments = cloudAttachments.appendingPathComponent(conversationId.uuidString, isDirectory: true) - if fileManager.fileExists(atPath: convAttachments.path) { - try fileManager.removeItem(at: convAttachments) + func loadProfileStateFromCloud() async throws -> CloudUserProfileState { + try await categoryOperationGate.perform { + try await readCategory { manager, documentsURL in + try manager.loadProfileState(in: documentsURL) } } } - func deleteAllFromCloud() throws { - guard let cloudURL = cloudConversationsDirectory() else { return } - if fileManager.fileExists(atPath: cloudURL.path) { - try fileManager.removeItem(at: cloudURL) + func loadProfileFromCloud() async throws -> UserProfile? { + switch try await loadProfileStateFromCloud() { + case .missing, .deleted: + nil + case .profile(let profile): + profile } - - // Remove all cloud attachment files - if let cloudAttachments = cloudAttachmentsDirectory(), - fileManager.fileExists(atPath: cloudAttachments.path) { - try fileManager.removeItem(at: cloudAttachments) - } - } - - func hasPendingConversationDownloads() throws -> Bool { - guard let cloudURL = cloudConversationsDirectory(), fileManager.fileExists(atPath: cloudURL.path) else { - return false - } - let files = try fileManager.contentsOfDirectory(at: cloudURL, includingPropertiesForKeys: nil, options: []) - let placeholders = files.filter { $0.lastPathComponent.hasPrefix(".") && $0.pathExtension == "icloud" } - for placeholder in placeholders { - try? fileManager.startDownloadingUbiquitousItem(at: placeholder) - } - guard placeholders.isEmpty else { return true } - let conversationFiles = files.filter { $0.pathExtension == "json" } - let conversationsPending = try conversationFiles.contains { try requiresDownload(at: $0) } - let tombstonesPending = try tombstonesRequireDownload() - return conversationsPending || tombstonesPending } - func materializeAttachmentsFromCloud(for conversation: Conversation) throws -> Bool { - guard let cloudAttachments = cloudAttachmentsDirectory() else { return false } - let localDocuments = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - let cloudFolder = cloudAttachments.appendingPathComponent(conversation.id.uuidString, isDirectory: true) - var completed = true - - for attachment in conversation.messages.flatMap(\.attachments) where !attachment.fileRelativePath.isEmpty { - let localFile = localDocuments.appendingPathComponent(attachment.fileRelativePath) - guard !fileManager.fileExists(atPath: localFile.path) else { continue } - let cloudFile = cloudFolder.appendingPathComponent(localFile.lastPathComponent) - let placeholder = cloudFolder.appendingPathComponent(".\(localFile.lastPathComponent).icloud") - if fileManager.fileExists(atPath: placeholder.path) { - try? fileManager.startDownloadingUbiquitousItem(at: placeholder) - completed = false - continue - } - guard fileManager.fileExists(atPath: cloudFile.path) else { - completed = false - continue + func deleteProfileFromCloud() async throws { + try await categoryOperationGate.perform { + try await mutateCategory { manager, documentsURL in + let profileURL = documentsURL.appendingPathComponent("UserProfile.json") + let markerURL = documentsURL.appendingPathComponent("UserProfileDeletion.json") + let existingProfile = try manager.decodeIfPresent(UserProfile.self, at: profileURL) + let existingMarker = try manager.decodeIfPresent(CloudDeletionMarker.self, at: markerURL) + guard existingProfile != nil || existingMarker == nil else { return } + let profileRevision = existingProfile?.modifiedAt.addingTimeInterval(0.001) ?? .distantPast + let deletedAt = max(Date(), max(profileRevision, existingMarker?.deletedAt ?? .distantPast)) + let marker = CloudDeletionMarker(id: Self.profileMarkerId, deletedAt: deletedAt) + try manager.writeEncoded(marker, to: markerURL) + try manager.removeCategoryItemIfPresent(at: profileURL) } - try ensureDirectoryExists(at: localFile.deletingLastPathComponent()) - try fileManager.copyItem(at: cloudFile, to: localFile) } - return completed } - func loadConversationTombstonesFromCloud() throws -> [ConversationTombstone] { - var tombstones: [ConversationTombstone] = [] - if let legacyURL = cloudConversationTombstonesFileURL(), fileManager.fileExists(atPath: legacyURL.path) { - tombstones += try decodeTombstones(at: legacyURL) - } - if let directory = cloudConversationTombstonesDirectory(), fileManager.fileExists(atPath: directory.path) { - let files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - tombstones += try files.filter { $0.pathExtension == "json" }.map { try decodeTombstone(at: $0) } + func syncTemplatesToCloud(_ templates: [PromptTemplate]) async throws { + try await categoryOperationGate.perform { + try await mutateCategory { manager, documentsURL in + let directory = documentsURL.appendingPathComponent("PromptTemplates", isDirectory: true) + try manager.ensureDirectoryExists(at: directory) + let markers = try manager.loadTemplateDeletionMarkers(in: documentsURL) + for template in templates { + let templateURL = directory.appendingPathComponent("\(template.id.uuidString).json") + if let marker = markers[template.id], template.updatedAt <= marker.deletedAt { + if let existing = try manager.decodeIfPresent(PromptTemplate.self, at: templateURL), + existing.updatedAt > marker.deletedAt { + continue + } + try manager.removeCategoryItemIfPresent(at: templateURL) + continue + } + try manager.writeEncoded(template, to: templateURL) + let markerURL = documentsURL.appendingPathComponent( + "PromptTemplateTombstones/\(template.id.uuidString).json" + ) + try manager.removeCategoryItemIfPresent(at: markerURL) + } + } } - return tombstones } - func saveConversationTombstonesToCloud(_ tombstones: [ConversationTombstone]) throws { - guard let directory = cloudConversationTombstonesDirectory() else { return } - try ensureDirectoryExists(at: directory) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - for tombstone in tombstones { - let fileURL = directory.appendingPathComponent("\(tombstone.conversationId.uuidString).json") - try writeIfChanged(encoder.encode(tombstone), to: fileURL) + func loadTemplatesFromCloud() async throws -> PromptTemplateCloudSnapshot { + try await categoryOperationGate.perform { + try await readCategory { manager, documentsURL in + let deletionMarkers = try manager.loadTemplateDeletionMarkers(in: documentsURL) + let directory = documentsURL.appendingPathComponent("PromptTemplates", isDirectory: true) + guard manager.fileManager.fileExists(atPath: directory.path) else { + return PromptTemplateCloudSnapshot( + templates: [], + templateData: [:], + deletionMarkers: deletionMarkers + ) + } + let urls = try manager.categoryContents(of: directory) + var templates: [PromptTemplate] = [] + var templateData: [UUID: Data] = [:] + for url in urls { + guard url.pathExtension == "json", + let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { continue } + try manager.requireCategoryFileReady(at: url) + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let template = try decoder.decode(PromptTemplate.self, from: data) + guard template.id == id else { continue } + if let marker = deletionMarkers[id], template.updatedAt <= marker.deletedAt { continue } + templates.append(template) + templateData[id] = data + } + return PromptTemplateCloudSnapshot( + templates: templates, + templateData: templateData, + deletionMarkers: deletionMarkers + ) + } } } - func saveProfileToCloud(_ profile: UserProfile) throws { - guard let fileURL = cloudProfileFileURL() else { return } - - let directory = fileURL.deletingLastPathComponent() - try ensureDirectoryExists(at: directory) - - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let data = try encoder.encode(profile) - try data.write(to: fileURL, options: .atomic) - } - - func loadProfileFromCloud() throws -> UserProfile? { - guard let fileURL = cloudProfileFileURL() else { return nil } - - // Trigger download of iCloud placeholder if needed. - let directory = fileURL.deletingLastPathComponent() - if fileManager.fileExists(atPath: directory.path) { - let files = try? fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey], - options: [] - ) - for url in files ?? [] where url.lastPathComponent.hasPrefix(".") && url.pathExtension == "icloud" { - try? fileManager.startDownloadingUbiquitousItem(at: url) + func deleteTemplateFromCloud(_ templateId: UUID, deletedAt: Date) async throws { + try await categoryOperationGate.perform { + try await mutateCategory { manager, documentsURL in + let markerDirectory = documentsURL.appendingPathComponent("PromptTemplateTombstones", isDirectory: true) + try manager.ensureDirectoryExists(at: markerDirectory) + let markerURL = markerDirectory.appendingPathComponent("\(templateId.uuidString).json") + let existingMarker = try manager.decodeIfPresent(CloudDeletionMarker.self, at: markerURL) + let marker = existingMarker.map { existing in + existing.deletedAt >= deletedAt + ? existing + : CloudDeletionMarker(id: templateId, deletedAt: deletedAt) + } ?? CloudDeletionMarker(id: templateId, deletedAt: deletedAt) + try manager.writeEncoded(marker, to: markerURL) + let payload = documentsURL.appendingPathComponent("PromptTemplates/\(templateId.uuidString).json") + if let template = try manager.decodeIfPresent(PromptTemplate.self, at: payload), + template.updatedAt > marker.deletedAt { + return + } + try manager.removeCategoryItemIfPresent(at: payload) } } - - guard fileManager.fileExists(atPath: fileURL.path) else { return nil } - - let data = try Data(contentsOf: fileURL) - return try JSONDecoder().decode(UserProfile.self, from: data) - } - - func deleteProfileFromCloud() throws { - guard let fileURL = cloudProfileFileURL() else { return } - guard fileManager.fileExists(atPath: fileURL.path) else { return } - try fileManager.removeItem(at: fileURL) } - func syncTemplatesToCloud(_ templates: [PromptTemplate]) throws { - guard let cloudURL = cloudTemplatesDirectory() else { return } - - try ensureDirectoryExists(at: cloudURL) - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = .prettyPrinted - - for template in templates { - let fileURL = cloudURL.appendingPathComponent("\(template.id.uuidString).json") - let data = try encoder.encode(template) - try data.write(to: fileURL, options: .atomic) + func saveMemoryToCloud(_ items: [MemoryItem]) async throws { + try await mutateCategory { manager, documentsURL in + let markers = try manager.loadMemoryDeletionMarkers(in: documentsURL) + let markerById = Dictionary(uniqueKeysWithValues: markers.map { ($0.id, $0) }) + let survivors = items.filter { item in + guard let marker = markerById[item.id] else { return true } + return item.updatedAt > marker.deletedAt + }.sorted { $0.id.uuidString < $1.id.uuidString } + try manager.writeMemoryValue(survivors, to: documentsURL.appendingPathComponent("Memory.json")) } } - func loadTemplatesFromCloud() throws -> [PromptTemplate] { - guard let cloudURL = cloudTemplatesDirectory() else { return [] } - guard fileManager.fileExists(atPath: cloudURL.path) else { return [] } - - let fileURLs = try fileManager.contentsOfDirectory( - at: cloudURL, - includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey], - options: [] - ) - - for url in fileURLs where url.lastPathComponent.hasPrefix(".") && url.pathExtension == "icloud" { - try? fileManager.startDownloadingUbiquitousItem(at: url) - } - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - var templates: [PromptTemplate] = [] - for url in fileURLs where url.pathExtension == "json" { - do { - let data = try Data(contentsOf: url) - let template = try decoder.decode(PromptTemplate.self, from: data) - templates.append(template) - } catch { - continue + func loadMemorySyncSnapshot() async throws -> MemoryCloudSyncSnapshot { + try await readCategory { manager, documentsURL in + let markers = try manager.loadMemoryDeletionMarkers(in: documentsURL) + let url = documentsURL.appendingPathComponent("Memory.json") + let items = try manager.decodeIfPresent([MemoryItem].self, at: url) + let markerById = Dictionary(uniqueKeysWithValues: markers.map { ($0.id, $0) }) + let eligibleItems = items?.filter { item in + guard let marker = markerById[item.id] else { return true } + return item.updatedAt > marker.deletedAt } + return MemoryCloudSyncSnapshot(items: eligibleItems, deletionMarkers: markers) } - return templates } - func allCloudTemplateIds() -> Set? { - guard let cloudURL = cloudTemplatesDirectory() else { return nil } - guard fileManager.fileExists(atPath: cloudURL.path) else { return nil } - - guard let fileURLs = try? fileManager.contentsOfDirectory( - at: cloudURL, - includingPropertiesForKeys: nil, - options: [] - ) else { return nil } - - var ids = Set() - for url in fileURLs { - let name = url.lastPathComponent - if url.pathExtension == "json", - let uuid = UUID(uuidString: url.deletingPathExtension().lastPathComponent) { - ids.insert(uuid) - } else if name.hasPrefix(".") && name.hasSuffix(".json.icloud") { - let stripped = String(name.dropFirst()) - let uuidString = stripped.replacingOccurrences(of: ".json.icloud", with: "") - if let uuid = UUID(uuidString: uuidString) { - ids.insert(uuid) - } + func deleteMemoryItemFromCloud(_ itemId: UUID, deletedAt: Date) async throws { + try await mutateCategory { manager, documentsURL in + var markers = try manager.loadMemoryDeletionMarkers(in: documentsURL) + let marker = CloudDeletionMarker(id: itemId, deletedAt: deletedAt) + if let index = markers.firstIndex(where: { $0.id == itemId }) { + if markers[index].deletedAt < deletedAt { markers[index] = marker } + } else { + markers.append(marker) + } + markers.sort { $0.id.uuidString < $1.id.uuidString } + let markersURL = documentsURL.appendingPathComponent("MemoryTombstones.json") + try manager.writeMemoryValue(markers, to: markersURL) + let memoryURL = documentsURL.appendingPathComponent("Memory.json") + if var items = try manager.decodeIfPresent([MemoryItem].self, at: memoryURL) { + let effectiveDeletionDate = markers.first { $0.id == itemId }?.deletedAt ?? deletedAt + items.removeAll { $0.id == itemId && $0.updatedAt <= effectiveDeletionDate } + try manager.writeMemoryValue(items, to: memoryURL) } } - return ids - } - - func deleteTemplateFromCloud(_ templateId: UUID) throws { - guard let cloudURL = cloudTemplatesDirectory() else { return } - let fileURL = cloudURL.appendingPathComponent("\(templateId.uuidString).json") - guard fileManager.fileExists(atPath: fileURL.path) else { return } - try fileManager.removeItem(at: fileURL) } - func saveMemoryToCloud(_ items: [MemoryItem]) throws { - guard let fileURL = cloudMemoryFileURL() else { return } - - let directory = fileURL.deletingLastPathComponent() - try ensureDirectoryExists(at: directory) +} - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = .prettyPrinted - let data = try encoder.encode(items) - try data.write(to: fileURL, options: .atomic) +// MARK: - Memory + +private extension CloudSyncManager { + func writeMemoryValue(_ value: Value, to url: URL) throws { + let encoder = SyncJSONCoding.makeEncoder() + let data = try encoder.encode(value) + if fileManager.fileExists(atPath: url.path), try Data(contentsOf: url) == data { return } + try ensureDirectoryExists(at: url.deletingLastPathComponent()) + try data.write(to: url, options: .atomic) + let writtenData = try Data(contentsOf: url) + guard writtenData == data else { throw CloudSyncError.cloudContentChanged } + let decoded = try SyncJSONCoding.makeDecoder().decode(Value.self, from: writtenData) + guard try encoder.encode(decoded) == data else { throw CloudSyncError.cloudContentChanged } } +} - func loadMemoryFromCloud() throws -> [MemoryItem]? { - guard let fileURL = cloudMemoryFileURL() else { return nil } +// MARK: - Prompt Templates - let directory = fileURL.deletingLastPathComponent() - if fileManager.fileExists(atPath: directory.path) { - let files = try? fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey], - options: [] - ) - for url in files ?? [] where url.lastPathComponent.hasPrefix(".") && url.pathExtension == "icloud" { - try? fileManager.startDownloadingUbiquitousItem(at: url) +private extension CloudSyncManager { + func loadTemplateDeletionMarkers(in documentsURL: URL) throws -> [UUID: CloudDeletionMarker] { + let directory = documentsURL.appendingPathComponent("PromptTemplateTombstones", isDirectory: true) + guard fileManager.fileExists(atPath: directory.path) else { return [:] } + let markers = try categoryContents(of: directory).compactMap { url -> CloudDeletionMarker? in + guard url.pathExtension == "json" else { return nil } + return try decode(CloudDeletionMarker.self, at: url) + } + return markers.reduce(into: [:]) { result, marker in + if result[marker.id]?.deletedAt ?? .distantPast < marker.deletedAt { + result[marker.id] = marker } } - - guard fileManager.fileExists(atPath: fileURL.path) else { return nil } - - let data = try Data(contentsOf: fileURL) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode([MemoryItem].self, from: data) - } - - func deleteMemoryFromCloud() throws { - guard let fileURL = cloudMemoryFileURL() else { return } - guard fileManager.fileExists(atPath: fileURL.path) else { return } - try fileManager.removeItem(at: fileURL) } } diff --git a/openclient-llm/Shared/Core/Managers/ConversationCloudObserver.swift b/openclient-llm/Shared/Core/Managers/ConversationCloudObserver.swift index 64d6528c..46947b1b 100644 --- a/openclient-llm/Shared/Core/Managers/ConversationCloudObserver.swift +++ b/openclient-llm/Shared/Core/Managers/ConversationCloudObserver.swift @@ -8,102 +8,377 @@ import Foundation -protocol ConversationCloudObserving: AnyObject, Sendable { - func start() -} - -// Safety: NSMetadataQuery callbacks are delivered to .main and its mutable state is -// accessed only there. Dependencies are Sendable file-based managers. -final class ConversationCloudObserver: ConversationCloudObserving, @unchecked Sendable { +@MainActor +final class ConversationCloudObserver { // MARK: - Properties + static let synchronizedPathComponents = [ + "SyncManifest.json", + "UserProfile.json", + "UserProfileDeletion.json", + "Memory.json", + "MemoryTombstones.json", + "/PromptTemplates", + "/PromptTemplateTombstones", + "/Conversations", + "/ConversationTombstones", + "ConversationTombstones.json", + "ConversationDeleteAll.json", + "/Attachments" + ] + private let settingsManager: SettingsManagerProtocol private let cloudSyncManager: CloudSyncManagerProtocol - private nonisolated(unsafe) var metadataQuery: NSMetadataQuery? - private nonisolated(unsafe) var queryObservers: [NSObjectProtocol] = [] - private var contentChangeDates: [String: Date] = [:] + private let syncConversationsUseCase: SyncConversationsUseCaseProtocol + private let notificationCenter: NotificationCenter + private let metadataReadiness: CloudMetadataReadiness + private let containerProvider: CloudContainerProviding + private let fileManager: FileManager + private let metadataDebounceDuration: Duration + private var metadataQuery: NSMetadataQuery? + private var metadataSession: CloudSyncSession? + private var queryObservers: [NSObjectProtocol] = [] + private var lifecycleObservers: [NSObjectProtocol] = [] + private var contentFingerprints: [String: ContentFingerprint] = [:] private var hasEstablishedBaseline = false + private var synchronizationTask: Task? + private var cancellationTask: Task? + private var metadataDebounceTask: Task? + private var startTask: Task? + private var needsSynchronization = false + private var synchronizationGeneration = 0 + private var startGeneration = 0 // MARK: - Init init( settingsManager: SettingsManagerProtocol = SettingsManager(), - cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager() + cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager(), + syncConversationsUseCase: SyncConversationsUseCaseProtocol = SyncConversationsUseCase(), + notificationCenter: NotificationCenter = .default, + metadataReadiness: CloudMetadataReadiness = .shared, + containerProvider: CloudContainerProviding? = nil, + fileManager: FileManager = .default, + metadataDebounceDuration: Duration = .milliseconds(500) ) { self.settingsManager = settingsManager self.cloudSyncManager = cloudSyncManager + self.syncConversationsUseCase = syncConversationsUseCase + self.notificationCenter = notificationCenter + self.metadataReadiness = metadataReadiness + self.containerProvider = containerProvider ?? UbiquityCloudContainerProvider( + fileManager: fileManager, + metadataReadiness: metadataReadiness + ) + self.fileManager = fileManager + self.metadataDebounceDuration = metadataDebounceDuration + observeLifecycleChanges() } // MARK: - Public func start() { - guard metadataQuery == nil, cloudSyncManager.isCloudAvailable() else { return } + guard settingsManager.getIsCloudSyncEnabled() else { + stop() + return + } + startGeneration += 1 + let generation = startGeneration + startTask?.cancel() + let resolution = resolveCurrentSession() + startTask = Task { [weak self] in + let session = await resolution.value + guard !Task.isCancelled, let self else { return } + completeStart(session: session, generation: generation) + } + } + + func stop() { + startGeneration += 1 + startTask?.cancel() + startTask = nil + metadataReadiness.reset() + metadataQuery?.stop() + metadataQuery = nil + metadataSession = nil + hasEstablishedBaseline = false + contentFingerprints = [:] + for observer in queryObservers { + notificationCenter.removeObserver(observer) + } + queryObservers = [] + synchronizationGeneration += 1 + synchronizationTask?.cancel() + synchronizationTask = nil + metadataDebounceTask?.cancel() + metadataDebounceTask = nil + needsSynchronization = false + let syncUseCase = syncConversationsUseCase + cancellationTask = Task { await syncUseCase.cancel() } + } + + func handleMetadataChange() { + guard hasEstablishedBaseline, + let metadataSession, + metadataReadiness.isReady(for: metadataSession) else { return } + metadataDebounceTask?.cancel() + metadataDebounceTask = Task { [weak self, metadataDebounceDuration] in + try? await Task.sleep(for: metadataDebounceDuration) + guard !Task.isCancelled, let self else { return } + startSynchronization() + } + } + + private func completeStart(session: CloudSyncSession?, generation: Int) { + guard generation == startGeneration else { return } + startTask = nil + guard let session else { + stop() + return + } + if metadataQuery != nil { + guard metadataSession != session else { return } + stop() + start() + return + } + metadataReadiness.reset() + metadataSession = session + let query = makeMetadataQuery() + let queryReference = MetadataQueryReference(query) + queryObservers = [ + makeGatheringObserver(for: query, reference: queryReference), + makeUpdateObserver(for: query, reference: queryReference) + ] + metadataQuery = query + if !query.start() { + stop() + } + } + + private func startSynchronization() { + guard settingsManager.getIsCloudSyncEnabled(), + hasEstablishedBaseline, + let metadataSession, + metadataReadiness.isReady(for: metadataSession) else { return } + guard synchronizationTask == nil else { + needsSynchronization = true + return + } + + let generation = synchronizationGeneration + let pendingCancellation = cancellationTask + synchronizationTask = Task { [weak self] in + guard let self else { return } + await pendingCancellation?.value + guard synchronizationGeneration == generation else { return } + repeat { + needsSynchronization = false + _ = await syncConversationsUseCase.execute() + guard synchronizationGeneration == generation else { return } + guard !Task.isCancelled, + settingsManager.getIsCloudSyncEnabled() else { break } + notificationCenter.post(name: .conversationDidUpdate, object: nil) + } while needsSynchronization + if synchronizationGeneration == generation { + synchronizationTask = nil + } + } + } + + static func requiresDownload(forDownloadingStatus status: String?) -> Bool { + guard let status else { return false } + return status != NSMetadataUbiquitousItemDownloadingStatusCurrent + } + + private func metadataState(in query: NSMetadataQuery) -> MetadataState { + query.disableUpdates() + defer { query.enableUpdates() } + + var latestFingerprints: [String: ContentFingerprint] = [:] + var hasPendingDownloads = false + for case let item as NSMetadataItem in query.results { + guard let path = item.value(forAttribute: NSMetadataItemPathKey) as? String else { continue } + let changeDate = item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date ?? .distantPast + let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String + latestFingerprints[path] = ContentFingerprint(changeDate: changeDate, downloadingStatus: status) + guard Self.requiresDownload(forDownloadingStatus: status) else { continue } + hasPendingDownloads = true + if let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL { + try? fileManager.startDownloadingUbiquitousItem(at: url) + } + } + + let hasContentChanges = latestFingerprints != contentFingerprints + contentFingerprints = latestFingerprints + return MetadataState( + hasContentChanges: hasContentChanges, + hasPendingDownloads: hasPendingDownloads + ) + } + + isolated deinit { + metadataReadiness.reset() + metadataQuery?.stop() + synchronizationTask?.cancel() + cancellationTask?.cancel() + metadataDebounceTask?.cancel() + startTask?.cancel() + for observer in queryObservers { + notificationCenter.removeObserver(observer) + } + for observer in lifecycleObservers { + notificationCenter.removeObserver(observer) + } + } +} + +private extension ConversationCloudObserver { + // Safety: The query is created on MainActor and accessed only by callbacks delivered on the main queue. + final class MetadataQueryReference: @unchecked Sendable { + let query: NSMetadataQuery + + init(_ query: NSMetadataQuery) { + self.query = query + } + } + + struct ContentFingerprint: Equatable { + let changeDate: Date + let downloadingStatus: String? + } + + struct MetadataState { + let hasContentChanges: Bool + let hasPendingDownloads: Bool + } + + func resolveCurrentSession() -> Task { + let cloudSyncManager = cloudSyncManager + let containerProvider = containerProvider + return Task.detached(priority: .utility) { + guard cloudSyncManager.isCloudAvailable() else { return nil } + return containerProvider.currentSession() + } + } + + func makeMetadataQuery() -> NSMetadataQuery { let query = NSMetadataQuery() query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] - query.predicate = NSPredicate( - format: "%K CONTAINS %@ OR %K CONTAINS %@ OR %K CONTAINS %@ OR %K CONTAINS %@", - NSMetadataItemPathKey, - "/Conversations/", - NSMetadataItemPathKey, - "/ConversationTombstones/", - NSMetadataItemPathKey, - "/Attachments/", - NSMetadataItemPathKey, - "ConversationDeleteAll.json" + query.predicate = NSCompoundPredicate( + orPredicateWithSubpredicates: Self.synchronizedPathComponents.map { + NSPredicate(format: "%K CONTAINS %@", NSMetadataItemPathKey, $0) + } ) - let gatheringObserver = NotificationCenter.default.addObserver( + return query + } + + func establishBaseline(for query: NSMetadataQuery, session: CloudSyncSession) { + let resolution = resolveCurrentSession() + Task { [weak self] in + let currentSession = await resolution.value + guard let self, + self.metadataQuery === query, + self.settingsManager.getIsCloudSyncEnabled() else { return } + guard currentSession == session else { + self.stop() + self.start() + return + } + let state = self.metadataState(in: query) + guard !state.hasPendingDownloads else { + self.metadataReadiness.reset(for: session) + return + } + self.hasEstablishedBaseline = true + self.metadataReadiness.setReady(for: session) + self.handleMetadataChange() + } + } + + func makeGatheringObserver( + for query: NSMetadataQuery, + reference: MetadataQueryReference + ) -> NSObjectProtocol { + notificationCenter.addObserver( forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main - ) { [weak self] _ in + ) { [weak self, reference] _ in MainActor.assumeIsolated { + let query = reference.query guard let self, - let query = self.metadataQuery, - self.settingsManager.getIsCloudSyncEnabled() else { return } - _ = self.hasContentChanges(in: query) - self.hasEstablishedBaseline = true - NotificationCenter.default.post(name: .conversationCloudDidChange, object: nil) + self.metadataQuery === query, + let session = self.metadataSession else { return } + let state = self.metadataState(in: query) + guard !state.hasPendingDownloads else { + self.metadataReadiness.reset(for: session) + return + } + self.establishBaseline(for: query, session: session) } } - let updateObserver = NotificationCenter.default.addObserver( + } + + func makeUpdateObserver( + for query: NSMetadataQuery, + reference: MetadataQueryReference + ) -> NSObjectProtocol { + notificationCenter.addObserver( forName: .NSMetadataQueryDidUpdate, object: query, queue: .main - ) { [weak self] _ in + ) { [weak self, reference] _ in MainActor.assumeIsolated { + let query = reference.query guard let self, - let query = self.metadataQuery, - self.settingsManager.getIsCloudSyncEnabled() else { return } - guard self.hasEstablishedBaseline else { return } - guard self.hasContentChanges(in: query) else { return } - NotificationCenter.default.post(name: .conversationCloudDidChange, object: nil) + self.metadataQuery === query, + self.settingsManager.getIsCloudSyncEnabled(), + let session = self.metadataSession else { return } + let state = self.metadataState(in: query) + guard !state.hasPendingDownloads else { + self.hasEstablishedBaseline = false + self.metadataReadiness.reset(for: session) + return + } + guard self.hasEstablishedBaseline else { + self.establishBaseline(for: query, session: session) + return + } + guard state.hasContentChanges else { return } + self.handleMetadataChange() } } - queryObservers = [gatheringObserver, updateObserver] - metadataQuery = query - query.start() } - private func hasContentChanges(in query: NSMetadataQuery) -> Bool { - query.disableUpdates() - defer { query.enableUpdates() } - - var latestContentChangeDates: [String: Date] = [:] - for case let item as NSMetadataItem in query.results { - guard let path = item.value(forAttribute: NSMetadataItemPathKey) as? String else { continue } - let changeDate = item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date ?? .distantPast - latestContentChangeDates[path] = changeDate + func observeLifecycleChanges() { + let intentObserver = notificationCenter.addObserver( + forName: .cloudSyncIntentDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + if self.settingsManager.getIsCloudSyncEnabled() { + self.start() + } else { + self.stop() + } + } } - - guard latestContentChangeDates != contentChangeDates else { return false } - contentChangeDates = latestContentChangeDates - return true - } - - deinit { - metadataQuery?.stop() - for observer in queryObservers { - NotificationCenter.default.removeObserver(observer) + let identityObserver = notificationCenter.addObserver( + forName: NSNotification.Name.NSUbiquityIdentityDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.metadataReadiness.reset() + self.stop() + self.start() + } } + lifecycleObservers = [intentObserver, identityObserver] } + } diff --git a/openclient-llm/Shared/Core/Managers/LogManager.swift b/openclient-llm/Shared/Core/Managers/LogManager.swift index 4b699341..9f252e27 100644 --- a/openclient-llm/Shared/Core/Managers/LogManager.swift +++ b/openclient-llm/Shared/Core/Managers/LogManager.swift @@ -8,10 +8,10 @@ import Foundation -enum LogManager { +nonisolated enum LogManager { // MARK: - Properties - enum Level: String { + enum Level: String, Sendable { case debug = "🔍 DEBUG" case info = "ℹ️ INFO" case warning = "⚠️ WARNING" @@ -80,7 +80,7 @@ enum LogManager { // MARK: - Private private extension LogManager { - static func log( + nonisolated static func log( level: Level, message: String, file: String, @@ -89,14 +89,19 @@ private extension LogManager { ) { #if DEBUG let fileName = URL(fileURLWithPath: file).deletingPathExtension().lastPathComponent - let timestamp = Self.dateFormatter.string(from: Date()) + let timestamp = makeTimestamp() print("[\(timestamp)] \(level.rawValue) [\(fileName):\(line)] \(function) → \(message)") #endif } - static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss.SSS" - return formatter - }() + nonisolated static func makeTimestamp() -> String { + let components = Calendar.current.dateComponents([.hour, .minute, .second, .nanosecond], from: Date()) + return String( + format: "%02d:%02d:%02d.%03d", + components.hour ?? 0, + components.minute ?? 0, + components.second ?? 0, + (components.nanosecond ?? 0) / 1_000_000 + ) + } } diff --git a/openclient-llm/Shared/Core/Managers/MemoryManager.swift b/openclient-llm/Shared/Core/Managers/MemoryManager.swift index d3a36ef5..327b9e8e 100644 --- a/openclient-llm/Shared/Core/Managers/MemoryManager.swift +++ b/openclient-llm/Shared/Core/Managers/MemoryManager.swift @@ -8,23 +8,26 @@ import Foundation +nonisolated struct MemoryCloudSyncSnapshot: Sendable { + let items: [MemoryItem]? + let deletionMarkers: [CloudDeletionMarker] +} + protocol MemoryManagerProtocol: Sendable { func getItems() -> [MemoryItem] - func add(_ item: MemoryItem) - func update(_ item: MemoryItem) - func delete(id: UUID) - func deleteAll() + func synchronize() async throws + func add(_ item: MemoryItem) async throws + func update(_ item: MemoryItem) async throws + func delete(id: UUID) async throws + func deleteAll() async throws } /// Manages the persistent memory list with optional iCloud sync. /// -/// When iCloud sync is enabled the cloud `Memory.json` is the single source of truth. -/// Local storage is a JSON file in DocumentDirectory and is used when sync is disabled. +/// Local and cloud records are reconciled by ID and revision when iCloud sync is enabled. /// -/// Safety: FileManager operations are thread-safe for different paths. CloudSyncManager -/// operations are file-based and called synchronously on callers' threads. -/// The class is @unchecked Sendable because `metadataQuery` and `queryObserver` -/// are only touched on the main thread during init/deinit. +/// Safety: FileManager operations are thread-safe for different paths. Cloud operations are async. +/// `metadataQuery` and `queryObserver` are touched only on the main actor. final class MemoryManager: MemoryManagerProtocol, @unchecked Sendable { // MARK: - Properties @@ -33,6 +36,8 @@ final class MemoryManager: MemoryManagerProtocol, @unchecked Sendable { } private static let fileName = "Memory.json" + private static let deletionFileName = "MemoryTombstones.json" + private static let recoveryFileName = "MemoryRecovery.json" /// Notification posted when iCloud pushes an external memory change. nonisolated static let memoryDidChangeExternallyNotification = Notification.Name( @@ -41,25 +46,32 @@ final class MemoryManager: MemoryManagerProtocol, @unchecked Sendable { private let settingsManager: SettingsManagerProtocol private let cloudSyncManager: CloudSyncManagerProtocol + private let categoryOperationGate: CloudCategoryOperationGate + private let userDefaults: UserDefaults + private let localFileURL: URL? + private let localDeletionFileURL: URL? + private let localRecoveryFileURL: URL? private nonisolated(unsafe) var metadataQuery: NSMetadataQuery? private nonisolated(unsafe) var queryObserver: NSObjectProtocol? - private var localFileURL: URL? { - FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask) - .first? - .appendingPathComponent(Self.fileName) - } - // MARK: - Init init( settingsManager: SettingsManagerProtocol = SettingsManager(), - cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager() + cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager(), + documentsURL: URL? = nil, + userDefaults: UserDefaults = .standard, + categoryOperationGate: CloudCategoryOperationGate = .shared ) { self.settingsManager = settingsManager self.cloudSyncManager = cloudSyncManager - migrateFromUserDefaultsIfNeeded() + self.categoryOperationGate = categoryOperationGate + self.userDefaults = userDefaults + let resolvedDocumentsURL = documentsURL + ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + self.localFileURL = resolvedDocumentsURL?.appendingPathComponent(Self.fileName) + self.localDeletionFileURL = resolvedDocumentsURL?.appendingPathComponent(Self.deletionFileName) + self.localRecoveryFileURL = resolvedDocumentsURL?.appendingPathComponent(Self.recoveryFileName) startMonitoringCloudFile() } @@ -73,38 +85,40 @@ final class MemoryManager: MemoryManagerProtocol, @unchecked Sendable { // MARK: - Public func getItems() -> [MemoryItem] { - if settingsManager.getIsCloudSyncEnabled() { - if let cloudItems = try? cloudSyncManager.loadMemoryFromCloud() { - saveToLocal(cloudItems) - return cloudItems - } + let items = (try? loadItemsForDisplay()) ?? [] + let markers = (try? loadDeletionMarkers()) ?? [] + return applying(markers, to: items) + } + + func synchronize() async throws { + guard settingsManager.getIsCloudSyncEnabled() else { return } + try await performSerializedIfNeeded { [self] in + try migrateFromUserDefaultsIfNeeded() + try await reconcile(localItems: loadFromLocal()) } - return loadFromLocal() } - func add(_ item: MemoryItem) { - var items = getItems() - items.append(item) - persist(items) + func add(_ item: MemoryItem) async throws { + try await performSerializedIfNeeded { [self] in + try await addItem(item) + } } - func update(_ item: MemoryItem) { - var items = getItems() - guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } - items[index] = item - persist(items) + func update(_ item: MemoryItem) async throws { + try await performSerializedIfNeeded { [self] in + try await updateItem(item) + } } - func delete(id: UUID) { - var items = getItems() - items.removeAll { $0.id == id } - persist(items) + func delete(id: UUID) async throws { + try await performSerializedIfNeeded { [self] in + try await deleteItem(id: id) + } } - func deleteAll() { - persist([]) - if settingsManager.getIsCloudSyncEnabled() { - try? cloudSyncManager.deleteMemoryFromCloud() + func deleteAll() async throws { + try await performSerializedIfNeeded { [self] in + try await deleteAllItems() } } } @@ -112,55 +126,272 @@ final class MemoryManager: MemoryManagerProtocol, @unchecked Sendable { // MARK: - Private private extension MemoryManager { - func loadFromLocal() -> [MemoryItem] { - guard let url = localFileURL, - let data = try? Data(contentsOf: url), - let items = try? makeDecoder().decode([MemoryItem].self, from: data) else { - return [] + struct MergeResult { + let items: [MemoryItem] + let losingItems: [MemoryItem] + } + + func addItem(_ item: MemoryItem) async throws { + try migrateFromUserDefaultsIfNeeded() + var newItem = item + let markers = try loadDeletionMarkers() + if let marker = markers.first(where: { $0.id == item.id }), newItem.updatedAt <= marker.deletedAt { + newItem.updatedAt = nextRevision(after: marker.deletedAt) } - return items + var items = applying(markers, to: try loadFromLocal()) + items.removeAll { $0.id == newItem.id } + items.append(newItem) + try await persist(items) } - func saveToLocal(_ items: [MemoryItem]) { - guard let url = localFileURL, - let data = try? makeEncoder().encode(items) else { return } - try? data.write(to: url, options: .atomic) + func updateItem(_ item: MemoryItem) async throws { + try migrateFromUserDefaultsIfNeeded() + let markers = try loadDeletionMarkers() + var items = applying(markers, to: try loadFromLocal()) + guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } + var revisedItem = item + revisedItem.updatedAt = nextRevision(after: items[index].updatedAt) + items[index] = revisedItem + try await persist(items) } - func persist(_ items: [MemoryItem]) { - saveToLocal(items) + func deleteItem(id: UUID) async throws { + try migrateFromUserDefaultsIfNeeded() + let items = try loadFromLocal() + var markers = try loadDeletionMarkers() + var relevantItems = items.filter { $0.id == id } + if settingsManager.getIsCloudSyncEnabled() { + let snapshot = try await cloudSyncManager.loadMemorySyncSnapshot() + markers = mergeDeletionMarkers(markers, snapshot.deletionMarkers) + relevantItems += (snapshot.items ?? []).filter { $0.id == id } + } + let newestRevision = relevantItems.map(\.updatedAt).max() ?? Date() + let deletionFloor = max(newestRevision, markers.first { $0.id == id }?.deletedAt ?? .distantPast) + let marker = CloudDeletionMarker(id: id, deletedAt: nextRevision(after: deletionFloor)) + markers = mergeDeletionMarkers(markers, [marker]) + try saveDeletionMarkers(markers) + try saveToLocal(items.filter { $0.id != id }) if settingsManager.getIsCloudSyncEnabled() { - try? cloudSyncManager.saveMemoryToCloud(items) + try await cloudSyncManager.deleteMemoryItemFromCloud(id, deletedAt: marker.deletedAt) + } + } + + func deleteAllItems() async throws { + try migrateFromUserDefaultsIfNeeded() + let items = try loadFromLocal() + var allItems = items + var markers = try loadDeletionMarkers() + if settingsManager.getIsCloudSyncEnabled() { + let snapshot = try await cloudSyncManager.loadMemorySyncSnapshot() + allItems += snapshot.items ?? [] + markers = mergeDeletionMarkers(markers, snapshot.deletionMarkers) + } + let deletionFloor = max( + allItems.map(\.updatedAt).max() ?? Date(), + markers.map(\.deletedAt).max() ?? .distantPast + ) + let deletedAt = nextRevision(after: deletionFloor) + let newMarkers = Set(allItems.map(\.id)).map { CloudDeletionMarker(id: $0, deletedAt: deletedAt) } + markers = mergeDeletionMarkers(markers, newMarkers) + try saveDeletionMarkers(markers) + try saveToLocal([]) + guard settingsManager.getIsCloudSyncEnabled() else { return } + try await retryCloudDeletions() + } + + func performSerializedIfNeeded( + _ operation: @escaping @MainActor @Sendable () async throws -> Void + ) async throws { + if settingsManager.getIsCloudSyncEnabled() { + try await categoryOperationGate.perform { + try await operation() + } + } else { + try await operation() + } + } + + func loadItemsForDisplay() throws -> [MemoryItem] { + if let url = localFileURL, FileManager.default.fileExists(atPath: url.path) { + return try loadFromLocal() + } + guard let data = userDefaults.data(forKey: Keys.legacyItems) else { return [] } + return try makeDecoder().decode([MemoryItem].self, from: data) + } + + func loadFromLocal() throws -> [MemoryItem] { + try decodeIfPresent([MemoryItem].self, at: localFileURL) ?? [] + } + + func saveToLocal(_ items: [MemoryItem]) throws { + try writeAndValidate(items.sorted(by: itemSort), to: localFileURL) + } + + func persist(_ items: [MemoryItem]) async throws { + if settingsManager.getIsCloudSyncEnabled() { + try await reconcile(localItems: items) + } else { + try saveToLocal(items) + } + } + + func reconcile(localItems: [MemoryItem]) async throws { + let snapshot = try await cloudSyncManager.loadMemorySyncSnapshot() + let markers = mergeDeletionMarkers(try loadDeletionMarkers(), snapshot.deletionMarkers) + let merge = try mergeItems( + local: localItems, + cloud: snapshot.items ?? [], + deletionMarkers: markers + ) + try preserveForRecovery(merge.losingItems) + try saveDeletionMarkers(markers) + try saveToLocal(merge.items) + for marker in markers { + try await cloudSyncManager.deleteMemoryItemFromCloud(marker.id, deletedAt: marker.deletedAt) + } + try await cloudSyncManager.saveMemoryToCloud(merge.items) + } + + func loadDeletionMarkers() throws -> [CloudDeletionMarker] { + let markers = try decodeIfPresent([CloudDeletionMarker].self, at: localDeletionFileURL) ?? [] + return mergeDeletionMarkers(markers, []) + } + + func saveDeletionMarkers(_ markers: [CloudDeletionMarker]) throws { + try writeAndValidate(markers.sorted { $0.id.uuidString < $1.id.uuidString }, to: localDeletionFileURL) + } + + func retryCloudDeletions() async throws { + for marker in try loadDeletionMarkers() { + try await cloudSyncManager.deleteMemoryItemFromCloud(marker.id, deletedAt: marker.deletedAt) } } /// One-time migration from the old `memory_items` UserDefaults blob to the /// new JSON file in DocumentDirectory. - func migrateFromUserDefaultsIfNeeded() { + func migrateFromUserDefaultsIfNeeded() throws { guard let url = localFileURL, !FileManager.default.fileExists(atPath: url.path) else { return } - let defaults = UserDefaults.standard - if let data = defaults.data(forKey: Keys.legacyItems), - let items = try? makeDecoder().decode([MemoryItem].self, from: data) { - saveToLocal(items) - defaults.removeObject(forKey: Keys.legacyItems) + guard let data = userDefaults.data(forKey: Keys.legacyItems) else { return } + let items = try makeDecoder().decode([MemoryItem].self, from: data) + try saveToLocal(items) + userDefaults.removeObject(forKey: Keys.legacyItems) + } + + func applying(_ markers: [CloudDeletionMarker], to items: [MemoryItem]) -> [MemoryItem] { + let markerById = Dictionary(uniqueKeysWithValues: markers.map { ($0.id, $0) }) + return items.filter { item in + guard let marker = markerById[item.id] else { return true } + return item.updatedAt > marker.deletedAt + } + } + + func mergeDeletionMarkers( + _ local: [CloudDeletionMarker], + _ cloud: [CloudDeletionMarker] + ) -> [CloudDeletionMarker] { + var merged: [UUID: CloudDeletionMarker] = [:] + for marker in local + cloud { + if let current = merged[marker.id], current.deletedAt >= marker.deletedAt { continue } + merged[marker.id] = marker + } + return merged.values.sorted { $0.id.uuidString < $1.id.uuidString } + } + + func mergeItems( + local: [MemoryItem], + cloud: [MemoryItem], + deletionMarkers: [CloudDeletionMarker] + ) throws -> MergeResult { + var winners: [UUID: MemoryItem] = [:] + var losingItems: [MemoryItem] = [] + + for candidate in applying(deletionMarkers, to: local + cloud) { + guard let current = winners[candidate.id] else { + winners[candidate.id] = candidate + continue + } + guard current != candidate else { continue } + if try isPreferred(candidate, over: current) { + losingItems.append(current) + winners[candidate.id] = candidate + } else { + losingItems.append(candidate) + } + } + + return MergeResult( + items: winners.values.sorted(by: itemSort), + losingItems: losingItems.sorted(by: recoverySort) + ) + } + + func isPreferred(_ candidate: MemoryItem, over current: MemoryItem) throws -> Bool { + if candidate.updatedAt != current.updatedAt { return candidate.updatedAt > current.updatedAt } + let candidateData = try makeEncoder().encode(candidate) + let currentData = try makeEncoder().encode(current) + return currentData.lexicographicallyPrecedes(candidateData) + } + + func preserveForRecovery(_ items: [MemoryItem]) throws { + guard !items.isEmpty else { return } + var recoveryItems = try decodeIfPresent([MemoryItem].self, at: localRecoveryFileURL) ?? [] + for item in items where !recoveryItems.contains(item) { + recoveryItems.append(item) } + try writeAndValidate(recoveryItems.sorted(by: recoverySort), to: localRecoveryFileURL) + } + + func decodeIfPresent(_ type: Value.Type, at url: URL?) throws -> Value? { + guard let url else { throw CocoaError(.fileNoSuchFile) } + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try makeDecoder().decode(type, from: Data(contentsOf: url)) + } + + func writeAndValidate(_ value: Value, to url: URL?) throws { + guard let url else { throw CocoaError(.fileWriteUnknown) } + let data = try makeEncoder().encode(value) + if FileManager.default.fileExists(atPath: url.path), try Data(contentsOf: url) == data { return } + try data.write(to: url, options: .atomic) + let writtenData = try Data(contentsOf: url) + guard writtenData == data else { throw CocoaError(.fileWriteUnknown) } + let decoded = try makeDecoder().decode(Value.self, from: writtenData) + guard try makeEncoder().encode(decoded) == data else { throw CocoaError(.fileWriteUnknown) } + } + + func nextRevision(after revision: Date) -> Date { + let now = Date() + return max(now, revision.addingTimeInterval(1)) + } + + func itemSort(_ lhs: MemoryItem, _ rhs: MemoryItem) -> Bool { + lhs.id.uuidString < rhs.id.uuidString + } + + func recoverySort(_ lhs: MemoryItem, _ rhs: MemoryItem) -> Bool { + if lhs.id != rhs.id { return lhs.id.uuidString < rhs.id.uuidString } + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt < rhs.updatedAt } + if lhs.content != rhs.content { return lhs.content < rhs.content } + if lhs.isEnabled != rhs.isEnabled { return !lhs.isEnabled } + return lhs.source.rawValue < rhs.source.rawValue } func makeEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return encoder + SyncJSONCoding.makeEncoder() } func makeDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder + SyncJSONCoding.makeDecoder() } func startMonitoringCloudFile() { - guard cloudSyncManager.isCloudAvailable() else { return } + guard settingsManager.getIsCloudSyncEnabled() else { return } + Task { [weak self] in + guard let self, await cloudSyncManager.checkCloudAvailability() else { return } + beginMonitoringCloudFile() + } + } + func beginMonitoringCloudFile() { let query = NSMetadataQuery() query.predicate = NSPredicate(format: "%K == %@", NSMetadataItemFSNameKey, "Memory.json") query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] diff --git a/openclient-llm/Shared/Core/Managers/SettingsManager.swift b/openclient-llm/Shared/Core/Managers/SettingsManager.swift index 084eb74d..dc520c02 100644 --- a/openclient-llm/Shared/Core/Managers/SettingsManager.swift +++ b/openclient-llm/Shared/Core/Managers/SettingsManager.swift @@ -131,7 +131,9 @@ final class SettingsManager: SettingsManagerProtocol, @unchecked Sendable { } func setIsCloudSyncEnabled(_ value: Bool) { + guard defaults.bool(forKey: Keys.isCloudSyncEnabled) != value else { return } defaults.set(value, forKey: Keys.isCloudSyncEnabled) + NotificationCenter.default.post(name: .cloudSyncIntentDidChange, object: nil) } func getShowTokenUsage() -> Bool { @@ -238,6 +240,7 @@ final class SettingsManager: SettingsManagerProtocol, @unchecked Sendable { } func deleteAll() { + let wasCloudSyncEnabled = getIsCloudSyncEnabled() defaults.removeObject(forKey: Keys.isOnboardingCompleted) defaults.removeObject(forKey: Keys.selectedModelId) defaults.removeObject(forKey: Keys.isCloudSyncEnabled) @@ -255,6 +258,9 @@ final class SettingsManager: SettingsManagerProtocol, @unchecked Sendable { defaults.removeObject(forKey: LegacyKeys.serverBaseURL) defaults.removeObject(forKey: LegacyKeys.apiKey) keychainManager.deleteAll() + if wasCloudSyncEnabled { + NotificationCenter.default.post(name: .cloudSyncIntentDidChange, object: nil) + } } } diff --git a/openclient-llm/Shared/Core/Managers/UserProfileManager.swift b/openclient-llm/Shared/Core/Managers/UserProfileManager.swift index 054cd4d9..35a803d2 100644 --- a/openclient-llm/Shared/Core/Managers/UserProfileManager.swift +++ b/openclient-llm/Shared/Core/Managers/UserProfileManager.swift @@ -10,11 +10,12 @@ import Foundation protocol UserProfileManagerProtocol: Sendable { func getProfile() -> UserProfile - func saveProfile(_ profile: UserProfile) + func saveProfile(_ profile: UserProfile) async throws func getLocalProfile() -> UserProfile - func getCloudProfile() -> UserProfile? - func resolveCloudSyncConflict(keepLocal: Bool) - func deleteLocalProfile() + func getCloudProfileState() async throws -> CloudUserProfileState + func getCloudProfile() async throws -> UserProfile? + func resolveCloudSyncConflict(keepLocal: Bool) async throws + func deleteLocalProfile() throws } /// Manages the user's personal context with optional iCloud file-based sync. @@ -22,10 +23,8 @@ protocol UserProfileManagerProtocol: Sendable { /// When iCloud sync is enabled the cloud `UserProfile.json` is the single source of truth. /// Local storage is a JSON file in DocumentDirectory and is used when sync is disabled. /// -/// Safety: FileManager operations are thread-safe for different paths. CloudSyncManager -/// operations are file-based and called synchronously. The NSMetadataQuery is -/// created and stopped on the main thread; the class is not Sendable-safe for -/// mutable fields but those are only touched during init/deinit on main. +/// Safety: FileManager operations are thread-safe for different paths. Cloud operations are async. +/// NSMetadataQuery state is created, observed, and stopped only on the main actor. final class UserProfileManager: UserProfileManagerProtocol, @unchecked Sendable { // MARK: - Properties @@ -42,26 +41,36 @@ final class UserProfileManager: UserProfileManagerProtocol, @unchecked Sendable private let settingsManager: SettingsManagerProtocol private let cloudSyncManager: CloudSyncManagerProtocol + private let defaults: UserDefaults + private let localFileURL: URL? + private let recoveryDirectoryURL: URL? + private(set) var migrationError: Error? private nonisolated(unsafe) var metadataQuery: NSMetadataQuery? // Must be stored to keep the observer alive. private nonisolated(unsafe) var queryObserver: NSObjectProtocol? - private var localFileURL: URL? { - FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask) - .first? - .appendingPathComponent(Self.fileName) - } - // MARK: - Init init( settingsManager: SettingsManagerProtocol = SettingsManager(), - cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager() + cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager(), + defaults: UserDefaults = .standard, + documentsURL: URL? = nil ) { self.settingsManager = settingsManager self.cloudSyncManager = cloudSyncManager - migrateFromUserDefaultsIfNeeded() + self.defaults = defaults + let resolvedDocumentsURL = documentsURL + ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + self.localFileURL = resolvedDocumentsURL?.appendingPathComponent(Self.fileName) + self.recoveryDirectoryURL = resolvedDocumentsURL?.appendingPathComponent("ProfileRecovery", isDirectory: true) + do { + try migrateFromUserDefaultsIfNeeded() + try migrateLegacyKeysIfNeeded() + } catch { + migrationError = error + LogManager.error("User profile migration failed; legacy source data was retained.") + } startMonitoringCloudFile() } @@ -75,81 +84,109 @@ final class UserProfileManager: UserProfileManagerProtocol, @unchecked Sendable // MARK: - Public func getProfile() -> UserProfile { - if settingsManager.getIsCloudSyncEnabled() { - if let cloud = try? cloudSyncManager.loadProfileFromCloud() { - // Keep local cache up to date. - saveToLocal(cloud) - return cloud - } - } return getLocalProfile() } - func saveProfile(_ profile: UserProfile) { - saveToLocal(profile) - if settingsManager.getIsCloudSyncEnabled() { - try? cloudSyncManager.saveProfileToCloud(profile) - } + func saveProfile(_ profile: UserProfile) async throws { + try saveToLocal(profile) + guard settingsManager.getIsCloudSyncEnabled() else { return } + let cloudState = try await cloudSyncManager.loadProfileStateFromCloud() + try reconcileBeforeSaving(profile, with: cloudState) + try await cloudSyncManager.saveProfileToCloud(profile) } func getLocalProfile() -> UserProfile { guard let url = localFileURL, let data = try? Data(contentsOf: url), - let profile = try? JSONDecoder().decode(UserProfile.self, from: data) else { - return migrateLegacyKeysIfNeeded() + let profile = try? makeDecoder().decode(UserProfile.self, from: data) else { + return UserProfile(modifiedAt: .distantPast) } return profile } - func getCloudProfile() -> UserProfile? { - try? cloudSyncManager.loadProfileFromCloud() + func getCloudProfileState() async throws -> CloudUserProfileState { + let state = try await cloudSyncManager.loadProfileStateFromCloud() + if case .deleted(let marker) = state { + try applyRemoteDeletion(marker) + } + return state + } + + func getCloudProfile() async throws -> UserProfile? { + switch try await getCloudProfileState() { + case .missing, .deleted: + nil + case .profile(let profile): + profile + } } - func resolveCloudSyncConflict(keepLocal: Bool) { + func resolveCloudSyncConflict(keepLocal: Bool) async throws { if keepLocal { - let local = getLocalProfile() - try? cloudSyncManager.saveProfileToCloud(local) + let state = try await cloudSyncManager.loadProfileStateFromCloud() + if case .deleted(let marker) = state, getLocalProfile().modifiedAt <= marker.deletedAt { + try applyRemoteDeletion(marker) + return + } + var local = getLocalProfile() + local.modifiedAt = nextRevision(after: state) + if case .profile(let cloud) = state, cloud != local { + try preserveForRecovery(cloud) + } + try saveToLocal(local) + try await cloudSyncManager.saveProfileToCloud(local) } else { - if let cloud = try? cloudSyncManager.loadProfileFromCloud() { - saveToLocal(cloud) + switch try await cloudSyncManager.loadProfileStateFromCloud() { + case .missing: + break + case .profile(let cloud): + let local = getLocalProfile() + if !local.isEmpty, local != cloud { + try preserveForRecovery(local) + } + try saveToLocal(cloud) + case .deleted(let marker): + try applyRemoteDeletion(marker) } } } - func deleteLocalProfile() { - guard let url = localFileURL else { return } - try? FileManager.default.removeItem(at: url) + func deleteLocalProfile() throws { + try removeLocalProfileFile() + if let recoveryDirectoryURL, FileManager.default.fileExists(atPath: recoveryDirectoryURL.path) { + try FileManager.default.removeItem(at: recoveryDirectoryURL) + } } } // MARK: - Private private extension UserProfileManager { - func saveToLocal(_ profile: UserProfile) { - guard let url = localFileURL, - let data = try? JSONEncoder().encode(profile) else { return } - try? data.write(to: url, options: .atomic) + func saveToLocal(_ profile: UserProfile) throws { + guard let url = localFileURL else { throw CocoaError(.fileNoSuchFile) } + let data = try makeEncoder().encode(profile) + try data.write(to: url, options: .atomic) + let storedProfile = try makeDecoder().decode(UserProfile.self, from: Data(contentsOf: url)) + guard storedProfile == profile else { throw CloudSyncError.cloudContentChanged } } /// One-time migration from the old `userProfile_data` UserDefaults blob to the /// new JSON file in DocumentDirectory. - func migrateFromUserDefaultsIfNeeded() { + func migrateFromUserDefaultsIfNeeded() throws { guard let url = localFileURL, !FileManager.default.fileExists(atPath: url.path) else { return } - let defaults = UserDefaults.standard - if let data = defaults.data(forKey: Keys.legacyProfileData), - let profile = try? JSONDecoder().decode(UserProfile.self, from: data) { - saveToLocal(profile) - defaults.removeObject(forKey: Keys.legacyProfileData) - } + guard let data = defaults.data(forKey: Keys.legacyProfileData) else { return } + let profile = try makeDecoder().decode(UserProfile.self, from: data) + try saveToLocal(profile) + defaults.removeObject(forKey: Keys.legacyProfileData) } /// One-time migration from the legacy per-key NSUbiquitousKeyValueStore / UserDefaults /// storage to the new single JSON file in DocumentDirectory. - func migrateLegacyKeysIfNeeded() -> UserProfile { - let legacyDefaults = UserDefaults.standard - let legacyName = legacyDefaults.string(forKey: "userProfile_name") - let legacyDescription = legacyDefaults.string(forKey: "userProfile_description") - let legacyExtraInfo = legacyDefaults.string(forKey: "userProfile_extraInfo") + func migrateLegacyKeysIfNeeded() throws { + guard let url = localFileURL, !FileManager.default.fileExists(atPath: url.path) else { return } + let legacyName = defaults.string(forKey: "userProfile_name") + let legacyDescription = defaults.string(forKey: "userProfile_description") + let legacyExtraInfo = defaults.string(forKey: "userProfile_extraInfo") // Also check NSUbiquitousKeyValueStore for any data stored there. let cloud = NSUbiquitousKeyValueStore.default @@ -164,47 +201,128 @@ private extension UserProfileManager { let profile = UserProfile(name: name, profileDescription: description, extraInfo: extraInfo) if !profile.isEmpty { - saveToLocal(profile) - // Clean up legacy keys. - legacyDefaults.removeObject(forKey: "userProfile_name") - legacyDefaults.removeObject(forKey: "userProfile_description") - legacyDefaults.removeObject(forKey: "userProfile_extraInfo") + try saveToLocal(profile) + defaults.removeObject(forKey: "userProfile_name") + defaults.removeObject(forKey: "userProfile_description") + defaults.removeObject(forKey: "userProfile_extraInfo") cloud.removeObject(forKey: "userProfile_name") cloud.removeObject(forKey: "userProfile_description") cloud.removeObject(forKey: "userProfile_extraInfo") cloud.synchronize() + } + } - // If cloud sync is enabled, push the migrated profile to the new file-based store. - if settingsManager.getIsCloudSyncEnabled() { - try? cloudSyncManager.saveProfileToCloud(profile) + func reconcileBeforeSaving(_ local: UserProfile, with state: CloudUserProfileState) throws { + switch state { + case .missing: + break + case .deleted(let marker): + guard local.modifiedAt > marker.deletedAt else { + if !local.isEmpty { try preserveForRecovery(local) } + try removeLocalProfileFile() + throw CloudSyncError.staleProfileRevision + } + case .profile(let cloud): + if cloud.modifiedAt > local.modifiedAt { + if !local.isEmpty { try preserveForRecovery(local) } + try saveToLocal(cloud) + throw CloudSyncError.staleProfileRevision + } + if cloud.modifiedAt == local.modifiedAt, cloud != local { + throw CloudSyncError.conflictingProfileRevision + } + if cloud != local { + try preserveForRecovery(cloud) } } + } - return profile + func applyRemoteDeletion(_ marker: CloudDeletionMarker) throws { + let local = getLocalProfile() + guard local.modifiedAt <= marker.deletedAt else { return } + if !local.isEmpty { try preserveForRecovery(local) } + try removeLocalProfileFile() + } + + func removeLocalProfileFile() throws { + guard let localFileURL, FileManager.default.fileExists(atPath: localFileURL.path) else { return } + try FileManager.default.removeItem(at: localFileURL) + } + + func preserveForRecovery(_ profile: UserProfile) throws { + guard let recoveryDirectoryURL else { throw CocoaError(.fileNoSuchFile) } + try FileManager.default.createDirectory(at: recoveryDirectoryURL, withIntermediateDirectories: true) + let data = try makeEncoder().encode(profile) + let url = recoveryDirectoryURL.appendingPathComponent("\(UUID().uuidString).json") + try data.write(to: url, options: .atomic) + let recovered = try makeDecoder().decode(UserProfile.self, from: Data(contentsOf: url)) + guard recovered == profile else { throw CloudSyncError.cloudContentChanged } + } + + func nextRevision(after state: CloudUserProfileState) -> Date { + let remoteRevision: Date + switch state { + case .missing: + remoteRevision = .distantPast + case .profile(let profile): + remoteRevision = profile.modifiedAt + case .deleted(let marker): + remoteRevision = marker.deletedAt + } + return max(Date(), remoteRevision.addingTimeInterval(0.001)) + } + + func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder } // MARK: - iCloud file monitoring func startMonitoringCloudFile() { - guard cloudSyncManager.isCloudAvailable() else { return } + guard settingsManager.getIsCloudSyncEnabled() else { return } + Task { [weak self] in + guard let self, await cloudSyncManager.checkCloudAvailability() else { return } + beginMonitoringCloudFile() + } + } + func beginMonitoringCloudFile() { let query = NSMetadataQuery() - query.predicate = NSPredicate(format: "%K == %@", NSMetadataItemFSNameKey, "UserProfile.json") + query.predicate = NSPredicate( + format: "%K IN %@", + NSMetadataItemFSNameKey, + ["UserProfile.json", "UserProfileDeletion.json"] + ) query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] - let settingsManager = self.settingsManager queryObserver = NotificationCenter.default.addObserver( forName: .NSMetadataQueryDidUpdate, object: query, queue: .main - ) { _ in + ) { [weak self] _ in // queue: .main guarantees main-thread execution. MainActor.assumeIsolated { - guard settingsManager.getIsCloudSyncEnabled() else { return } - NotificationCenter.default.post( - name: UserProfileManager.profileDidChangeExternallyNotification, - object: nil - ) + guard let self, self.settingsManager.getIsCloudSyncEnabled() else { return } + Task { + do { + _ = try await self.getCloudProfileState() + NotificationCenter.default.post( + name: UserProfileManager.profileDidChangeExternallyNotification, + object: nil + ) + } catch { + LogManager.error("External user profile reconciliation failed.") + } + } } } diff --git a/openclient-llm/Shared/Core/Models/CloudDeletionMarker.swift b/openclient-llm/Shared/Core/Models/CloudDeletionMarker.swift new file mode 100644 index 00000000..817c18f6 --- /dev/null +++ b/openclient-llm/Shared/Core/Models/CloudDeletionMarker.swift @@ -0,0 +1,14 @@ +// +// CloudDeletionMarker.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct CloudDeletionMarker: Codable, Equatable, Sendable { + let id: UUID + let deletedAt: Date +} diff --git a/openclient-llm/Shared/Core/Models/CloudSyncManifest.swift b/openclient-llm/Shared/Core/Models/CloudSyncManifest.swift new file mode 100644 index 00000000..cb38ba2a --- /dev/null +++ b/openclient-llm/Shared/Core/Models/CloudSyncManifest.swift @@ -0,0 +1,59 @@ +// +// CloudSyncManifest.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct CloudSyncManifest: Codable, Equatable, Sendable { + // MARK: - Properties + + enum ValidationError: Error, Equatable, Sendable { + case invalidFormat + case invalidVersionRange + case unsupportedSchemaVersion(Int) + } + + static let expectedFormat = "com.artcc.openclient-llm.icloud-sync" + static let currentSchemaVersion = 1 + + let format: String + let schemaVersion: Int + let minimumReaderVersion: Int + + static var current: CloudSyncManifest { + CloudSyncManifest( + format: expectedFormat, + schemaVersion: currentSchemaVersion, + minimumReaderVersion: currentSchemaVersion + ) + } + + // MARK: - Decode + + static func decode(_ data: Data?) throws -> CloudSyncManifest { + guard let data else { return .current } + let manifest = try JSONDecoder().decode(CloudSyncManifest.self, from: data) + try manifest.validate() + return manifest + } + + // MARK: - Validate + + func validate(supportedSchemaVersion: Int = currentSchemaVersion) throws { + guard format == Self.expectedFormat else { + throw ValidationError.invalidFormat + } + guard schemaVersion > 0, + minimumReaderVersion > 0, + minimumReaderVersion <= schemaVersion else { + throw ValidationError.invalidVersionRange + } + guard minimumReaderVersion <= supportedSchemaVersion else { + throw ValidationError.unsupportedSchemaVersion(schemaVersion) + } + } +} diff --git a/openclient-llm/Shared/Core/Models/CloudSyncStatus.swift b/openclient-llm/Shared/Core/Models/CloudSyncStatus.swift new file mode 100644 index 00000000..0294f6b6 --- /dev/null +++ b/openclient-llm/Shared/Core/Models/CloudSyncStatus.swift @@ -0,0 +1,50 @@ +// +// CloudSyncStatus.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated enum CloudSyncStatus: Equatable, Sendable { + // MARK: - Types + + enum DataCategory: String, CaseIterable, Hashable, Sendable { + case conversations + case attachments + case profile + case memory + case promptTemplates + } + + enum UnavailableReason: Equatable, Sendable { + case accountUnavailable + case containerUnavailable + } + + enum FailureReason: Equatable, Sendable { + case unsupportedSchema + case invalidData + case fileAccess + case insufficientStorage + case other + } + + struct Failure: Equatable, Sendable { + let reason: FailureReason + let affectedCategories: Set + } + + // MARK: - States + + case disabled + case checkingAvailability + case idle(lastSuccessfulSyncAt: Date?) + case synchronizing + case waitingForDownloads + case synchronized(lastSuccessfulSyncAt: Date) + case unavailable(UnavailableReason) + case failed(Failure) +} diff --git a/openclient-llm/Shared/Core/Models/SyncJSONCoding.swift b/openclient-llm/Shared/Core/Models/SyncJSONCoding.swift new file mode 100644 index 00000000..b877bed2 --- /dev/null +++ b/openclient-llm/Shared/Core/Models/SyncJSONCoding.swift @@ -0,0 +1,36 @@ +// +// SyncJSONCoding.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated enum SyncJSONCoding { + static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(format(date)) + } + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + private static func format(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .iso8601) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" + return formatter.string(from: date) + } +} diff --git a/openclient-llm/Shared/Features/Chat/Models/ChatMessage.swift b/openclient-llm/Shared/Features/Chat/Models/ChatMessage.swift index c6ae4fb1..acedfec9 100644 --- a/openclient-llm/Shared/Features/Chat/Models/ChatMessage.swift +++ b/openclient-llm/Shared/Features/Chat/Models/ChatMessage.swift @@ -8,7 +8,7 @@ import Foundation -struct ChatMessage: Identifiable, Equatable, Sendable, Codable { +nonisolated struct ChatMessage: Identifiable, Equatable, Sendable, Codable { // MARK: - Properties let id: UUID @@ -83,7 +83,7 @@ struct ChatMessage: Identifiable, Equatable, Sendable, Codable { // MARK: - Attachment extension ChatMessage { - enum AttachmentType: String, Sendable, Equatable, Codable { + nonisolated enum AttachmentType: String, Sendable, Equatable, Codable { case image case pdf } @@ -93,7 +93,7 @@ extension ChatMessage { /// Binary data is stored on disk (via `AttachmentRepository`) and referenced here /// by `fileRelativePath`. The `data` property loads it from disk on demand and is /// intentionally excluded from `Codable` serialisation. - struct Attachment: Identifiable, Equatable, Sendable, Codable { + nonisolated struct Attachment: Identifiable, Equatable, Sendable, Codable { // MARK: - Properties let id: UUID @@ -131,8 +131,8 @@ extension ChatMessage { /// Custom decoder that tolerates legacy JSON format (pre-v2) where /// `fileRelativePath` and `mimeType` were absent and `data` held raw bytes. - /// The `data` key is intentionally ignored; `AttachmentMigrationUseCase` - /// handles extracting and persisting those bytes to disk. + /// Legacy inline bytes are retained in memory until synchronization or migration + /// has materialized and verified the disk-backed representation. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: ChatMessageAttachmentCodingKeys.self) id = try container.decode(UUID.self, forKey: .id) @@ -144,7 +144,7 @@ extension ChatMessage { ?? Self.inferMimeType(for: decodedType, fileName: decodedFileName) // Legacy attachments won't have this key; migration will populate it fileRelativePath = try container.decodeIfPresent(String.self, forKey: .fileRelativePath) ?? "" - transientData = nil + transientData = try container.decodeIfPresent(Data.self, forKey: .data) } func encode(to encoder: Encoder) throws { @@ -174,16 +174,17 @@ extension ChatMessage { } } -private enum ChatMessageAttachmentCodingKeys: String, CodingKey { +private nonisolated enum ChatMessageAttachmentCodingKeys: String, CodingKey { case id case type case fileName case mimeType case fileRelativePath + case data } private extension ChatMessage { - enum CodingKeys: String, CodingKey { + nonisolated enum CodingKeys: String, CodingKey { case id case role case content diff --git a/openclient-llm/Shared/Features/Chat/Models/Conversation.swift b/openclient-llm/Shared/Features/Chat/Models/Conversation.swift index 34433b5a..6e0bdf4b 100644 --- a/openclient-llm/Shared/Features/Chat/Models/Conversation.swift +++ b/openclient-llm/Shared/Features/Chat/Models/Conversation.swift @@ -8,7 +8,7 @@ import Foundation -enum ConversationContextMetadataError: LocalizedError { +nonisolated enum ConversationContextMetadataError: LocalizedError { case invalidContextWindow case inconsistentSummary case invalidSummaryCursor @@ -25,7 +25,7 @@ enum ConversationContextMetadataError: LocalizedError { } } -struct Conversation: Identifiable, Equatable, Sendable, Codable { +nonisolated struct Conversation: Identifiable, Equatable, Sendable, Codable { // MARK: - Properties let id: UUID @@ -85,7 +85,7 @@ struct Conversation: Identifiable, Equatable, Sendable, Codable { id = try container.decode(UUID.self, forKey: .id) title = try container.decode(String.self, forKey: .title) modelId = try container.decode(String.self, forKey: .modelId) - systemPrompt = try container.decode(String.self, forKey: .systemPrompt) + systemPrompt = try container.decodeIfPresent(String.self, forKey: .systemPrompt) ?? "" contextWindowTokens = try container.decodeIfPresent(Int.self, forKey: .contextWindowTokens) contextSummary = try container.decodeIfPresent(String.self, forKey: .contextSummary) contextSummaryCursorMessageId = try container.decodeIfPresent(UUID.self, forKey: .contextSummaryCursorMessageId) diff --git a/openclient-llm/Shared/Features/Chat/Models/ConversationCloudSyncSnapshot.swift b/openclient-llm/Shared/Features/Chat/Models/ConversationCloudSyncSnapshot.swift new file mode 100644 index 00000000..8687308d --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Models/ConversationCloudSyncSnapshot.swift @@ -0,0 +1,105 @@ +// +// ConversationCloudSyncSnapshot.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct CloudSyncSession: Equatable, Sendable { + let containerURL: URL + let identity: Data +} + +nonisolated struct CloudAttachmentKey: Hashable, Sendable { + let conversationId: UUID + let fileName: String +} + +nonisolated struct ConversationCloudSyncSnapshot: Sendable { + let session: CloudSyncSession + let manifestData: Data? + let conversations: [UUID: Conversation] + let conversationData: [UUID: Data] + let tombstones: [ConversationTombstone] + let tombstoneData: [UUID: Data] + let legacyTombstoneData: Data? + let deleteAllMarker: ConversationDeleteAllMarker? + let deleteAllMarkerData: Data? + let attachmentData: [CloudAttachmentKey: Data] + let attachmentPlaceholders: Set +} + +nonisolated struct ConversationCloudSyncOutput: Sendable { + let conversations: [Conversation] + let conversationData: [UUID: Data] + let tombstones: [ConversationTombstone] + let deleteAllMarker: ConversationDeleteAllMarker? + let attachments: [CloudAttachmentKey: Data] +} + +nonisolated enum ConversationAttachmentPath { + static func key(for attachment: ChatMessage.Attachment) throws -> CloudAttachmentKey? { + guard !attachment.fileRelativePath.isEmpty else { return nil } + let components = (attachment.fileRelativePath as NSString).pathComponents + guard components.count == 3, + components[0] == "Attachments", + let conversationId = UUID(uuidString: components[1]), + components[2] != ".", + components[2] != "..", + !components[2].contains("/"), + !components[2].contains("\\"), + !components[2].contains("%"), + !isICloudPlaceholderFileName(components[2]) else { + throw CloudSyncError.invalidAttachmentPath + } + let key = CloudAttachmentKey(conversationId: conversationId, fileName: components[2]) + guard attachment.fileRelativePath == relativePath(for: key) else { + throw CloudSyncError.invalidAttachmentPath + } + return key + } + + static func key( + for attachment: ChatMessage.Attachment, + conversationId: UUID + ) throws -> CloudAttachmentKey? { + guard let key = try key(for: attachment) else { return nil } + guard key.conversationId == conversationId else { + throw CloudSyncError.invalidAttachmentPath + } + return key + } + + static func relativePath(for key: CloudAttachmentKey) -> String { + "Attachments/\(key.conversationId.uuidString)/\(key.fileName)" + } + + static func relativePath( + for attachment: ChatMessage.Attachment, + conversationId: UUID + ) -> String { + let fileName = "\(attachment.id.uuidString).\(fileExtension(for: attachment))" + return relativePath(for: CloudAttachmentKey(conversationId: conversationId, fileName: fileName)) + } + + private static func fileExtension(for attachment: ChatMessage.Attachment) -> String { + switch attachment.mimeType { + case "image/jpeg": return "jpg" + case "image/png": return "png" + case "image/gif": return "gif" + case "image/webp": return "webp" + case "application/pdf": return "pdf" + default: + let candidate = (attachment.fileName as NSString).pathExtension.lowercased() + let allowed = candidate.unicodeScalars.allSatisfy(CharacterSet.alphanumerics.contains) + return !candidate.isEmpty && allowed ? candidate : "bin" + } + } + + private static func isICloudPlaceholderFileName(_ fileName: String) -> Bool { + fileName.hasPrefix(".") && fileName.hasSuffix(".icloud") + } +} diff --git a/openclient-llm/Shared/Features/Chat/Models/ConversationDeleteAllMarker.swift b/openclient-llm/Shared/Features/Chat/Models/ConversationDeleteAllMarker.swift index 6c975906..875f60de 100644 --- a/openclient-llm/Shared/Features/Chat/Models/ConversationDeleteAllMarker.swift +++ b/openclient-llm/Shared/Features/Chat/Models/ConversationDeleteAllMarker.swift @@ -8,6 +8,6 @@ import Foundation -struct ConversationDeleteAllMarker: Codable, Equatable, Sendable { +nonisolated struct ConversationDeleteAllMarker: Codable, Equatable, Sendable { let deletedAt: Date } diff --git a/openclient-llm/Shared/Features/Chat/Models/ConversationSyncOperationError.swift b/openclient-llm/Shared/Features/Chat/Models/ConversationSyncOperationError.swift new file mode 100644 index 00000000..2c9dec16 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Models/ConversationSyncOperationError.swift @@ -0,0 +1,39 @@ +// +// ConversationSyncOperationError.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated enum ConversationSyncOperationError: LocalizedError, Equatable { + case pendingDownload + case unavailable + case failed + + init?(result: ConversationSyncResult) { + switch result { + case .synchronized: + return nil + case .pendingDownload: + self = .pendingDownload + case .unavailable: + self = .unavailable + case .failed: + self = .failed + } + } + + var errorDescription: String? { + switch self { + case .pendingDownload: + String(localized: "The cloud deletion is waiting for required downloads.") + case .unavailable: + String(localized: "The cloud deletion could not be completed because iCloud is unavailable.") + case .failed: + String(localized: "The cloud deletion could not be completed.") + } + } +} diff --git a/openclient-llm/Shared/Features/Chat/Models/ConversationSyncResult.swift b/openclient-llm/Shared/Features/Chat/Models/ConversationSyncResult.swift index 75144fb1..09b46685 100644 --- a/openclient-llm/Shared/Features/Chat/Models/ConversationSyncResult.swift +++ b/openclient-llm/Shared/Features/Chat/Models/ConversationSyncResult.swift @@ -7,7 +7,7 @@ import Foundation -enum ConversationSyncResult: Equatable, Sendable { +nonisolated enum ConversationSyncResult: Equatable, Sendable { case synchronized case pendingDownload case unavailable diff --git a/openclient-llm/Shared/Features/Chat/Models/ConversationTag.swift b/openclient-llm/Shared/Features/Chat/Models/ConversationTag.swift index 696ed046..64d96fb9 100644 --- a/openclient-llm/Shared/Features/Chat/Models/ConversationTag.swift +++ b/openclient-llm/Shared/Features/Chat/Models/ConversationTag.swift @@ -8,7 +8,7 @@ import Foundation -struct ConversationTag: Equatable, Hashable, Sendable, Codable { +nonisolated struct ConversationTag: Equatable, Hashable, Sendable, Codable { let name: String let color: TagColor } diff --git a/openclient-llm/Shared/Features/Chat/Models/ConversationTombstone.swift b/openclient-llm/Shared/Features/Chat/Models/ConversationTombstone.swift index 03b4c8dc..c9c2d901 100644 --- a/openclient-llm/Shared/Features/Chat/Models/ConversationTombstone.swift +++ b/openclient-llm/Shared/Features/Chat/Models/ConversationTombstone.swift @@ -8,7 +8,7 @@ import Foundation -struct ConversationTombstone: Codable, Equatable, Sendable { +nonisolated struct ConversationTombstone: Codable, Equatable, Sendable { let conversationId: UUID let deletedAt: Date } diff --git a/openclient-llm/Shared/Features/Chat/Models/DeleteMemoryTool.swift b/openclient-llm/Shared/Features/Chat/Models/DeleteMemoryTool.swift index 52949482..9cddf5f4 100644 --- a/openclient-llm/Shared/Features/Chat/Models/DeleteMemoryTool.swift +++ b/openclient-llm/Shared/Features/Chat/Models/DeleteMemoryTool.swift @@ -63,7 +63,7 @@ struct DeleteMemoryTool: ChatToolProtocol { return ToolExecutionResult(text: "No memory item found matching: \(query)") } - memoryManager.delete(id: item.id) + try await memoryManager.delete(id: item.id) NotificationCenter.default.post( name: MemoryManager.memoryDidChangeExternallyNotification, diff --git a/openclient-llm/Shared/Features/Chat/Models/ModelParameters.swift b/openclient-llm/Shared/Features/Chat/Models/ModelParameters.swift index 25cfcf5f..47aa442f 100644 --- a/openclient-llm/Shared/Features/Chat/Models/ModelParameters.swift +++ b/openclient-llm/Shared/Features/Chat/Models/ModelParameters.swift @@ -8,7 +8,7 @@ import Foundation -struct ModelParameters: Equatable, Sendable, Codable { +nonisolated struct ModelParameters: Equatable, Sendable, Codable { // MARK: - Properties var temperature: Double? diff --git a/openclient-llm/Shared/Features/Chat/Models/SaveMemoryTool.swift b/openclient-llm/Shared/Features/Chat/Models/SaveMemoryTool.swift index 81350828..d58d3b74 100644 --- a/openclient-llm/Shared/Features/Chat/Models/SaveMemoryTool.swift +++ b/openclient-llm/Shared/Features/Chat/Models/SaveMemoryTool.swift @@ -54,7 +54,7 @@ struct SaveMemoryTool: ChatToolProtocol { let trimmed = content.trimmingCharacters(in: .whitespaces) let item = MemoryItem(content: trimmed, source: .model) - memoryManager.add(item) + try await memoryManager.add(item) NotificationCenter.default.post( name: MemoryManager.memoryDidChangeExternallyNotification, diff --git a/openclient-llm/Shared/Features/Chat/Models/TagColor.swift b/openclient-llm/Shared/Features/Chat/Models/TagColor.swift index aa3a4323..034542fc 100644 --- a/openclient-llm/Shared/Features/Chat/Models/TagColor.swift +++ b/openclient-llm/Shared/Features/Chat/Models/TagColor.swift @@ -8,7 +8,7 @@ import Foundation -enum TagColor: String, CaseIterable, Codable, Identifiable, Sendable { +nonisolated enum TagColor: String, CaseIterable, Codable, Identifiable, Sendable { case red case orange case yellow diff --git a/openclient-llm/Shared/Features/Chat/Models/TokenUsage.swift b/openclient-llm/Shared/Features/Chat/Models/TokenUsage.swift index 8784b899..2658b81a 100644 --- a/openclient-llm/Shared/Features/Chat/Models/TokenUsage.swift +++ b/openclient-llm/Shared/Features/Chat/Models/TokenUsage.swift @@ -8,7 +8,7 @@ import Foundation -struct TokenUsage: Equatable, Sendable, Codable { +nonisolated struct TokenUsage: Equatable, Sendable, Codable { // MARK: - Properties let promptTokens: Int diff --git a/openclient-llm/Shared/Features/Chat/Repositories/AttachmentFileResolver.swift b/openclient-llm/Shared/Features/Chat/Repositories/AttachmentFileResolver.swift new file mode 100644 index 00000000..32ef4db0 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/AttachmentFileResolver.swift @@ -0,0 +1,63 @@ +// +// AttachmentFileResolver.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct AttachmentFileResolver { + // MARK: - Properties + + private let fileManager: FileManager + private let baseURL: URL + + // MARK: - Init + + init(fileManager: FileManager, baseURL: URL) { + self.fileManager = fileManager + self.baseURL = baseURL.standardizedFileURL + } + + // MARK: - Resolve + + func resolve(relativePath: String) throws -> URL { + let root = baseURL.appendingPathComponent("Attachments", isDirectory: true) + let candidate = baseURL.appendingPathComponent(relativePath).standardizedFileURL + guard isContained(candidate, in: root) else { throw CloudSyncError.invalidAttachmentPath } + try rejectSymbolicLinks(relativePath: relativePath) + guard isContained(candidate.resolvingSymlinksInPath(), in: root.resolvingSymlinksInPath()) else { + throw CloudSyncError.invalidAttachmentPath + } + return candidate + } + + func attachmentRoot() throws -> URL { + try rejectSymbolicLinks(relativePath: "Attachments") + return baseURL.appendingPathComponent("Attachments", isDirectory: true) + } + + func conversationDirectory(_ conversationId: UUID) throws -> URL { + let relativePath = "Attachments/\(conversationId.uuidString)" + try rejectSymbolicLinks(relativePath: relativePath) + return baseURL.appendingPathComponent(relativePath, isDirectory: true) + } + + // MARK: - Private + + private func rejectSymbolicLinks(relativePath: String) throws { + var currentURL = baseURL + for component in (relativePath as NSString).pathComponents { + currentURL.appendPathComponent(component) + guard fileManager.fileExists(atPath: currentURL.path) else { continue } + let values = try currentURL.resourceValues(forKeys: [.isSymbolicLinkKey]) + guard values.isSymbolicLink != true else { throw CloudSyncError.invalidAttachmentPath } + } + } + + private func isContained(_ url: URL, in directory: URL) -> Bool { + url.standardizedFileURL.path.hasPrefix(directory.standardizedFileURL.path + "/") + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/AttachmentRepository.swift b/openclient-llm/Shared/Features/Chat/Repositories/AttachmentRepository.swift index b7b616ae..d5df2854 100644 --- a/openclient-llm/Shared/Features/Chat/Repositories/AttachmentRepository.swift +++ b/openclient-llm/Shared/Features/Chat/Repositories/AttachmentRepository.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - Protocol -protocol AttachmentRepositoryProtocol: Sendable { +nonisolated protocol AttachmentRepositoryProtocol: Sendable { /// Persists `data` for `attachment` inside the given conversation folder and returns /// the relative path that was stored in `attachment.fileRelativePath`. /// - Returns: The relative path `"Attachments//."` @@ -32,60 +32,80 @@ protocol AttachmentRepositoryProtocol: Sendable { // MARK: - AttachmentRepository -struct AttachmentRepository: AttachmentRepositoryProtocol { +// Safety: FileManager is thread-safe per Apple documentation. All stored properties are immutable (`let`). +nonisolated struct AttachmentRepository: AttachmentRepositoryProtocol, @unchecked Sendable { // MARK: - Properties private let fileManager: FileManager private let baseURL: URL + private let fileResolver: AttachmentFileResolver // MARK: - Init - init(fileManager: FileManager = .default) { + init(fileManager: FileManager = .default, baseURL: URL? = nil) { self.fileManager = fileManager - let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - self.baseURL = documentsURL + let baseURL = baseURL ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + self.baseURL = baseURL + self.fileResolver = AttachmentFileResolver(fileManager: fileManager, baseURL: baseURL) } // MARK: - Public @discardableResult func save(data: Data, for attachment: ChatMessage.Attachment, conversationId: UUID) throws -> String { - let ext = fileExtension(for: attachment.mimeType, fallback: attachment.fileName) - let relativePath = "Attachments/\(conversationId.uuidString)/\(attachment.id.uuidString).\(ext)" + let relativePath = ConversationAttachmentPath.relativePath( + for: attachment, + conversationId: conversationId + ) + _ = try resolvedURL(for: relativePath) let fileURL = baseURL.appendingPathComponent(relativePath) try ensureDirectoryExists(for: fileURL) - try data.write(to: fileURL, options: .atomic) + let resolvedURL = try resolvedURL(for: relativePath) + try data.write(to: resolvedURL, options: .atomic) + guard try Data(contentsOf: resolvedURL) == data else { + throw AttachmentRepositoryError.fileNotFound + } - LogManager.debug("AttachmentRepository.save \(relativePath) (\(data.count) bytes)") + LogManager.debug("AttachmentRepository.save completed bytes=\(data.count)") return relativePath } func load(attachment: ChatMessage.Attachment) throws -> Data { - let fileURL = baseURL.appendingPathComponent(attachment.fileRelativePath) + let fileURL = try attachmentURL(for: attachment) guard fileManager.fileExists(atPath: fileURL.path) else { - LogManager.error("AttachmentRepository.load: file not found \(attachment.fileRelativePath)") - throw AttachmentRepositoryError.fileNotFound(attachment.fileRelativePath) + LogManager.error("AttachmentRepository.load failed reason=fileNotFound") + throw AttachmentRepositoryError.fileNotFound } return try Data(contentsOf: fileURL) } func delete(attachment: ChatMessage.Attachment) throws { - let fileURL = baseURL.appendingPathComponent(attachment.fileRelativePath) + let fileURL = try attachmentURL(for: attachment) guard fileManager.fileExists(atPath: fileURL.path) else { return } try fileManager.removeItem(at: fileURL) - LogManager.debug("AttachmentRepository.delete \(attachment.fileRelativePath)") + LogManager.debug("AttachmentRepository.delete completed") } func deleteAll(forConversationId conversationId: UUID) throws { - let dirURL = baseURL.appendingPathComponent("Attachments/\(conversationId.uuidString)", isDirectory: true) + let dirURL: URL + do { + dirURL = try fileResolver.conversationDirectory(conversationId) + } catch { + throw AttachmentRepositoryError.invalidPath + } guard fileManager.fileExists(atPath: dirURL.path) else { return } try fileManager.removeItem(at: dirURL) LogManager.debug("AttachmentRepository.deleteAll conversationId=\(conversationId)") } func deleteAll() throws { - let dirURL = baseURL.appendingPathComponent("Attachments", isDirectory: true) + let dirURL: URL + do { + dirURL = try fileResolver.attachmentRoot() + } catch { + throw AttachmentRepositoryError.invalidPath + } guard fileManager.fileExists(atPath: dirURL.path) else { return } try fileManager.removeItem(at: dirURL) LogManager.warning("AttachmentRepository.deleteAll — all attachments removed") @@ -101,30 +121,40 @@ private extension AttachmentRepository { try fileManager.createDirectory(at: dirURL, withIntermediateDirectories: true) } - /// Derives a file extension from MIME type, with a fallback to the original file name extension. - func fileExtension(for mimeType: String, fallback fileName: String) -> String { - switch mimeType { - case "image/jpeg": return "jpg" - case "image/png": return "png" - case "image/gif": return "gif" - case "image/webp": return "webp" - case "application/pdf": return "pdf" - default: - let ext = (fileName as NSString).pathExtension.lowercased() - return ext.isEmpty ? "bin" : ext + func attachmentURL(for attachment: ChatMessage.Attachment) throws -> URL { + let key: CloudAttachmentKey + do { + guard let value = try ConversationAttachmentPath.key(for: attachment) else { + throw AttachmentRepositoryError.invalidPath + } + key = value + } catch { + throw AttachmentRepositoryError.invalidPath + } + return try resolvedURL(for: ConversationAttachmentPath.relativePath(for: key)) + } + + func resolvedURL(for relativePath: String) throws -> URL { + do { + return try fileResolver.resolve(relativePath: relativePath) + } catch { + throw AttachmentRepositoryError.invalidPath } } } // MARK: - AttachmentRepositoryError -enum AttachmentRepositoryError: LocalizedError { - case fileNotFound(String) +nonisolated enum AttachmentRepositoryError: LocalizedError { + case fileNotFound + case invalidPath var errorDescription: String? { switch self { - case .fileNotFound(let path): - return "Attachment file not found at path: \(path)" + case .fileNotFound: + String(localized: "The attachment file could not be found.") + case .invalidPath: + String(localized: "The attachment file path is invalid.") } } } diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationLocalTransaction.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationLocalTransaction.swift new file mode 100644 index 00000000..d1388bd8 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationLocalTransaction.swift @@ -0,0 +1,386 @@ +// +// ConversationLocalTransaction.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct ConversationLocalTransaction { + struct Verification { + var conversations: [UUID: Conversation] = [:] + var pendingMutationBases: [UUID: Conversation] = [:] + var attachments: [CloudAttachmentKey: Data] = [:] + var absentConversationIds: Set = [] + var absentPendingMutationIds: Set = [] + var absentAttachmentKeys: Set = [] + var exactConversationSet = false + var emptyConversations = false + var emptyPendingMutations = false + var emptyAttachments = false + } + + // MARK: - Properties + + private static let defaultItemNames = [ + "Conversations", + "ConversationTombstones.json", + "ConversationDeleteAll.json", + "ConversationLocalReset.json", + "ConversationPendingMutations" + ] + + private struct Manifest: Codable { + let itemNames: [String] + let attachmentPaths: [String] + } + + private let fileManager: FileManager + private let documentsURL: URL + private let backupURL: URL + + // MARK: - Init + + init( + fileManager: FileManager, + documentsURL: URL, + attachmentKeys: Set = [], + backsUpAllAttachments: Bool = false + ) throws { + self.fileManager = fileManager + self.documentsURL = documentsURL + let transactionsURL = Self.transactionsDirectory(documentsURL: documentsURL) + let identifier = UUID().uuidString + let stagingURL = transactionsURL.appendingPathComponent("\(identifier).staging", isDirectory: true) + let backupURL = transactionsURL.appendingPathComponent("\(identifier).pending", isDirectory: true) + self.backupURL = backupURL + + do { + try fileManager.createDirectory(at: stagingURL, withIntermediateDirectories: true) + let itemNames = Self.defaultItemNames + (backsUpAllAttachments ? ["Attachments"] : []) + for itemName in itemNames { + let sourceURL = documentsURL.appendingPathComponent(itemName) + guard fileManager.fileExists(atPath: sourceURL.path) else { continue } + let destinationURL = stagingURL.appendingPathComponent(itemName) + try fileManager.copyItem(at: sourceURL, to: destinationURL) + try Self.verifyCopy(sourceURL: sourceURL, destinationURL: destinationURL, fileManager: fileManager) + } + let attachmentPaths = backsUpAllAttachments + ? [] + : attachmentKeys.map(ConversationAttachmentPath.relativePath).sorted() + try Self.copyAttachments( + at: attachmentPaths, + documentsURL: documentsURL, + stagingURL: stagingURL, + fileManager: fileManager + ) + let manifest = Manifest(itemNames: itemNames, attachmentPaths: attachmentPaths) + try JSONEncoder().encode(manifest).write( + to: stagingURL.appendingPathComponent("Manifest.json"), + options: .atomic + ) + try fileManager.moveItem(at: stagingURL, to: backupURL) + } catch { + try? fileManager.removeItem(at: stagingURL) + try? fileManager.removeItem(at: backupURL) + throw error + } + } + + // MARK: - Transaction + + static func recoverPendingTransactions(fileManager: FileManager, documentsURL: URL) throws { + let transactionsURL = transactionsDirectory(documentsURL: documentsURL) + guard fileManager.fileExists(atPath: transactionsURL.path) else { return } + let backups = try fileManager.contentsOfDirectory(at: transactionsURL, includingPropertiesForKeys: nil) + for backupURL in backups { + let values = try backupURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { continue } + switch backupURL.pathExtension { + case "staging", "committed": + try fileManager.removeItem(at: backupURL) + continue + case "pending": + break + default: + continue + } + let transaction = ConversationLocalTransaction( + fileManager: fileManager, + documentsURL: documentsURL, + backupURL: backupURL + ) + try transaction.rollback() + } + } + + func commit(verifying verification: Verification) throws { + try verify(verification) + try disposeRecovery() + } + + func commit() throws { + try verifyCurrentStore() + try disposeRecovery() + } + + private func disposeRecovery() throws { + let committedURL = backupURL + .deletingPathExtension() + .appendingPathExtension("committed") + try fileManager.moveItem(at: backupURL, to: committedURL) + try? fileManager.removeItem(at: committedURL) + } + + func rollback() throws { + let manifest = try loadManifest() + let restoreURL = backupURL.appendingPathComponent("Restore", isDirectory: true) + let displacedURL = backupURL.appendingPathComponent("Displaced", isDirectory: true) + try removeIfPresent(restoreURL) + try removeIfPresent(displacedURL) + try fileManager.createDirectory(at: restoreURL, withIntermediateDirectories: true) + + for itemName in manifest.itemNames { + let sourceURL = backupURL.appendingPathComponent(itemName) + guard fileManager.fileExists(atPath: sourceURL.path) else { continue } + let stagedURL = restoreURL.appendingPathComponent(itemName) + try fileManager.copyItem(at: sourceURL, to: stagedURL) + guard fileManager.contentsEqual(atPath: sourceURL.path, andPath: stagedURL.path) else { + throw CloudSyncError.invalidConversationData + } + } + + let backupResolver = AttachmentFileResolver(fileManager: fileManager, baseURL: backupURL) + for relativePath in manifest.attachmentPaths { + let sourceURL = try backupResolver.resolve(relativePath: relativePath) + guard fileManager.fileExists(atPath: sourceURL.path) else { continue } + let stagedURL = restoreURL.appendingPathComponent(relativePath) + try fileManager.createDirectory( + at: stagedURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fileManager.copyItem(at: sourceURL, to: stagedURL) + try Self.verifyCopy(sourceURL: sourceURL, destinationURL: stagedURL, fileManager: fileManager) + } + + for itemName in manifest.itemNames { + let destinationURL = documentsURL.appendingPathComponent(itemName) + let displacedItemURL = displacedURL.appendingPathComponent(itemName) + let stagedURL = restoreURL.appendingPathComponent(itemName) + try restoreItem(destinationURL: destinationURL, stagedURL: stagedURL, displacedURL: displacedItemURL) + } + + let liveResolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + for relativePath in manifest.attachmentPaths { + let destinationURL = try liveResolver.resolve(relativePath: relativePath) + let stagedURL = restoreURL.appendingPathComponent(relativePath) + let displacedItemURL = displacedURL.appendingPathComponent(relativePath) + try restoreItem(destinationURL: destinationURL, stagedURL: stagedURL, displacedURL: displacedItemURL) + } + try fileManager.removeItem(at: backupURL) + } +} + +// MARK: - Private + +private nonisolated extension ConversationLocalTransaction { + init(fileManager: FileManager, documentsURL: URL, backupURL: URL) { + self.fileManager = fileManager + self.documentsURL = documentsURL + self.backupURL = backupURL + } + + static func transactionsDirectory(documentsURL: URL) -> URL { + documentsURL + .appendingPathComponent("ConversationRecovery", isDirectory: true) + .appendingPathComponent("Transactions", isDirectory: true) + } + + static func copyAttachments( + at relativePaths: [String], + documentsURL: URL, + stagingURL: URL, + fileManager: FileManager + ) throws { + let sourceResolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + for relativePath in relativePaths { + let sourceURL = try sourceResolver.resolve(relativePath: relativePath) + guard fileManager.fileExists(atPath: sourceURL.path) else { continue } + let destinationURL = stagingURL.appendingPathComponent(relativePath) + try fileManager.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fileManager.copyItem(at: sourceURL, to: destinationURL) + try verifyCopy(sourceURL: sourceURL, destinationURL: destinationURL, fileManager: fileManager) + } + } + + static func verifyCopy( + sourceURL: URL, + destinationURL: URL, + fileManager: FileManager + ) throws { + guard fileManager.contentsEqual(atPath: sourceURL.path, andPath: destinationURL.path) else { + throw CloudSyncError.invalidConversationData + } + } + + private func loadManifest() throws -> Manifest { + let data = try Data(contentsOf: backupURL.appendingPathComponent("Manifest.json")) + return try JSONDecoder().decode(Manifest.self, from: data) + } + + func verify(_ verification: Verification) throws { + let decoder = SyncJSONCoding.makeDecoder() + try verifyConversations(verification, decoder: decoder) + try verifyPendingMutations(verification, decoder: decoder) + try verifyAttachments(verification) + try verifyEmptyDirectories(verification) + } + + func verifyConversations(_ verification: Verification, decoder: JSONDecoder) throws { + for (id, expected) in verification.conversations { + let url = documentsURL.appendingPathComponent("Conversations/\(id.uuidString).json") + let decoded = try decoder.decode(Conversation.self, from: Data(contentsOf: url)) + try decoded.validateContextMetadata() + guard decoded == expected else { throw CloudSyncError.invalidConversationData } + } + for id in verification.absentConversationIds { + let url = documentsURL.appendingPathComponent("Conversations/\(id.uuidString).json") + guard !fileManager.fileExists(atPath: url.path) else { + throw CloudSyncError.invalidConversationData + } + } + if verification.exactConversationSet { + let ids = Set(try decodedConversations( + in: documentsURL.appendingPathComponent("Conversations", isDirectory: true), + decoder: decoder + ).map(\.id)) + guard ids == Set(verification.conversations.keys) else { + throw CloudSyncError.invalidConversationData + } + } + } + + func verifyPendingMutations(_ verification: Verification, decoder: JSONDecoder) throws { + for (id, expected) in verification.pendingMutationBases { + let url = documentsURL.appendingPathComponent("ConversationPendingMutations/\(id.uuidString).json") + let decoded = try decoder.decode(Conversation.self, from: Data(contentsOf: url)) + try decoded.validateContextMetadata() + guard decoded == expected else { throw CloudSyncError.invalidConversationData } + } + for id in verification.absentPendingMutationIds { + let url = documentsURL.appendingPathComponent("ConversationPendingMutations/\(id.uuidString).json") + guard !fileManager.fileExists(atPath: url.path) else { + throw CloudSyncError.invalidConversationData + } + } + } + + func verifyAttachments(_ verification: Verification) throws { + let resolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + for (key, expected) in verification.attachments { + let url = try resolver.resolve(relativePath: ConversationAttachmentPath.relativePath(for: key)) + guard try Data(contentsOf: url) == expected else { throw CloudSyncError.missingAttachment } + } + for key in verification.absentAttachmentKeys { + let url = try resolver.resolve(relativePath: ConversationAttachmentPath.relativePath(for: key)) + guard !fileManager.fileExists(atPath: url.path) else { throw CloudSyncError.missingAttachment } + } + } + + func verifyEmptyDirectories(_ verification: Verification) throws { + if verification.emptyConversations { + try requireEmptyDirectory(named: "Conversations") + } + if verification.emptyPendingMutations { + try requireEmptyDirectory(named: "ConversationPendingMutations") + } + if verification.emptyAttachments { + try requireEmptyDirectory(named: "Attachments") + } + } + + func verifyCurrentStore() throws { + let decoder = SyncJSONCoding.makeDecoder() + let conversations = try decodedConversations( + in: documentsURL.appendingPathComponent("Conversations", isDirectory: true), + decoder: decoder + ) + _ = try decodedConversations( + in: documentsURL.appendingPathComponent("ConversationPendingMutations", isDirectory: true), + decoder: decoder + ) + let resolver = AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + for conversation in conversations { + for attachment in conversation.messages.flatMap(\.attachments) { + guard let key = try ConversationAttachmentPath.key(for: attachment) else { + throw CloudSyncError.missingAttachment + } + let url = try resolver.resolve(relativePath: ConversationAttachmentPath.relativePath(for: key)) + _ = try Data(contentsOf: url) + } + } + } + + func decodedConversations(in directory: URL, decoder: JSONDecoder) throws -> [Conversation] { + guard fileManager.fileExists(atPath: directory.path) else { return [] } + return try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ).filter { $0.pathExtension == "json" }.map { url in + let conversation = try decoder.decode(Conversation.self, from: Data(contentsOf: url)) + try conversation.validateContextMetadata() + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) == conversation.id else { + throw CloudSyncError.invalidConversationData + } + return conversation + } + } + + func requireEmptyDirectory(named name: String) throws { + let url = documentsURL.appendingPathComponent(name, isDirectory: true) + guard fileManager.fileExists(atPath: url.path) else { return } + guard try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil).isEmpty else { + throw CloudSyncError.invalidConversationData + } + } + + func restoreItem( + destinationURL: URL, + stagedURL: URL, + displacedURL: URL + ) throws { + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.createDirectory( + at: displacedURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fileManager.moveItem(at: destinationURL, to: displacedURL) + } + do { + if fileManager.fileExists(atPath: stagedURL.path) { + try fileManager.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fileManager.moveItem(at: stagedURL, to: destinationURL) + } + try removeIfPresent(displacedURL) + } catch { + if fileManager.fileExists(atPath: displacedURL.path) { + try fileManager.moveItem(at: displacedURL, to: destinationURL) + } + throw error + } + } + + func removeIfPresent(_ url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationRebaser.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationRebaser.swift new file mode 100644 index 00000000..6cadaf9d --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationRebaser.swift @@ -0,0 +1,213 @@ +// +// ConversationRebaser.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated enum ConversationRebaser { + static func rebase( + _ incoming: Conversation, + base: Conversation, + onto current: Conversation + ) throws -> Conversation { + var result = current + rebaseConfiguration(incoming, base: base, result: &result) + rebaseMetadata(incoming, base: base, result: &result) + result.messages = try rebasedMessages( + incoming.messages, + base: base.messages, + current: current.messages + ) + return result + } +} + +// MARK: - Private + +private extension ConversationRebaser { + nonisolated struct MessageGraph { + var edges: [UUID: Set] + var indegrees: [UUID: Int] + } + + nonisolated static func rebaseConfiguration( + _ incoming: Conversation, + base: Conversation, + result: inout Conversation + ) { + if incoming.title != base.title { result.title = incoming.title } + if incoming.modelId != base.modelId { result.modelId = incoming.modelId } + if incoming.systemPrompt != base.systemPrompt { result.systemPrompt = incoming.systemPrompt } + if incoming.contextWindowTokens != base.contextWindowTokens { + result.contextWindowTokens = incoming.contextWindowTokens + } + if incoming.contextSummary != base.contextSummary { result.contextSummary = incoming.contextSummary } + if incoming.contextSummaryCursorMessageId != base.contextSummaryCursorMessageId { + result.contextSummaryCursorMessageId = incoming.contextSummaryCursorMessageId + } + if incoming.modelParameters != base.modelParameters { result.modelParameters = incoming.modelParameters } + } + + nonisolated static func rebaseMetadata( + _ incoming: Conversation, + base: Conversation, + result: inout Conversation + ) { + if incoming.isPinned != base.isPinned { result.isPinned = incoming.isPinned } + if incoming.tags != base.tags { result.tags = incoming.tags } + if incoming.parentConversationId != base.parentConversationId { + result.parentConversationId = incoming.parentConversationId + } + if incoming.branchedFromMessageId != base.branchedFromMessageId { + result.branchedFromMessageId = incoming.branchedFromMessageId + } + } + + nonisolated static func rebasedMessages( + _ incoming: [ChatMessage], + base: [ChatMessage], + current: [ChatMessage] + ) throws -> [ChatMessage] { + guard incoming != base else { return current } + guard current != base else { return incoming } + guard incoming != current else { return current } + if messagesDifferOnlyByFavourite(incoming, base) { + return applyingFavouriteChanges(from: incoming, base: base, to: current) + } + if messagesDifferOnlyByFavourite(current, base) { + return applyingFavouriteChanges(from: current, base: base, to: incoming) + } + guard preservesBaseMessages(incoming, base: base), + preservesBaseMessages(current, base: base) else { + throw CloudSyncError.staleConversationRevision + } + return try mergeMessageSequences(incoming, current, base: base) + } + + nonisolated static func preservesBaseMessages(_ candidate: [ChatMessage], base: [ChatMessage]) -> Bool { + guard Set(candidate.map(\.id)).count == candidate.count else { return false } + let baseById = Dictionary(uniqueKeysWithValues: base.map { ($0.id, $0) }) + let retainedBase = candidate.compactMap { message -> ChatMessage? in + guard let baseMessage = baseById[message.id] else { return nil } + return messagesDifferOnlyByFavourite([message], [baseMessage]) ? message : nil + } + return retainedBase.map(\.id) == base.map(\.id) + } + + nonisolated static func mergeMessageSequences( + _ first: [ChatMessage], + _ second: [ChatMessage], + base: [ChatMessage] + ) throws -> [ChatMessage] { + let messages = try mergedMessagesById(first, second, base: base) + var graph = makeGraph(sequences: [first, second], ids: Set(messages.keys)) + var available = graph.indegrees.filter { $0.value == 0 }.map(\.key) + var result: [ChatMessage] = [] + while let id = nextMessageId(from: &available, messages: messages) { + guard let message = messages[id] else { throw CloudSyncError.invalidConversationData } + result.append(message) + for successor in graph.edges[id] ?? [] { + graph.indegrees[successor, default: 0] -= 1 + if graph.indegrees[successor] == 0 { available.append(successor) } + } + } + guard result.count == messages.count else { throw CloudSyncError.staleConversationRevision } + return result + } + + nonisolated static func mergedMessagesById( + _ first: [ChatMessage], + _ second: [ChatMessage], + base: [ChatMessage] + ) throws -> [UUID: ChatMessage] { + let baseById = Dictionary(uniqueKeysWithValues: base.map { ($0.id, $0) }) + var result: [UUID: ChatMessage] = [:] + for message in first + second { + guard let existing = result[message.id] else { + result[message.id] = message + continue + } + result[message.id] = try mergedMessage(existing, message, base: baseById[message.id]) + } + return result + } + + nonisolated static func mergedMessage( + _ first: ChatMessage, + _ second: ChatMessage, + base: ChatMessage? + ) throws -> ChatMessage { + var normalized = first + normalized.isFavourite = second.isFavourite + guard normalized == second else { throw CloudSyncError.staleConversationRevision } + guard let base else { + guard first.isFavourite == second.isFavourite else { + throw CloudSyncError.staleConversationRevision + } + return first + } + let firstChanged = first.isFavourite != base.isFavourite + let secondChanged = second.isFavourite != base.isFavourite + if firstChanged { return first } + if secondChanged { return second } + return first + } + + nonisolated static func makeGraph(sequences: [[ChatMessage]], ids: Set) -> MessageGraph { + var graph = MessageGraph( + edges: Dictionary(uniqueKeysWithValues: ids.map { ($0, Set()) }), + indegrees: Dictionary(uniqueKeysWithValues: ids.map { ($0, 0) }) + ) + for sequence in sequences { + for (source, destination) in zip(sequence, sequence.dropFirst()) where source.id != destination.id { + if graph.edges[source.id, default: []].insert(destination.id).inserted { + graph.indegrees[destination.id, default: 0] += 1 + } + } + } + return graph + } + + nonisolated static func nextMessageId( + from available: inout [UUID], + messages: [UUID: ChatMessage] + ) -> UUID? { + available.sort { lhs, rhs in + guard let left = messages[lhs], let right = messages[rhs] else { + return lhs.uuidString < rhs.uuidString + } + if left.timestamp == right.timestamp { return lhs.uuidString < rhs.uuidString } + return left.timestamp < right.timestamp + } + return available.isEmpty ? nil : available.removeFirst() + } + + nonisolated static func applyingFavouriteChanges( + from source: [ChatMessage], + base: [ChatMessage], + to target: [ChatMessage] + ) -> [ChatMessage] { + let changes = Dictionary(uniqueKeysWithValues: zip(source, base).compactMap { source, base in + source.isFavourite == base.isFavourite ? nil : (source.id, source.isFavourite) + }) + return target.map { message in + guard let isFavourite = changes[message.id] else { return message } + var message = message + message.isFavourite = isFavourite + return message + } + } + + nonisolated static func messagesDifferOnlyByFavourite(_ lhs: [ChatMessage], _ rhs: [ChatMessage]) -> Bool { + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { left, right in + var normalizedLeft = left + normalizedLeft.isFavourite = right.isFavourite + return normalizedLeft == right + } + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationRepository.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationRepository.swift index 26ade64b..75425ec0 100644 --- a/openclient-llm/Shared/Features/Chat/Repositories/ConversationRepository.swift +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationRepository.swift @@ -10,352 +10,200 @@ import Foundation import WidgetKit protocol ConversationRepositoryProtocol: Sendable { - func loadAll() throws -> [Conversation] - func loadLocal() throws -> [Conversation] - func save(_ conversation: Conversation) throws - func delete(_ conversationId: UUID) throws - func deleteAll() throws + func loadAll() async throws -> [Conversation] + func loadLocal() async throws -> [Conversation] @discardableResult - func synchronize() -> ConversationSyncResult + func save(_ conversation: Conversation, expectedBase: Conversation?) async throws -> Conversation + func importBatch(_ conversations: [Conversation]) async throws -> [Conversation] + func setPinned(_ isPinned: Bool, conversationId: UUID) async throws -> Conversation? + func rename(_ conversationId: UUID, title: String) async throws -> Conversation? + func updateTags(_ conversationId: UUID, tags: [ConversationTag]) async throws -> Conversation? + func delete(_ conversationId: UUID) async throws + func deleteAll() async throws + @discardableResult + func synchronize() async -> ConversationSyncResult + func cancelSynchronization() async + func cancelSynchronizationAndDeleteAll() async throws +} + +extension ConversationRepositoryProtocol { + @discardableResult + func save(_ conversation: Conversation) async throws -> Conversation { + try await save(conversation, expectedBase: nil) + } } struct ConversationRepository: ConversationRepositoryProtocol { // MARK: - Properties - private let fileManager: FileManager - private let directoryURL: URL - private let tombstonesURL: URL - private let deleteAllMarkerURL: URL + private static let liveStorage = ConversationStorage() + private static let liveSyncCoordinator = ConversationSyncCoordinator(storage: liveStorage) + private let settingsManager: SettingsManagerProtocol - private let cloudSyncManager: CloudSyncManagerProtocol - private let attachmentRepository: AttachmentRepositoryProtocol + private let storage: ConversationStorage + private let syncCoordinator: ConversationSyncCoordinator // MARK: - Init init( - fileManager: FileManager = .default, settingsManager: SettingsManagerProtocol = SettingsManager(), - cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager(), - attachmentRepository: AttachmentRepositoryProtocol = AttachmentRepository(), - baseDirectory: URL? = nil + cloudSyncManager: CloudSyncManagerProtocol? = nil, + attachmentRepository: AttachmentRepositoryProtocol? = nil, + baseDirectory: URL? = nil, + storage: ConversationStorage? = nil, + syncCoordinator: ConversationSyncCoordinator? = nil ) { - self.fileManager = fileManager - let documentsURL = baseDirectory ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - self.directoryURL = documentsURL.appendingPathComponent("Conversations", isDirectory: true) - self.tombstonesURL = documentsURL.appendingPathComponent("ConversationTombstones.json") - self.deleteAllMarkerURL = documentsURL.appendingPathComponent("ConversationDeleteAll.json") self.settingsManager = settingsManager - self.cloudSyncManager = cloudSyncManager - self.attachmentRepository = attachmentRepository + if let storage { + self.storage = storage + self.syncCoordinator = syncCoordinator ?? ConversationSyncCoordinator(storage: storage) + } else if baseDirectory != nil || cloudSyncManager != nil || attachmentRepository != nil { + let manager = cloudSyncManager ?? CloudSyncManager() + let storage = ConversationStorage( + cloudSyncManager: manager, + attachmentRepository: attachmentRepository, + baseDirectory: baseDirectory + ) + self.storage = storage + self.syncCoordinator = syncCoordinator ?? ConversationSyncCoordinator(storage: storage) + } else { + self.storage = Self.liveStorage + self.syncCoordinator = Self.liveSyncCoordinator + } } // MARK: - Public - func loadAll() throws -> [Conversation] { - LogManager.debug("loadAll conversations") - try ensureDirectoryExists() - + func loadAll() async throws -> [Conversation] { if settingsManager.getIsCloudSyncEnabled() { - _ = synchronize() + _ = await syncCoordinator.synchronize() } - return try loadLocal() - } - - func loadLocal() throws -> [Conversation] { - LogManager.debug("loadLocal conversations") - try ensureDirectoryExists() - - let localConversations = try loadLocalConversations() - - let sorted = localConversations.sorted { $0.updatedAt > $1.updatedAt } - updateWidgetSnapshot(conversations: sorted) - LogManager.success("loadLocal returned \(sorted.count) conversations") - return sorted + return try await loadLocal() } - func save(_ conversation: Conversation) throws { - LogManager.debug("save conversation id=\(conversation.id) title='\(conversation.title)'") - try ensureDirectoryExists() - try saveLocal(conversation) - - if settingsManager.getIsCloudSyncEnabled() { - _ = synchronize() - } - - updateWidgetSnapshot() + func loadLocal() async throws -> [Conversation] { + let conversations = try await storage.loadLocal() + updateWidgetSnapshot(conversations: conversations) + return conversations } - func delete(_ conversationId: UUID) throws { - LogManager.debug("delete conversation id=\(conversationId)") - // Load conversation before deleting so we can clean up its attachment files - let fileURL = directoryURL.appendingPathComponent("\(conversationId.uuidString).json") - if let data = try? Data(contentsOf: fileURL), - let conversation = try? JSONDecoder.iso8601.decode(Conversation.self, from: data) { - deleteAttachments(for: conversation) - } - try saveTombstones(mergedTombstones([ConversationTombstone(conversationId: conversationId, deletedAt: Date())])) - if fileManager.fileExists(atPath: fileURL.path) { - try fileManager.removeItem(at: fileURL) - } - LogManager.success("delete conversation id=\(conversationId) done") - - if settingsManager.getIsCloudSyncEnabled() { - _ = synchronize() - } - - updateWidgetSnapshot() + @discardableResult + func save(_ conversation: Conversation, expectedBase: Conversation?) async throws -> Conversation { + let admissionToken = await syncCoordinator.admissionToken() + let saved = try await syncCoordinator.save( + conversation, + expectedBase: expectedBase, + synchronize: settingsManager.getIsCloudSyncEnabled(), + admissionToken: admissionToken + ) + updateWidgetSnapshot(conversations: try await storage.loadLocal()) + return saved } - func deleteAll() throws { - LogManager.warning("deleteAll conversations") - if settingsManager.getIsCloudSyncEnabled() { - try saveDeleteAllMarker(ConversationDeleteAllMarker(deletedAt: Date())) - } - if fileManager.fileExists(atPath: directoryURL.path) { - try fileManager.removeItem(at: directoryURL) - } - try ensureDirectoryExists() - try? attachmentRepository.deleteAll() - LogManager.success("deleteAll conversations done") - + func importBatch(_ conversations: [Conversation]) async throws -> [Conversation] { + let saved = try await storage.importBatch(conversations) if settingsManager.getIsCloudSyncEnabled() { - _ = synchronize() + _ = await syncCoordinator.synchronize() } - - if AppGroupStore.clearConversations() { - WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.conversationsWidgetKind) - WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.taggedConversationsWidgetKind) + if let localConversations = try? await storage.loadLocal() { + updateWidgetSnapshot(conversations: localConversations) } + return saved } - @discardableResult - func synchronize() -> ConversationSyncResult { - guard settingsManager.getIsCloudSyncEnabled(), cloudSyncManager.isCloudAvailable() else { - return .unavailable - } - do { - try ensureDirectoryExists() - if try cloudSyncManager.hasPendingConversationDownloads() { - return .pendingDownload - } - return try synchronizeAvailableCloud() - } catch { - LogManager.error("Conversation synchronization failed: \(error)") - return .failed - } - } -} - -// MARK: - Private - -private extension ConversationRepository { - func ensureDirectoryExists() throws { - guard !fileManager.fileExists(atPath: directoryURL.path) else { return } - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + func setPinned(_ isPinned: Bool, conversationId: UUID) async throws -> Conversation? { + let admissionToken = await syncCoordinator.admissionToken() + let conversation = try await syncCoordinator.setPinned( + isPinned, + conversationId: conversationId, + synchronize: settingsManager.getIsCloudSyncEnabled(), + admissionToken: admissionToken + ) + try await updateAfterMutation(conversation != nil) + return conversation } - func loadLocalConversations() throws -> [Conversation] { - let fileURLs = try fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles + func rename(_ conversationId: UUID, title: String) async throws -> Conversation? { + let admissionToken = await syncCoordinator.admissionToken() + let conversation = try await syncCoordinator.rename( + conversationId, + title: title, + synchronize: settingsManager.getIsCloudSyncEnabled(), + admissionToken: admissionToken ) - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - var conversations: [Conversation] = [] - for url in fileURLs where url.pathExtension == "json" { - do { - let data = try Data(contentsOf: url) - let conversation = try decoder.decode(Conversation.self, from: data) - conversations.append(conversation) - } catch { - LogManager.error("Failed to decode conversation at \(url.lastPathComponent): \(error)") - continue - } - } - return conversations + try await updateAfterMutation(conversation != nil) + return conversation } - func saveLocal(_ conversation: Conversation) throws { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - - let data = try encoder.encode(conversation) - let fileURL = directoryURL.appendingPathComponent("\(conversation.id.uuidString).json") - try writeIfChanged(data, to: fileURL) + func updateTags(_ conversationId: UUID, tags: [ConversationTag]) async throws -> Conversation? { + let admissionToken = await syncCoordinator.admissionToken() + let conversation = try await syncCoordinator.updateTags( + conversationId, + tags: tags, + synchronize: settingsManager.getIsCloudSyncEnabled(), + admissionToken: admissionToken + ) + try await updateAfterMutation(conversation != nil) + return conversation } - func mergeConversations( - local: [Conversation], - cloud: [Conversation], - tombstones: [ConversationTombstone], - deleteAllMarker: ConversationDeleteAllMarker? - ) -> [Conversation] { - let deletedAt = Dictionary(uniqueKeysWithValues: tombstones.map { ($0.conversationId, $0.deletedAt) }) - var merged: [UUID: Conversation] = [:] - - for conversation in local { - guard shouldKeep(conversation, deletedAt: deletedAt, marker: deleteAllMarker) else { continue } - merged[conversation.id] = conversation - } - - for cloudConversation in cloud { - guard shouldKeep(cloudConversation, deletedAt: deletedAt, marker: deleteAllMarker) else { continue } - if let existing = merged[cloudConversation.id] { - // Keep the most recently updated version - if cloudConversation.updatedAt > existing.updatedAt { - merged[cloudConversation.id] = cloudConversation - } - } else { - merged[cloudConversation.id] = cloudConversation - } - } - - return Array(merged.values) + func delete(_ conversationId: UUID) async throws { + let admissionToken = await syncCoordinator.admissionToken() + try await syncCoordinator.delete( + conversationId, + synchronize: settingsManager.getIsCloudSyncEnabled(), + admissionToken: admissionToken + ) + updateWidgetSnapshot(conversations: try await storage.loadLocal()) } - func synchronizeAvailableCloud() throws -> ConversationSyncResult { - let local = try loadLocalConversations() - let localTombstones = try loadTombstones() - let cloud = try cloudSyncManager.loadConversationsFromCloud() - let cloudTombstones = try cloudSyncManager.loadConversationTombstonesFromCloud() - let marker = newestMarker( - try loadDeleteAllMarker(), - try cloudSyncManager.loadConversationDeleteAllMarkerFromCloud() - ) - let deleteAllTombstones = marker.map { marker in - (local + cloud).map { - ConversationTombstone(conversationId: $0.id, deletedAt: marker.deletedAt) - } - } ?? [] - let tombstones = mergeTombstones(localTombstones + cloudTombstones + deleteAllTombstones) - let conversations = mergeConversations( - local: local, - cloud: cloud, - tombstones: tombstones, - deleteAllMarker: marker + func deleteAll() async throws { + let shouldSynchronize = settingsManager.getIsCloudSyncEnabled() + let admissionToken = await syncCoordinator.admissionToken() + try await syncCoordinator.deleteAll( + synchronize: shouldSynchronize, + admissionToken: admissionToken ) - - try persistLocal(conversations: conversations, tombstones: tombstones) - try cloudSyncManager.saveConversationTombstonesToCloud(tombstones) - if let marker { - try cloudSyncManager.saveConversationDeleteAllMarkerToCloud(marker) - try saveDeleteAllMarker(marker) - } - try cloudSyncManager.syncConversationsToCloud(conversations) - for tombstone in tombstones { - try cloudSyncManager.deleteConversationFromCloud(tombstone.conversationId) - } - - var attachmentsReady = true - for conversation in conversations { - let isMaterialized = try cloudSyncManager.materializeAttachmentsFromCloud(for: conversation) - attachmentsReady = isMaterialized && attachmentsReady - } - updateWidgetSnapshot() - return attachmentsReady ? .synchronized : .pendingDownload + clearWidgetSnapshot() } - func persistLocal(conversations: [Conversation], tombstones: [ConversationTombstone]) throws { - let ids = Set(conversations.map(\.id)) - cleanupLocalFiles(keeping: ids) - for conversation in conversations { - try saveLocal(conversation) - } - try saveTombstones(tombstones) - for tombstone in tombstones { - try? attachmentRepository.deleteAll(forConversationId: tombstone.conversationId) + @discardableResult + func synchronize() async -> ConversationSyncResult { + guard settingsManager.getIsCloudSyncEnabled() else { return .unavailable } + let result = await syncCoordinator.synchronize() + if let conversations = try? await storage.loadLocal() { + updateWidgetSnapshot(conversations: conversations) } + return result } - func shouldKeep( - _ conversation: Conversation, - deletedAt: [UUID: Date], - marker: ConversationDeleteAllMarker? - ) -> Bool { - deletedAt[conversation.id] == nil - && (marker == nil || conversation.updatedAt > marker?.deletedAt ?? .distantFuture) - } - - func loadTombstones() throws -> [ConversationTombstone] { - guard fileManager.fileExists(atPath: tombstonesURL.path) else { return [] } - let data = try Data(contentsOf: tombstonesURL) - return try JSONDecoder.iso8601.decode([ConversationTombstone].self, from: data) - } - - func saveTombstones(_ tombstones: [ConversationTombstone]) throws { - let data = try JSONEncoder.iso8601.encode(tombstones) - try writeIfChanged(data, to: tombstonesURL) - } - - func loadDeleteAllMarker() throws -> ConversationDeleteAllMarker? { - guard fileManager.fileExists(atPath: deleteAllMarkerURL.path) else { return nil } - let data = try Data(contentsOf: deleteAllMarkerURL) - return try JSONDecoder.iso8601.decode(ConversationDeleteAllMarker.self, from: data) - } - - func saveDeleteAllMarker(_ marker: ConversationDeleteAllMarker) throws { - let data = try JSONEncoder.iso8601.encode(marker) - try writeIfChanged(data, to: deleteAllMarkerURL) + func cancelSynchronization() async { + await syncCoordinator.cancel() } - func newestMarker( - _ local: ConversationDeleteAllMarker?, - _ cloud: ConversationDeleteAllMarker? - ) -> ConversationDeleteAllMarker? { - [local, cloud].compactMap { $0 }.max { $0.deletedAt < $1.deletedAt } + func cancelSynchronizationAndDeleteAll() async throws { + try await syncCoordinator.cancelAndDeleteAll() + clearWidgetSnapshot() } +} - func mergedTombstones(_ adding: [ConversationTombstone]) -> [ConversationTombstone] { - mergeTombstones(((try? loadTombstones()) ?? []) + adding) - } - - func mergeTombstones(_ tombstones: [ConversationTombstone]) -> [ConversationTombstone] { - var latest: [UUID: ConversationTombstone] = [:] - for tombstone in tombstones { - let existingDate = latest[tombstone.conversationId]?.deletedAt ?? .distantPast - guard existingDate < tombstone.deletedAt else { continue } - latest[tombstone.conversationId] = tombstone - } - return Array(latest.values) - } - - func cleanupLocalFiles(keeping ids: Set) { - guard let fileURLs = try? fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles - ) else { return } - - for url in fileURLs where url.pathExtension == "json" { - if let uuid = UUID(uuidString: url.deletingPathExtension().lastPathComponent), - !ids.contains(uuid) { - try? fileManager.removeItem(at: url) - LogManager.debug("Cleaned up local conversation file: \(uuid)") - } - } - } +// MARK: - Private - func writeIfChanged(_ data: Data, to url: URL) throws { - if let existing = try? Data(contentsOf: url), existing == data { return } - try data.write(to: url, options: .atomic) +private extension ConversationRepository { + func clearWidgetSnapshot() { + guard AppGroupStore.clearConversations() else { return } + WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.conversationsWidgetKind) + WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.pinnedConversationsWidgetKind) + WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.latestConversationWidgetKind) + WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.taggedConversationsWidgetKind) } - /// Deletes all attachment files referenced by the messages of a conversation. - func deleteAttachments(for conversation: Conversation) { - for message in conversation.messages { - for attachment in message.attachments { - try? attachmentRepository.delete(attachment: attachment) - } - } + func updateAfterMutation(_ didMutate: Bool) async throws { + guard didMutate else { return } + updateWidgetSnapshot(conversations: try await storage.loadLocal()) } - /// Rebuilds the App Group widget snapshot and reloads its timeline when it changed. - func updateWidgetSnapshot(conversations: [Conversation]? = nil) { - let conversations = conversations ?? (try? loadLocalConversations()) ?? [] + func updateWidgetSnapshot(conversations: [Conversation]) { let sorted = conversations.sorted { $0.updatedAt > $1.updatedAt } let recentConversations = makeWidgetConversations(from: Array(sorted.prefix(6))) let pinnedConversations = makeWidgetConversations(from: sorted.filter(\.isPinned)) @@ -365,20 +213,16 @@ private extension ConversationRepository { let tagsChanged = AppGroupStore.saveTags(tags) if recentChanged { WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.conversationsWidgetKind) + WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.latestConversationWidgetKind) } if pinnedChanged { WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.pinnedConversationsWidgetKind) } - if recentChanged { - WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.latestConversationWidgetKind) - } if tagsChanged || recentChanged || pinnedChanged { WidgetCenter.shared.reloadTimelines(ofKind: AppGroupStore.taggedConversationsWidgetKind) } } -} -private extension ConversationRepository { func makeWidgetConversations(from conversations: [Conversation]) -> [WidgetConversation] { conversations.map { conversation in WidgetConversation( @@ -394,22 +238,3 @@ private extension ConversationRepository { } } } - -// MARK: - JSONDecoder convenience - -private extension JSONDecoder { - static let iso8601: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder - }() -} - -private extension JSONEncoder { - static let iso8601: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - return encoder - }() -} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+AttachmentNormalization.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+AttachmentNormalization.swift new file mode 100644 index 00000000..be45eb71 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+AttachmentNormalization.swift @@ -0,0 +1,143 @@ +// +// ConversationStorage+AttachmentNormalization.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +// MARK: - Attachment Normalization + +extension ConversationStorage { + struct AttachmentNormalizationContext { + let sourceData: [CloudAttachmentKey: Data] + let sourcePlaceholders: Set + let knownConversationIds: Set + } + + struct NormalizedAttachment { + let attachment: ChatMessage.Attachment + let key: CloudAttachmentKey + let data: Data? + } + + func makeMergedConversation( + conversation: Conversation, + data: Data, + source: Source, + normalizationContext: AttachmentNormalizationContext + ) throws -> MergedConversation { + var conversation = conversation + try conversation.validateContextMetadata() + let inlineAttachmentData = try normalizeAttachments( + in: &conversation, + context: normalizationContext + ) + + let normalizedData: Data + if !inlineAttachmentData.isEmpty { + try preserveForRecovery(data, conversationId: conversation.id) + normalizedData = try makeEncoder().encode(conversation) + } else { + normalizedData = data + } + return MergedConversation( + conversation: conversation, + data: normalizedData, + source: source, + localInlineAttachmentData: source == .local ? inlineAttachmentData : [:], + cloudInlineAttachmentData: source == .cloud ? inlineAttachmentData : [:] + ) + } + + func normalizeAttachments( + in conversation: inout Conversation, + context: AttachmentNormalizationContext + ) throws -> [CloudAttachmentKey: Data] { + var inlineAttachmentData: [CloudAttachmentKey: Data] = [:] + for messageIndex in conversation.messages.indices { + for attachmentIndex in conversation.messages[messageIndex].attachments.indices { + let attachment = conversation.messages[messageIndex].attachments[attachmentIndex] + let normalized = try normalizedAttachment( + attachment, + conversationId: conversation.id, + context: context + ) + if let attachmentData = normalized.data, + let existing = inlineAttachmentData[normalized.key], + existing != attachmentData { + throw CloudSyncError.invalidConversationData + } + if let attachmentData = normalized.data { + inlineAttachmentData[normalized.key] = attachmentData + } + conversation.messages[messageIndex].attachments[attachmentIndex] = normalized.attachment + } + } + return inlineAttachmentData + } + + func normalizedAttachment( + _ attachment: ChatMessage.Attachment, + conversationId: UUID, + context: AttachmentNormalizationContext + ) throws -> NormalizedAttachment { + guard !attachment.fileRelativePath.isEmpty || attachment.transientData != nil else { + throw CloudSyncError.missingAttachment + } + let sourceKey = try ConversationAttachmentPath.key(for: attachment) + let key: CloudAttachmentKey + var data = attachment.transientData + if let sourceKey, sourceKey.conversationId != conversationId { + let fileIdentifier = UUID(uuidString: (sourceKey.fileName as NSString).deletingPathExtension) + guard fileIdentifier == attachment.id, + !context.knownConversationIds.contains(sourceKey.conversationId) else { + throw CloudSyncError.invalidAttachmentPath + } + if context.sourcePlaceholders.contains(sourceKey) { + throw CloudSyncError.requiredDownloadPending + } + data = data ?? context.sourceData[sourceKey] + guard data != nil else { throw CloudSyncError.missingAttachment } + key = CloudAttachmentKey(conversationId: conversationId, fileName: sourceKey.fileName) + } else if let sourceKey { + key = sourceKey + } else { + key = try generatedAttachmentKey(attachment, conversationId: conversationId) + } + let normalizedAttachment = ChatMessage.Attachment( + id: attachment.id, + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileRelativePath: ConversationAttachmentPath.relativePath(for: key) + ) + return NormalizedAttachment(attachment: normalizedAttachment, key: key, data: data) + } + + func generatedAttachmentKey( + _ attachment: ChatMessage.Attachment, + conversationId: UUID + ) throws -> CloudAttachmentKey { + let relativePath = ConversationAttachmentPath.relativePath( + for: attachment, + conversationId: conversationId + ) + let normalized = ChatMessage.Attachment( + id: attachment.id, + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileRelativePath: relativePath + ) + guard let key = try ConversationAttachmentPath.key( + for: normalized, + conversationId: conversationId + ) else { + throw CloudSyncError.invalidAttachmentPath + } + return key + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+DeletionMetadata.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+DeletionMetadata.swift new file mode 100644 index 00000000..6db1fff0 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+DeletionMetadata.swift @@ -0,0 +1,235 @@ +// +// ConversationStorage+DeletionMetadata.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +// MARK: - Deletion Metadata + +extension ConversationStorage { + func loadTombstones() throws -> [ConversationTombstone] { + guard fileManager.fileExists(atPath: tombstonesURL.path) else { return [] } + let data = try Data(contentsOf: tombstonesURL) + return try SyncJSONCoding.makeDecoder().decode([ConversationTombstone].self, from: data) + } + + func saveTombstones(_ tombstones: [ConversationTombstone]) throws { + try writeDeletionDataIfChanged(SyncJSONCoding.makeEncoder().encode(tombstones), to: tombstonesURL) + } + + func loadDeleteAllMarker() throws -> ConversationDeleteAllMarker? { + guard fileManager.fileExists(atPath: deleteAllMarkerURL.path) else { return nil } + let data = try Data(contentsOf: deleteAllMarkerURL) + return try SyncJSONCoding.makeDecoder().decode(ConversationDeleteAllMarker.self, from: data) + } + + func saveDeleteAllMarker(_ marker: ConversationDeleteAllMarker) throws { + try writeDeletionDataIfChanged(SyncJSONCoding.makeEncoder().encode(marker), to: deleteAllMarkerURL) + } + + func loadLocalResetMarker() throws -> ConversationDeleteAllMarker? { + guard fileManager.fileExists(atPath: localResetMarkerURL.path) else { return nil } + let data = try Data(contentsOf: localResetMarkerURL) + return try SyncJSONCoding.makeDecoder().decode(ConversationDeleteAllMarker.self, from: data) + } + + func saveLocalResetMarker(_ marker: ConversationDeleteAllMarker) throws { + try writeDeletionDataIfChanged(SyncJSONCoding.makeEncoder().encode(marker), to: localResetMarkerURL) + } + + func newestMarker( + _ local: ConversationDeleteAllMarker?, + _ cloud: ConversationDeleteAllMarker? + ) -> ConversationDeleteAllMarker? { + [local, cloud].compactMap { $0 }.max { $0.deletedAt < $1.deletedAt } + } + + func mergedTombstones(_ adding: [ConversationTombstone]) throws -> [ConversationTombstone] { + try mergeTombstones(loadTombstones() + adding) + } + + func mergeTombstones(_ tombstones: [ConversationTombstone]) -> [ConversationTombstone] { + var latest: [UUID: ConversationTombstone] = [:] + for tombstone in tombstones { + let existingDate = latest[tombstone.conversationId]?.deletedAt ?? .distantPast + if existingDate < tombstone.deletedAt { + latest[tombstone.conversationId] = tombstone + } + } + return latest.values.sorted { + $0.conversationId.uuidString < $1.conversationId.uuidString + } + } +} + +// MARK: - Private + +extension ConversationStorage { + private func writeDeletionDataIfChanged(_ data: Data, to url: URL) throws { + if (try? Data(contentsOf: url)) != data { + try data.write(to: url, options: .atomic) + } + guard try Data(contentsOf: url) == data else { + throw CloudSyncError.invalidConversationData + } + } + + func applyLocalDelete(_ conversationId: UUID) throws { + let conversationDate = try loadLocalConversation(id: conversationId)?.updatedAt + let existingTombstoneDate = try loadTombstones() + .first(where: { $0.conversationId == conversationId })?.deletedAt + let hasNewerConversation = conversationDate.map { $0 > existingTombstoneDate ?? .distantPast } == true + if existingTombstoneDate == nil || hasNewerConversation { + let barrier = [conversationDate, existingTombstoneDate].compactMap { $0 }.max() + let tombstone = ConversationTombstone( + conversationId: conversationId, + deletedAt: nextModificationDate(after: barrier) + ) + try saveTombstones(try mergedTombstones([tombstone])) + } + try removeLocalFileIfPresent(conversationFileURL(for: conversationId)) + try removePendingMutation(conversationId: conversationId) + try attachmentRepository.deleteAll(forConversationId: conversationId) + } + + func delete( + _ conversationId: UUID, + using snapshot: ConversationCloudSyncSnapshot + ) throws { + try Task.checkCancellation() + try ensureDirectoryExists() + let tombstone = try deletionTombstone(for: conversationId, snapshot: snapshot) + let plan = try makeSynchronizationPlan( + snapshot: snapshot, + conversationId: nil, + additionalTombstones: [tombstone], + mutation: nil + ) + try Task.checkCancellation() + try commitSynchronization( + output: plan.output, + snapshot: snapshot, + resolvedPendingMutationIds: plan.resolvedPendingMutationIds, + localAttachmentKeys: plan.localAttachmentKeys, + localConflictAttachmentKeys: plan.localConflictAttachmentKeys + ) + try removeRecoveryData(conversationId: conversationId) + } + + func deleteAll(using snapshot: ConversationCloudSyncSnapshot) throws { + try Task.checkCancellation() + try ensureDirectoryExists() + let marker = try deletionMarker(snapshot: snapshot) + let plan = try makeSynchronizationPlan( + snapshot: snapshot, + conversationId: nil, + deleteAllMarkerOverride: marker, + mutation: nil + ) + try Task.checkCancellation() + try commitSynchronization( + output: plan.output, + snapshot: snapshot, + resolvedPendingMutationIds: plan.resolvedPendingMutationIds, + localAttachmentKeys: plan.localAttachmentKeys, + localConflictAttachmentKeys: plan.localConflictAttachmentKeys + ) + if fileManager.fileExists(atPath: recoveryDirectoryURL.path) { + try fileManager.removeItem(at: recoveryDirectoryURL) + } + try ensureDirectoryExists() + } + + func applyLocalDeleteAll(createCloudMarker: Bool) throws { + if createCloudMarker { + let newestConversationDate = try loadLocalConversations().map(\.updatedAt).max() + let existingMarkerDate = try loadDeleteAllMarker()?.deletedAt + let hasNewerConversation = newestConversationDate.map { + $0 > existingMarkerDate ?? .distantPast + } == true + if existingMarkerDate == nil || hasNewerConversation { + let barrier = [newestConversationDate, existingMarkerDate].compactMap { $0 }.max() + try saveDeleteAllMarker( + ConversationDeleteAllMarker(deletedAt: nextModificationDate(after: barrier)) + ) + } + } else { + let newestConversationDate = try loadLocalConversations().map(\.updatedAt).max() + let existingResetDate = try loadLocalResetMarker()?.deletedAt + try saveLocalResetMarker(ConversationDeleteAllMarker( + deletedAt: nextModificationDate(after: [ + newestConversationDate, + existingResetDate + ].compactMap { $0 }.max()) + )) + try removeLocalFileIfPresent(tombstonesURL) + try removeLocalFileIfPresent(deleteAllMarkerURL) + } + try removeAllPendingMutations() + try removeLocalFileIfPresent(directoryURL) + try attachmentRepository.deleteAll() + } + + private func deletionTombstone( + for conversationId: UUID, + snapshot: ConversationCloudSyncSnapshot + ) throws -> ConversationTombstone { + let existingTombstone = mergeTombstones(try loadTombstones() + snapshot.tombstones) + .first { $0.conversationId == conversationId } + let newestRevision = [ + try loadLocalConversation(id: conversationId)?.updatedAt, + snapshot.conversations[conversationId]?.updatedAt + ].compactMap { $0 }.max() + if let existingTombstone, + newestRevision.map({ $0 < existingTombstone.deletedAt }) ?? true { + return existingTombstone + } + let barrier = [ + newestRevision, + existingTombstone?.deletedAt, + try loadDeleteAllMarker()?.deletedAt, + snapshot.deleteAllMarker?.deletedAt + ].compactMap { $0 }.max() + return ConversationTombstone( + conversationId: conversationId, + deletedAt: nextModificationDate(after: barrier) + ) + } + + private func deletionMarker( + snapshot: ConversationCloudSyncSnapshot + ) throws -> ConversationDeleteAllMarker { + let existingMarker = newestMarker(try loadDeleteAllMarker(), snapshot.deleteAllMarker) + let newestRevision = try (loadLocalConversations().map(\.updatedAt) + + snapshot.conversations.values.map(\.updatedAt)).max() + if let existingMarker, + newestRevision.map({ $0 < existingMarker.deletedAt }) ?? true { + return existingMarker + } + return ConversationDeleteAllMarker(deletedAt: nextModificationDate(after: [ + newestRevision, + existingMarker?.deletedAt + ].compactMap { $0 }.max())) + } + + func removeRecoveryData(conversationId: UUID) throws { + guard fileManager.fileExists(atPath: recoveryDirectoryURL.path) else { return } + let urls = try fileManager.contentsOfDirectory(at: recoveryDirectoryURL, includingPropertiesForKeys: nil) + for url in urls where url.lastPathComponent.hasPrefix(conversationId.uuidString) { + try fileManager.removeItem(at: url) + } + let attachmentURL = recoveryDirectoryURL + .appendingPathComponent("Attachments", isDirectory: true) + .appendingPathComponent(conversationId.uuidString, isDirectory: true) + try removeLocalFileIfPresent(attachmentURL) + } + + func removeLocalFileIfPresent(_ url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+ImportBatch.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+ImportBatch.swift new file mode 100644 index 00000000..ed56426b --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+ImportBatch.swift @@ -0,0 +1,49 @@ +// +// ConversationStorage+ImportBatch.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +// MARK: - Import Batch + +extension ConversationStorage { + func importBatch(_ conversations: [Conversation]) throws -> [Conversation] { + try ensureDirectoryExists() + let existingIds = Set(try loadLocalConversations().map(\.id)) + let importedIds = conversations.map(\.id) + guard Set(importedIds).count == importedIds.count, + importedIds.allSatisfy({ !existingIds.contains($0) }) else { + throw CloudSyncError.invalidConversationData + } + let canonical = try conversations.map(canonicalConversation) + let attachmentKeys = try canonical.reduce(into: Set()) { keys, item in + keys.formUnion(try self.attachmentKeys(in: [item.conversation])) + } + let verification = ConversationLocalTransaction.Verification( + conversations: Dictionary(uniqueKeysWithValues: canonical.map { ($0.conversation.id, $0.conversation) }), + attachments: canonical.reduce(into: [CloudAttachmentKey: Data]()) { data, item in + data.merge(item.attachmentData) { _, imported in imported } + } + ) + let transaction = try ConversationLocalTransaction( + fileManager: fileManager, + documentsURL: documentsURL, + attachmentKeys: attachmentKeys + ) + do { + for item in canonical { + try persistLocalAttachments(item.attachmentData) + try saveLocal(item.conversation) + } + try transaction.commit(verifying: verification) + } catch { + try transaction.rollback() + throw error + } + return canonical.map(\.conversation) + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+LocalPersistence.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+LocalPersistence.swift new file mode 100644 index 00000000..a9c855e9 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+LocalPersistence.swift @@ -0,0 +1,468 @@ +// +// ConversationStorage+LocalPersistence.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import CryptoKit +import Foundation + +// MARK: - Local Persistence + +extension ConversationStorage { + func makeEncoder() -> JSONEncoder { + SyncJSONCoding.makeEncoder() + } + + func makeDecoder() -> JSONDecoder { + SyncJSONCoding.makeDecoder() + } + + func ensureDirectoryExists() throws { + try ConversationLocalTransaction.recoverPendingTransactions( + fileManager: fileManager, + documentsURL: documentsURL + ) + guard !fileManager.fileExists(atPath: directoryURL.path) else { return } + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } + + func loadLocalConversations() throws -> [Conversation] { + Array(try loadLocalConversationFiles().values.values) + } + + func loadLocalConversationFiles() throws -> ConversationFiles { + let fileURLs = try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + var files = ConversationFiles() + for url in fileURLs where url.pathExtension == "json" { + let data = try Data(contentsOf: url) + let conversation = try makeDecoder().decode(Conversation.self, from: data) + try conversation.validateContextMetadata() + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) == conversation.id, + files.values[conversation.id] == nil else { + throw CloudSyncError.invalidConversationData + } + files.values[conversation.id] = conversation + files.data[conversation.id] = data + } + return files + } + + func loadLocalConversation(id: UUID) throws -> Conversation? { + let url = conversationFileURL(for: id) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let conversation = try makeDecoder().decode(Conversation.self, from: Data(contentsOf: url)) + try conversation.validateContextMetadata() + guard conversation.id == id else { throw CloudSyncError.invalidConversationData } + return conversation + } + + func saveLocal(_ conversation: Conversation) throws { + try writeIfChanged( + makeEncoder().encode(conversation), + to: conversationFileURL(for: conversation.id) + ) + } + + func persistLocal(output: ConversationCloudSyncOutput) throws { + for conversation in output.conversations { + guard let data = output.conversationData[conversation.id] else { + throw CloudSyncError.invalidConversationData + } + let url = conversationFileURL(for: conversation.id) + try writeIfChanged(data, to: url) + guard try makeDecoder().decode(Conversation.self, from: Data(contentsOf: url)) == conversation else { + throw CloudSyncError.invalidConversationData + } + } + try saveTombstones(output.tombstones) + try cleanupLocalFiles(keeping: Set(output.conversations.map(\.id))) + } + + func commitSynchronization( + output: ConversationCloudSyncOutput, + snapshot: ConversationCloudSyncSnapshot, + resolvedPendingMutationIds: Set, + localAttachmentKeys: Set, + localConflictAttachmentKeys: Set + ) throws { + try cloudSyncManager.validateConversationSyncOutput(output, basedOn: snapshot) + let knownAttachmentKeys = Set(snapshot.attachmentData.keys) + .union(snapshot.attachmentPlaceholders) + .union(localAttachmentKeys) + let isLocalCurrent = try isLocalOutputCurrent( + output, + knownAttachmentKeys: knownAttachmentKeys + ) && resolvedPendingMutationIds.isEmpty + if isLocalCurrent { + guard !isCloudOutputCurrent(output, snapshot: snapshot) else { return } + try Task.checkCancellation() + try cloudSyncManager.applyConversationSyncOutput(output, basedOn: snapshot) + return + } + try commitSynchronizationTransaction( + output: output, + snapshot: snapshot, + resolvedPendingMutationIds: resolvedPendingMutationIds, + knownAttachmentKeys: knownAttachmentKeys, + localConflictAttachmentKeys: localConflictAttachmentKeys + ) + } + + func commitSynchronizationTransaction( + output: ConversationCloudSyncOutput, + snapshot: ConversationCloudSyncSnapshot, + resolvedPendingMutationIds: Set, + knownAttachmentKeys: Set, + localConflictAttachmentKeys: Set + ) throws { + let transaction = try ConversationLocalTransaction( + fileManager: fileManager, + documentsURL: documentsURL, + attachmentKeys: knownAttachmentKeys.union(output.attachments.keys) + ) + do { + try persistLocalAttachments(output.attachments) + try persistLocal(output: output) + try cleanupLocalAttachments( + removing: knownAttachmentKeys, + keeping: Set(output.attachments.keys), + preserving: localConflictAttachmentKeys + ) + if let marker = output.deleteAllMarker { + try saveDeleteAllMarker(marker) + } + for id in resolvedPendingMutationIds { + try removePendingMutation(conversationId: id) + } + try Task.checkCancellation() + try cloudSyncManager.applyConversationSyncOutput(output, basedOn: snapshot) + try transaction.commit(verifying: .init( + conversations: Dictionary(uniqueKeysWithValues: output.conversations.map { ($0.id, $0) }), + attachments: output.attachments, + absentPendingMutationIds: resolvedPendingMutationIds, + absentAttachmentKeys: knownAttachmentKeys.subtracting(output.attachments.keys), + exactConversationSet: true, + emptyPendingMutations: true + )) + } catch { + do { + try transaction.rollback() + } catch let rollbackError { + throw rollbackError + } + throw error + } + } + + func localAttachmentData(for key: CloudAttachmentKey) throws -> Data? { + let relativePath = ConversationAttachmentPath.relativePath(for: key) + let url = try attachmentFileResolver().resolve(relativePath: relativePath) + guard fileManager.fileExists(atPath: url.path) else { return nil } + return try Data(contentsOf: url) + } + + func loadLocalAttachmentFiles() throws -> [CloudAttachmentKey: Data] { + let resolver = attachmentFileResolver() + let root = try resolver.attachmentRoot() + guard fileManager.fileExists(atPath: root.path) else { return [:] } + let folders = try fileManager.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + var result: [CloudAttachmentKey: Data] = [:] + for folder in folders { + let values = try folder.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, + values.isSymbolicLink != true, + let conversationId = UUID(uuidString: folder.lastPathComponent) else { continue } + let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) + for file in files { + let fileValues = try file.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard fileValues.isDirectory != true else { continue } + guard fileValues.isSymbolicLink != true else { throw CloudSyncError.invalidAttachmentPath } + let key = CloudAttachmentKey(conversationId: conversationId, fileName: file.lastPathComponent) + let relativePath = ConversationAttachmentPath.relativePath(for: key) + result[key] = try Data(contentsOf: resolver.resolve(relativePath: relativePath)) + } + } + return result + } + + func persistLocalAttachments(_ attachments: [CloudAttachmentKey: Data]) throws { + for (key, data) in attachments { + let resolver = attachmentFileResolver() + let relativePath = ConversationAttachmentPath.relativePath(for: key) + let url = try resolver.resolve(relativePath: relativePath) + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let resolvedURL = try resolver.resolve(relativePath: relativePath) + try writeIfChanged(data, to: resolvedURL) + guard try Data(contentsOf: resolvedURL) == data else { + throw CloudSyncError.missingAttachment + } + } + } + + func persistLocalMutation( + _ conversation: Conversation, + replacing previous: Conversation, + pendingBase: Conversation?, + replacePendingBase: Bool = false, + attachmentData: [CloudAttachmentKey: Data] + ) throws { + let conversation = try canonicalJSONConversation(conversation) + let pendingBase = try pendingBase.map(canonicalJSONConversation) + let previousKeys = try storedAttachmentKeys(in: previous) + let retainedKeys = try attachmentKeys(in: [conversation]) + var expectedAttachments = try localAttachmentData(for: retainedKeys) + for (key, data) in attachmentData { + if let existing = expectedAttachments[key], existing != data { + throw CloudSyncError.invalidConversationData + } + expectedAttachments[key] = data + } + let transaction = try ConversationLocalTransaction( + fileManager: fileManager, + documentsURL: documentsURL, + attachmentKeys: previousKeys.union(retainedKeys) + ) + do { + try persistLocalAttachments(attachmentData) + if let pendingBase { + if replacePendingBase { + try replacePendingMutationBase(pendingBase) + } else { + try savePendingMutationBase(pendingBase) + } + } + try saveLocal(conversation) + try removeLocalAttachments(previousKeys.subtracting(retainedKeys)) + let expectedPendingBase: Conversation? + if let pendingBase { + expectedPendingBase = pendingBase + } else { + expectedPendingBase = try loadPendingMutationBase(conversationId: conversation.id) + } + try transaction.commit(verifying: .init( + conversations: [conversation.id: conversation], + pendingMutationBases: expectedPendingBase.map { [conversation.id: $0] } ?? [:], + attachments: expectedAttachments, + absentAttachmentKeys: previousKeys.subtracting(retainedKeys) + )) + } catch { + try transaction.rollback() + throw error + } + } + + func persistNewLocalConversation( + _ conversation: Conversation, + attachmentData: [CloudAttachmentKey: Data] + ) throws { + let conversation = try canonicalJSONConversation(conversation) + let attachmentKeys = try attachmentKeys(in: [conversation]) + var expectedAttachments = try localAttachmentData(for: attachmentKeys) + for (key, data) in attachmentData { + if let existing = expectedAttachments[key], existing != data { + throw CloudSyncError.invalidConversationData + } + expectedAttachments[key] = data + } + let transaction = try ConversationLocalTransaction( + fileManager: fileManager, + documentsURL: documentsURL, + attachmentKeys: attachmentKeys + ) + do { + try persistLocalAttachments(attachmentData) + try saveLocal(conversation) + try transaction.commit(verifying: .init( + conversations: [conversation.id: conversation], + attachments: expectedAttachments + )) + } catch { + try transaction.rollback() + throw error + } + } + + func removeLocalAttachments(_ keys: Set) throws { + let resolver = attachmentFileResolver() + for key in keys { + let relativePath = ConversationAttachmentPath.relativePath(for: key) + let url = try resolver.resolve(relativePath: relativePath) + guard fileManager.fileExists(atPath: url.path) else { continue } + try preserveAttachmentForRecovery(Data(contentsOf: url), key: key) + try fileManager.removeItem(at: url) + let directory = try resolver.conversationDirectory(key.conversationId) + if try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).isEmpty { + try fileManager.removeItem(at: directory) + } + } + } + + func localAttachmentData(for keys: Set) throws -> [CloudAttachmentKey: Data] { + var result: [CloudAttachmentKey: Data] = [:] + for key in keys { + if let data = try localAttachmentData(for: key) { + result[key] = data + } + } + return result + } + + func canonicalJSONConversation(_ conversation: Conversation) throws -> Conversation { + try makeDecoder().decode(Conversation.self, from: makeEncoder().encode(conversation)) + } + + func cleanupLocalFiles(keeping ids: Set) throws { + let fileURLs = try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + for url in fileURLs where url.pathExtension == "json" { + guard let uuid = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { continue } + if !ids.contains(uuid) { + try fileManager.removeItem(at: url) + } + } + } + + func cleanupLocalAttachments( + removing knownKeys: Set, + keeping retainedKeys: Set, + preserving recoveryKeys: Set + ) throws { + let resolver = attachmentFileResolver() + let root = try resolver.attachmentRoot() + guard fileManager.fileExists(atPath: root.path) else { return } + let folders = try fileManager.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + for folder in folders { + let values = try folder.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, + values.isSymbolicLink != true, + let conversationId = UUID(uuidString: folder.lastPathComponent) else { continue } + _ = try resolver.conversationDirectory(conversationId) + let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) + for file in files { + let fileValues = try file.resourceValues(forKeys: [.isSymbolicLinkKey]) + guard fileValues.isSymbolicLink != true else { throw CloudSyncError.invalidAttachmentPath } + let key = CloudAttachmentKey(conversationId: conversationId, fileName: file.lastPathComponent) + guard knownKeys.contains(key), !retainedKeys.contains(key) else { continue } + let resolvedFile = try resolver.resolve(relativePath: ConversationAttachmentPath.relativePath(for: key)) + if recoveryKeys.contains(key) { + let data = try Data(contentsOf: resolvedFile) + try preserveAttachmentForRecovery(data, key: key) + } + try fileManager.removeItem(at: resolvedFile) + } + if try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil).isEmpty { + try fileManager.removeItem(at: folder) + } + } + } + + func preserveRemovedCloudAttachments( + output: ConversationCloudSyncOutput, + snapshot: ConversationCloudSyncSnapshot, + tombstones: [ConversationTombstone], + marker: ConversationDeleteAllMarker? + ) throws { + let outputById = Dictionary(uniqueKeysWithValues: output.conversations.map { ($0.id, $0) }) + for cloudConversation in snapshot.conversations.values where shouldKeep( + cloudConversation, + tombstones: tombstones, + marker: marker + ) { + guard let winner = outputById[cloudConversation.id], winner != cloudConversation else { continue } + let removedKeys = try attachmentKeys(in: [cloudConversation]) + .subtracting(attachmentKeys(in: [winner])) + if !removedKeys.isDisjoint(with: snapshot.attachmentPlaceholders) { + throw CloudSyncError.requiredDownloadPending + } + for key in removedKeys { + guard let data = snapshot.attachmentData[key] else { continue } + try preserveAttachmentForRecovery(data, key: key) + } + } + } + + func preserveForRecovery(_ data: Data, conversationId: UUID) throws { + try fileManager.createDirectory(at: recoveryDirectoryURL, withIntermediateDirectories: true) + let digest = recoveryDigest(for: data) + let url = recoveryDirectoryURL.appendingPathComponent("\(conversationId.uuidString)-\(digest).json") + try writeIfChanged(data, to: url) + guard try Data(contentsOf: url) == data else { + throw CloudSyncError.invalidConversationData + } + } + + func preserveAttachmentForRecovery(_ data: Data, key: CloudAttachmentKey) throws { + let directory = recoveryDirectoryURL + .appendingPathComponent("Attachments", isDirectory: true) + .appendingPathComponent(key.conversationId.uuidString, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("\(recoveryDigest(for: data))-\(key.fileName)") + try writeIfChanged(data, to: url) + guard try Data(contentsOf: url) == data else { + throw CloudSyncError.missingAttachment + } + } + + func conversationFileURL(for id: UUID) -> URL { + directoryURL.appendingPathComponent("\(id.uuidString).json") + } + + func writeIfChanged(_ data: Data, to url: URL) throws { + if let existing = try? Data(contentsOf: url), existing == data { return } + try data.write(to: url, options: .atomic) + } + + func isLocalOutputCurrent( + _ output: ConversationCloudSyncOutput, + knownAttachmentKeys: Set + ) throws -> Bool { + guard try loadLocalConversationFiles().data == output.conversationData, + try loadTombstones() == output.tombstones, + try loadDeleteAllMarker() == output.deleteAllMarker else { + return false + } + for key in knownAttachmentKeys { + let localData = try localAttachmentData(for: key) + if localData != output.attachments[key] { return false } + } + return true + } + + func isCloudOutputCurrent( + _ output: ConversationCloudSyncOutput, + snapshot: ConversationCloudSyncSnapshot + ) -> Bool { + guard snapshot.manifestData != nil, + snapshot.conversationData == output.conversationData, + snapshot.attachmentData == output.attachments, + snapshot.attachmentPlaceholders.isEmpty, + snapshot.deleteAllMarker == output.deleteAllMarker else { + return false + } + let encoder = makeEncoder() + guard let tombstoneData = try? Dictionary(uniqueKeysWithValues: output.tombstones.map { + ($0.conversationId, try encoder.encode($0)) + }) else { + return false + } + return snapshot.tombstoneData == tombstoneData + } + + private func recoveryDigest(for data: Data) -> String { + SHA256.hash(data: data).prefix(8).map { String(format: "%02x", $0) }.joined() + } + + private func attachmentFileResolver() -> AttachmentFileResolver { + AttachmentFileResolver(fileManager: fileManager, baseURL: documentsURL) + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Merge.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Merge.swift new file mode 100644 index 00000000..83451141 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Merge.swift @@ -0,0 +1,191 @@ +// +// ConversationStorage+Merge.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +// MARK: - Merge + +extension ConversationStorage { + func shouldKeep( + _ conversation: Conversation, + tombstones: [ConversationTombstone], + marker: ConversationDeleteAllMarker? + ) -> Bool { + let tombstoneDate = tombstones + .filter { $0.conversationId == conversation.id } + .map(\.deletedAt) + .max() + if let tombstoneDate, conversation.updatedAt <= tombstoneDate { + return false + } + if let marker, conversation.updatedAt <= marker.deletedAt { + return false + } + return true + } + + struct MergeContext { + let local: ConversationFiles + let snapshot: ConversationCloudSyncSnapshot + let tombstones: [ConversationTombstone] + let deleteAllMarker: ConversationDeleteAllMarker? + let pendingMutationBases: [UUID: Conversation] + let localAttachmentData: [CloudAttachmentKey: Data] + } + + struct PendingMergeContext { + let localCandidate: MergedConversation? + let cloudCandidate: MergedConversation? + let localConversation: Conversation + let localData: Data + let pendingBase: Conversation + let mergeContext: MergeContext + } + + func mergeConversations(context: MergeContext) throws -> [MergedConversation] { + let ids = Set(context.local.values.keys).union(context.snapshot.conversations.keys) + return try ids.compactMap { try mergedConversation(id: $0, context: context) } + } + + func mergedConversation(id: UUID, context: MergeContext) throws -> MergedConversation? { + let localCandidate = try localCandidate(id: id, context: context) + let cloudCandidate = try cloudCandidate(id: id, context: context) + if let pendingBase = context.pendingMutationBases[id], + let localConversation = context.local.values[id], + let localData = context.local.data[id] { + return try mergePendingConversation(context: PendingMergeContext( + localCandidate: localCandidate, + cloudCandidate: cloudCandidate, + localConversation: localConversation, + localData: localData, + pendingBase: pendingBase, + mergeContext: context + )) + } + if let localCandidate, let cloudCandidate { + return try preferredConversation(local: localCandidate, cloud: cloudCandidate) + } + return localCandidate ?? cloudCandidate + } + + func mergePendingConversation(context: PendingMergeContext) throws -> MergedConversation? { + guard shouldKeep( + context.pendingBase, + tombstones: context.mergeContext.tombstones, + marker: context.mergeContext.deleteAllMarker + ) else { + try preservePendingConversationForRecovery( + context.localConversation, + data: context.localData, + attachmentData: context.mergeContext.localAttachmentData + ) + return context.cloudCandidate + } + guard let localCandidate = context.localCandidate else { + throw CloudSyncError.invalidConversationData + } + do { + return try rebasedPendingConversation( + local: localCandidate, + localConversation: context.localConversation, + base: context.pendingBase, + cloud: context.cloudCandidate + ) + } catch { + try preserveForRecovery(context.localData, conversationId: context.localConversation.id) + if let cloudCandidate = context.cloudCandidate { + try preserveForRecovery(cloudCandidate.data, conversationId: context.localConversation.id) + } + throw error + } + } + + func localCandidate(id: UUID, context: MergeContext) throws -> MergedConversation? { + guard let conversation = context.local.values[id], + context.pendingMutationBases[id] != nil || shouldKeep( + conversation, + tombstones: context.tombstones, + marker: context.deleteAllMarker + ) else { + return nil + } + guard let data = context.local.data[id] else { throw CloudSyncError.invalidConversationData } + return try makeMergedConversation( + conversation: conversation, + data: data, + source: .local, + normalizationContext: AttachmentNormalizationContext( + sourceData: context.localAttachmentData, + sourcePlaceholders: [], + knownConversationIds: Set(context.local.values.keys) + ) + ) + } + + func cloudCandidate(id: UUID, context: MergeContext) throws -> MergedConversation? { + guard let conversation = context.snapshot.conversations[id], + shouldKeep( + conversation, + tombstones: context.tombstones, + marker: context.deleteAllMarker + ) else { + return nil + } + guard let data = context.snapshot.conversationData[id] else { + throw CloudSyncError.invalidConversationData + } + return try makeMergedConversation( + conversation: conversation, + data: data, + source: .cloud, + normalizationContext: AttachmentNormalizationContext( + sourceData: context.snapshot.attachmentData, + sourcePlaceholders: context.snapshot.attachmentPlaceholders, + knownConversationIds: Set(context.snapshot.conversations.keys) + ) + ) + } + + func rebasedPendingConversation( + local: MergedConversation, + localConversation: Conversation, + base: Conversation, + cloud: MergedConversation? + ) throws -> MergedConversation { + let current = cloud?.conversation ?? base + var conversation = try rebasedConversation(localConversation, base: base, onto: current) + guard conversation != current else { return cloud ?? local } + let latestDate = [localConversation.updatedAt, current.updatedAt, base.updatedAt].max() + conversation.updatedAt = nextModificationDate(after: latestDate) + let data = try makeEncoder().encode(conversation) + let canonicalConversation = try makeDecoder().decode(Conversation.self, from: data) + if let cloud, cloud.data != data { + try preserveForRecovery(cloud.data, conversationId: conversation.id) + } + return MergedConversation( + conversation: canonicalConversation, + data: data, + source: .local, + localInlineAttachmentData: local.localInlineAttachmentData, + cloudInlineAttachmentData: cloud?.cloudInlineAttachmentData ?? [:] + ) + } + + func preservePendingConversationForRecovery( + _ conversation: Conversation, + data: Data, + attachmentData: [CloudAttachmentKey: Data] + ) throws { + try preserveForRecovery(data, conversationId: conversation.id) + for key in try storedAttachmentKeys(in: conversation) { + if let data = attachmentData[key] { + try preserveAttachmentForRecovery(data, key: key) + } + } + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Mutations.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Mutations.swift new file mode 100644 index 00000000..ecaa535a --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+Mutations.swift @@ -0,0 +1,452 @@ +// +// ConversationStorage+Mutations.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +// MARK: - Mutations + +extension ConversationStorage { + func saveSynchronizing( + _ conversation: Conversation, + expectedBase: Conversation?, + synchronize: Bool, + preservePendingBase: Bool = false + ) throws -> Conversation? { + try ensureDirectoryExists() + let canonicalIncoming = try canonicalConversation(conversation) + let incoming = canonicalIncoming.conversation + let expectedBase = try expectedBase.map { + try canonicalConversation($0).conversation + } + let latestLocal = try loadLocalConversation(id: incoming.id).map { + try canonicalConversation($0).conversation + } + try validateLocalSaveBase(expectedBase, latestLocal: latestLocal, conversationId: incoming.id) + guard let latestLocal else { + return try saveNewConversation( + incoming, + attachmentData: canonicalIncoming.attachmentData, + synchronize: synchronize && !Task.isCancelled + ) + } + let base = expectedBase ?? latestLocal + if Task.isCancelled || preservePendingBase { + return try saveWithoutCloudIfAllowed( + incoming, + base: base, + latestLocal: latestLocal, + attachmentData: canonicalIncoming.attachmentData + ) + } + guard synchronize else { + return try saveRebasedLocally( + incoming, + base: base, + latestLocal: latestLocal, + attachmentData: canonicalIncoming.attachmentData + ) + } + guard cloudSyncManager.isCloudAvailable() else { + return try saveWithoutCloudIfAllowed( + incoming, + base: base, + latestLocal: latestLocal, + attachmentData: canonicalIncoming.attachmentData + ) + } + return try saveAgainstCloud( + incoming, + base: base, + latestLocal: latestLocal, + attachmentData: canonicalIncoming.attachmentData + ) + } + + @discardableResult + func setPinned( + _ isPinned: Bool, + conversationId: UUID, + synchronize: Bool + ) throws -> Conversation? { + try mutate(conversationId, synchronize: synchronize) { conversation, _ in + conversation.isPinned = isPinned + } + } + + @discardableResult + func rename( + _ conversationId: UUID, + title: String, + synchronize: Bool + ) throws -> Conversation? { + try mutate(conversationId, synchronize: synchronize) { conversation, _ in + conversation.title = title.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + @discardableResult + func updateTags( + _ conversationId: UUID, + tags: [ConversationTag], + synchronize: Bool + ) throws -> Conversation? { + try mutate(conversationId, synchronize: synchronize) { conversation, allConversations in + let colorsByName = allConversations.flatMap(\.tags).reduce(into: [String: TagColor]()) { colors, tag in + if colors[tag.name] == nil { + colors[tag.name] = tag.color + } + } + var tagNames = Set() + conversation.tags = tags.compactMap { tag in + let name = tag.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, tagNames.insert(name).inserted else { return nil } + return ConversationTag(name: name, color: colorsByName[name] ?? tag.color) + } + } + } + + func mutateMergedConversation( + conversationId: UUID?, + merged: inout [MergedConversation], + attachmentData: [CloudAttachmentKey: Data] = [:], + mutation: ((inout Conversation, [Conversation]) throws -> Void)? + ) throws -> Conversation? { + guard let conversationId, + let mutation, + let index = merged.firstIndex(where: { $0.conversation.id == conversationId }) else { + return nil + } + let original = merged[index] + var conversation = original.conversation + try mutation(&conversation, merged.map(\.conversation)) + guard conversation != original.conversation else { return conversation } + conversation.updatedAt = nextModificationDate(after: original.conversation.updatedAt) + try preserveForRecovery(original.data, conversationId: conversationId) + let data = try makeEncoder().encode(conversation) + let canonicalConversation = try makeDecoder().decode(Conversation.self, from: data) + merged[index] = MergedConversation( + conversation: canonicalConversation, + data: data, + source: original.source, + localInlineAttachmentData: original.localInlineAttachmentData.merging(attachmentData) { _, new in new }, + cloudInlineAttachmentData: original.cloudInlineAttachmentData + ) + return canonicalConversation + } +} + +// MARK: - Private + +extension ConversationStorage { + func saveWithoutCloudIfAllowed( + _ conversation: Conversation, + base: Conversation, + latestLocal: Conversation, + attachmentData: [CloudAttachmentKey: Data] + ) throws -> Conversation? { + let pendingBase = try loadPendingMutationBase(conversationId: conversation.id) ?? base + var rebased = try rebasedConversation(conversation, base: base, onto: latestLocal) + guard rebased != latestLocal else { return latestLocal } + rebased.updatedAt = nextModificationDate(after: latestLocal.updatedAt) + try persistLocalMutation( + rebased, + replacing: latestLocal, + pendingBase: pendingBase, + attachmentData: attachmentData + ) + return try loadLocalConversation(id: rebased.id) + } + + func saveRebasedLocally( + _ conversation: Conversation, + base: Conversation, + latestLocal: Conversation, + attachmentData: [CloudAttachmentKey: Data] + ) throws -> Conversation? { + var rebased = try rebasedConversation(conversation, base: base, onto: latestLocal) + guard rebased != latestLocal else { return latestLocal } + rebased.updatedAt = nextModificationDate(after: latestLocal.updatedAt) + try persistLocalMutation( + rebased, + replacing: latestLocal, + pendingBase: nil, + attachmentData: attachmentData + ) + return try loadLocalConversation(id: rebased.id) + } + + func saveNewConversation( + _ conversation: Conversation, + attachmentData: [CloudAttachmentKey: Data], + synchronize: Bool + ) throws -> Conversation? { + if let localBarrier = try deletionBarrier(for: conversation.id) { + guard conversation.updatedAt > localBarrier else { + throw CloudSyncError.staleConversationRevision + } + } + guard synchronize, cloudSyncManager.isCloudAvailable() else { + try persistNewLocalConversation(conversation, attachmentData: attachmentData) + return try loadLocalConversation(id: conversation.id) + } + do { + let snapshot = try cloudSyncManager.loadConversationSyncSnapshot() + let tombstoneDate = snapshot.tombstones + .filter { $0.conversationId == conversation.id } + .map(\.deletedAt) + .max() + let cloudBarrier = [tombstoneDate, snapshot.deleteAllMarker?.deletedAt] + .compactMap { $0 } + .max() + if let cloudBarrier { + guard conversation.updatedAt > cloudBarrier else { + throw CloudSyncError.staleConversationRevision + } + } + try persistNewLocalConversation(conversation, attachmentData: attachmentData) + try self.synchronize(with: snapshot) + } catch CloudSyncError.staleConversationRevision { + throw CloudSyncError.staleConversationRevision + } catch { + try persistNewLocalConversation(conversation, attachmentData: attachmentData) + LogManager.error("New conversation cloud synchronization deferred") + } + guard let saved = try loadLocalConversation(id: conversation.id) else { + throw CloudSyncError.staleConversationRevision + } + return saved + } + + func saveAgainstCloud( + _ conversation: Conversation, + base: Conversation, + latestLocal: Conversation, + attachmentData: [CloudAttachmentKey: Data] + ) throws -> Conversation? { + for attempt in 0..<2 { + do { + let snapshot = try cloudSyncManager.loadConversationSyncSnapshot() + try validateSaveBase(base, against: snapshot) + let saved = try self.synchronize( + with: snapshot, + conversationId: conversation.id, + mutationAttachmentData: attachmentData + ) { current, _ in + current = try self.rebasedConversation(conversation, base: base, onto: current) + } + guard let saved else { throw CloudSyncError.staleConversationRevision } + return saved + } catch CloudSyncError.cloudContentChanged where attempt == 0 { + continue + } catch CloudSyncError.staleConversationRevision { + throw CloudSyncError.staleConversationRevision + } catch { + return try saveWithoutCloudIfAllowed( + conversation, + base: base, + latestLocal: latestLocal, + attachmentData: attachmentData + ) + } + } + return try saveWithoutCloudIfAllowed( + conversation, + base: base, + latestLocal: latestLocal, + attachmentData: attachmentData + ) + } + + func validateLocalSaveBase( + _ expectedBase: Conversation?, + latestLocal: Conversation?, + conversationId: UUID + ) throws { + guard let expectedBase else { return } + guard expectedBase.id == conversationId else { + throw CloudSyncError.staleConversationRevision + } + guard latestLocal == nil else { return } + guard try deletionBarrier(for: conversationId) == nil else { + throw CloudSyncError.staleConversationRevision + } + } + + func validateSaveBase( + _ base: Conversation, + against snapshot: ConversationCloudSyncSnapshot + ) throws { + let tombstoneDate = snapshot.tombstones + .filter { $0.conversationId == base.id } + .map(\.deletedAt) + .max() + let barrier = [tombstoneDate, snapshot.deleteAllMarker?.deletedAt] + .compactMap { $0 } + .max() + if let barrier, base.updatedAt <= barrier { + throw CloudSyncError.staleConversationRevision + } + } + + func canonicalConversation( + _ conversation: Conversation + ) throws -> (conversation: Conversation, attachmentData: [CloudAttachmentKey: Data]) { + var conversation = conversation + let localAttachmentData = try loadLocalAttachmentFiles() + let knownConversationIds = Set(try loadLocalConversations().map(\.id)) + let normalizedAttachmentData = try normalizeAttachments( + in: &conversation, + context: AttachmentNormalizationContext( + sourceData: localAttachmentData, + sourcePlaceholders: [], + knownConversationIds: knownConversationIds + ) + ) + let requiredAttachmentKeys = try attachmentKeys(in: [conversation]) + guard requiredAttachmentKeys.allSatisfy({ + normalizedAttachmentData[$0] != nil || localAttachmentData[$0] != nil + }) else { + throw CloudSyncError.missingAttachment + } + for (key, data) in normalizedAttachmentData { + if let existing = localAttachmentData[key], existing != data { + throw CloudSyncError.invalidConversationData + } + } + let attachmentData = normalizedAttachmentData.filter { localAttachmentData[$0.key] == nil } + let data = try makeEncoder().encode(conversation) + return (try makeDecoder().decode(Conversation.self, from: data), attachmentData) + } + + func rebasedConversation( + _ incoming: Conversation, + base: Conversation, + onto current: Conversation + ) throws -> Conversation { + try ConversationRebaser.rebase(incoming, base: base, onto: current) + } + + func mutate( + _ conversationId: UUID, + synchronize: Bool, + mutation: @escaping (inout Conversation, [Conversation]) -> Void + ) throws -> Conversation? { + try Task.checkCancellation() + try ensureDirectoryExists() + guard synchronize else { + return try mutateLocalConversation(conversationId, mutation: mutation) + } + guard cloudSyncManager.isCloudAvailable() else { + return try mutatePendingLocalConversation(conversationId, mutation: mutation) + } + for attempt in 0..<2 { + do { + try Task.checkCancellation() + let snapshot = try cloudSyncManager.loadConversationSyncSnapshot() + return try self.synchronize( + with: snapshot, + conversationId: conversationId, + mutation: mutation + ) + } catch CloudSyncError.cloudContentChanged where attempt == 0 { + continue + } catch CloudSyncError.staleConversationRevision { + throw CloudSyncError.staleConversationRevision + } catch { + return try mutatePendingLocalConversation(conversationId, mutation: mutation) + } + } + return try mutatePendingLocalConversation(conversationId, mutation: mutation) + } + + func mutateLocalConversation( + _ conversationId: UUID, + mutation: (inout Conversation, [Conversation]) -> Void + ) throws -> Conversation? { + let conversations = try loadLocalConversations() + guard let storedConversation = conversations.first(where: { $0.id == conversationId }) else { return nil } + let canonical = try canonicalConversation(storedConversation) + var conversation = canonical.conversation + let original = conversation + mutation(&conversation, conversations) + let didMutate = conversation != original + guard didMutate || !canonical.attachmentData.isEmpty else { return conversation } + if didMutate { + let barrier = try [conversation.updatedAt, deletionBarrier(for: conversationId)] + .compactMap { $0 } + .max() + conversation.updatedAt = nextModificationDate(after: barrier) + } + try persistLocalMutation( + conversation, + replacing: original, + pendingBase: nil, + attachmentData: canonical.attachmentData + ) + return try loadLocalConversation(id: conversationId) + } + + func mutatePendingLocalConversation( + _ conversationId: UUID, + mutation: (inout Conversation, [Conversation]) -> Void + ) throws -> Conversation? { + let conversations = try loadLocalConversations() + guard let storedConversation = conversations.first(where: { $0.id == conversationId }) else { return nil } + let canonical = try canonicalConversation(storedConversation) + var conversation = canonical.conversation + let storedPendingBase = try loadPendingMutationBase(conversationId: conversationId) + let pendingCanonical = try storedPendingBase.map(canonicalConversation) + let pendingBase = pendingCanonical?.conversation ?? conversation + var attachmentData = canonical.attachmentData + if let pendingCanonical { + for (key, data) in pendingCanonical.attachmentData { + if let existing = attachmentData[key], existing != data { + throw CloudSyncError.invalidConversationData + } + attachmentData[key] = data + } + } + let original = conversation + mutation(&conversation, conversations) + let didMutate = conversation != original + let shouldReplacePendingBase = storedPendingBase != nil + && storedPendingBase != pendingCanonical?.conversation + guard didMutate || !attachmentData.isEmpty || shouldReplacePendingBase else { return conversation } + if didMutate { + conversation.updatedAt = nextModificationDate(after: original.updatedAt) + } + try persistLocalMutation( + conversation, + replacing: original, + pendingBase: didMutate ? pendingBase : pendingCanonical?.conversation, + replacePendingBase: shouldReplacePendingBase, + attachmentData: attachmentData + ) + return try loadLocalConversation(id: conversationId) + } + + func deletionBarrier(for conversationId: UUID) throws -> Date? { + let tombstoneDate = try loadTombstones() + .filter { $0.conversationId == conversationId } + .map(\.deletedAt) + .max() + let markerDate = try loadDeleteAllMarker()?.deletedAt + let localResetDate = try loadLocalResetMarker()?.deletedAt + return [tombstoneDate, markerDate, localResetDate].compactMap { $0 }.max() + } + + func nextModificationDate(after date: Date?) -> Date { + guard let date else { return Date() } + return max(Date(), date.addingTimeInterval(0.000001)) + } + + func modificationDate(requested: Date, after barrier: Date?) -> Date { + guard let barrier, requested <= barrier else { return requested } + return barrier.addingTimeInterval(0.000001) + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+PendingMutations.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+PendingMutations.swift new file mode 100644 index 00000000..4fa643e7 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage+PendingMutations.swift @@ -0,0 +1,78 @@ +// +// ConversationStorage+PendingMutations.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +// MARK: - Pending Mutations + +extension ConversationStorage { + func loadPendingMutationBases() throws -> [UUID: Conversation] { + guard fileManager.fileExists(atPath: pendingMutationsURL.path) else { return [:] } + let urls = try fileManager.contentsOfDirectory( + at: pendingMutationsURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + var result: [UUID: Conversation] = [:] + for url in urls where url.pathExtension == "json" { + let data = try Data(contentsOf: url) + let conversation = try makeDecoder().decode(Conversation.self, from: data) + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) == conversation.id, + result[conversation.id] == nil else { + throw CloudSyncError.invalidConversationData + } + result[conversation.id] = conversation + } + return result + } + + func loadPendingMutationBase(conversationId: UUID) throws -> Conversation? { + let url = pendingMutationURL(conversationId: conversationId) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let conversation = try makeDecoder().decode(Conversation.self, from: Data(contentsOf: url)) + guard conversation.id == conversationId else { + throw CloudSyncError.invalidConversationData + } + return conversation + } + + func savePendingMutationBase(_ conversation: Conversation) throws { + guard try loadPendingMutationBase(conversationId: conversation.id) == nil else { return } + try fileManager.createDirectory(at: pendingMutationsURL, withIntermediateDirectories: true) + try writeIfChanged( + makeEncoder().encode(conversation), + to: pendingMutationURL(conversationId: conversation.id) + ) + } + + func replacePendingMutationBase(_ conversation: Conversation) throws { + try fileManager.createDirectory(at: pendingMutationsURL, withIntermediateDirectories: true) + try writeIfChanged( + makeEncoder().encode(conversation), + to: pendingMutationURL(conversationId: conversation.id) + ) + } + + func removePendingMutation(conversationId: UUID) throws { + let url = pendingMutationURL(conversationId: conversationId) + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + if try fileManager.contentsOfDirectory(at: pendingMutationsURL, includingPropertiesForKeys: nil).isEmpty { + try fileManager.removeItem(at: pendingMutationsURL) + } + } + + func removeAllPendingMutations() throws { + guard fileManager.fileExists(atPath: pendingMutationsURL.path) else { return } + try fileManager.removeItem(at: pendingMutationsURL) + } + + private func pendingMutationURL(conversationId: UUID) -> URL { + pendingMutationsURL.appendingPathComponent("\(conversationId.uuidString).json") + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage.swift new file mode 100644 index 00000000..5368178d --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationStorage.swift @@ -0,0 +1,489 @@ +// +// ConversationStorage.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +actor ConversationStorage { + // MARK: - Properties + + let fileManager: FileManager + let documentsURL: URL + let directoryURL: URL + let tombstonesURL: URL + let deleteAllMarkerURL: URL + let localResetMarkerURL: URL + let pendingMutationsURL: URL + let recoveryDirectoryURL: URL + let cloudSyncManager: CloudSyncManagerProtocol + let attachmentRepository: AttachmentRepositoryProtocol + + // MARK: - Init + + init( + cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager(), + attachmentRepository: AttachmentRepositoryProtocol? = nil, + baseDirectory: URL? = nil + ) { + let fileManager = FileManager.default + self.fileManager = fileManager + let documentsURL = baseDirectory ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + self.documentsURL = documentsURL + self.directoryURL = documentsURL.appendingPathComponent("Conversations", isDirectory: true) + self.tombstonesURL = documentsURL.appendingPathComponent("ConversationTombstones.json") + self.deleteAllMarkerURL = documentsURL.appendingPathComponent("ConversationDeleteAll.json") + self.localResetMarkerURL = documentsURL.appendingPathComponent("ConversationLocalReset.json") + self.pendingMutationsURL = documentsURL.appendingPathComponent( + "ConversationPendingMutations", + isDirectory: true + ) + self.recoveryDirectoryURL = documentsURL.appendingPathComponent("ConversationRecovery", isDirectory: true) + self.cloudSyncManager = cloudSyncManager + self.attachmentRepository = attachmentRepository + ?? AttachmentRepository(fileManager: fileManager, baseURL: documentsURL) + } + + // MARK: - Public + + func loadLocal() throws -> [Conversation] { + try ensureDirectoryExists() + let tombstones = try loadTombstones() + let marker = try loadDeleteAllMarker() + return try loadLocalConversations() + .filter { shouldKeep($0, tombstones: tombstones, marker: marker) } + .sorted { $0.updatedAt > $1.updatedAt } + } + + func save(_ conversation: Conversation) throws { + try ensureDirectoryExists() + let canonical = try canonicalConversation(conversation) + let existing = try loadLocalConversation(id: canonical.conversation.id) + if let existing, existing.updatedAt > canonical.conversation.updatedAt { + return + } + var conversation = canonical.conversation + conversation.updatedAt = try modificationDate( + requested: conversation.updatedAt, + after: deletionBarrier(for: conversation.id) + ) + if let existing { + guard existing != conversation || !canonical.attachmentData.isEmpty else { return } + try persistLocalMutation( + conversation, + replacing: existing, + pendingBase: nil, + attachmentData: canonical.attachmentData + ) + } else { + try persistNewLocalConversation(conversation, attachmentData: canonical.attachmentData) + } + } + + func delete(_ conversationId: UUID, synchronize: Bool) throws { + guard synchronize else { + try deleteLocally(conversationId) + return + } + guard cloudSyncManager.isCloudAvailable() else { + throw CloudSyncError.containerUnavailable + } + for attempt in 0..<2 { + do { + let snapshot = try cloudSyncManager.loadConversationSyncSnapshot() + try delete(conversationId, using: snapshot) + return + } catch CloudSyncError.cloudContentChanged where attempt == 0 { + continue + } + } + throw CloudSyncError.cloudContentChanged + } + + func deleteAll(synchronize: Bool) throws { + guard synchronize else { + try deleteAllLocally() + return + } + guard cloudSyncManager.isCloudAvailable() else { + throw CloudSyncError.containerUnavailable + } + for attempt in 0..<2 { + do { + let snapshot = try cloudSyncManager.loadConversationSyncSnapshot() + try deleteAll(using: snapshot) + return + } catch CloudSyncError.cloudContentChanged where attempt == 0 { + continue + } + } + throw CloudSyncError.cloudContentChanged + } + + private func deleteLocally(_ conversationId: UUID) throws { + try Task.checkCancellation() + try ensureDirectoryExists() + let attachmentKeys: Set + if let conversation = try loadLocalConversation(id: conversationId) { + attachmentKeys = try storedAttachmentKeys(in: conversation) + } else { + attachmentKeys = [] + } + let transaction = try ConversationLocalTransaction( + fileManager: fileManager, + documentsURL: documentsURL, + backsUpAllAttachments: true + ) + do { + try applyLocalDelete(conversationId) + try transaction.commit(verifying: .init( + absentConversationIds: [conversationId], + absentPendingMutationIds: [conversationId], + absentAttachmentKeys: attachmentKeys, + emptyAttachments: false + )) + } catch { + try transaction.rollback() + throw error + } + try removeRecoveryData(conversationId: conversationId) + } + + private func deleteAllLocally() throws { + try Task.checkCancellation() + try ensureDirectoryExists() + let transaction = try ConversationLocalTransaction( + fileManager: fileManager, + documentsURL: documentsURL, + backsUpAllAttachments: true + ) + do { + try applyLocalDeleteAll(createCloudMarker: false) + try transaction.commit(verifying: .init( + emptyConversations: true, + emptyPendingMutations: true, + emptyAttachments: true + )) + } catch { + try transaction.rollback() + throw error + } + if fileManager.fileExists(atPath: recoveryDirectoryURL.path) { + try fileManager.removeItem(at: recoveryDirectoryURL) + } + try ensureDirectoryExists() + } + + @discardableResult + func synchronize() -> ConversationSyncResult { + guard !Task.isCancelled else { return .unavailable } + guard cloudSyncManager.isCloudAvailable() else { return .unavailable } + for attempt in 0..<2 { + do { + try ensureDirectoryExists() + let snapshot = try cloudSyncManager.loadConversationSyncSnapshot() + try synchronize(with: snapshot) + return .synchronized + } catch CloudSyncError.cloudContentChanged where attempt == 0 { + continue + } catch CloudSyncError.requiredDownloadPending { + return .pendingDownload + } catch CloudSyncError.containerUnavailable { + return .unavailable + } catch CloudSyncError.containerIdentityChanged { + return .unavailable + } catch is CancellationError { + return .unavailable + } catch { + LogManager.error("Conversation synchronization failed category=storage") + return .failed + } + } + return .failed + } +} + +// MARK: - Synchronization + +extension ConversationStorage { + struct ConversationFiles { + var values: [UUID: Conversation] = [:] + var data: [UUID: Data] = [:] + } + + struct MergedConversation { + let conversation: Conversation + let data: Data + let source: Source + let localInlineAttachmentData: [CloudAttachmentKey: Data] + let cloudInlineAttachmentData: [CloudAttachmentKey: Data] + } + + enum Source: Equatable { + case local + case cloud + case equivalent + } + + struct SynchronizationPlan { + let output: ConversationCloudSyncOutput + let mutatedConversation: Conversation? + let resolvedPendingMutationIds: Set + let localAttachmentKeys: Set + let localConflictAttachmentKeys: Set + } + + @discardableResult + func synchronize( + with snapshot: ConversationCloudSyncSnapshot, + conversationId: UUID? = nil, + mutationAttachmentData: [CloudAttachmentKey: Data] = [:], + mutation: ((inout Conversation, [Conversation]) throws -> Void)? = nil + ) throws -> Conversation? { + let plan = try makeSynchronizationPlan( + snapshot: snapshot, + conversationId: conversationId, + mutationAttachmentData: mutationAttachmentData, + mutation: mutation + ) + try Task.checkCancellation() + try commitSynchronization( + output: plan.output, + snapshot: snapshot, + resolvedPendingMutationIds: plan.resolvedPendingMutationIds, + localAttachmentKeys: plan.localAttachmentKeys, + localConflictAttachmentKeys: plan.localConflictAttachmentKeys + ) + return plan.mutatedConversation + } + + func makeSynchronizationPlan( + snapshot: ConversationCloudSyncSnapshot, + conversationId: UUID?, + mutationAttachmentData: [CloudAttachmentKey: Data] = [:], + additionalTombstones: [ConversationTombstone] = [], + deleteAllMarkerOverride: ConversationDeleteAllMarker? = nil, + mutation: ((inout Conversation, [Conversation]) throws -> Void)? + ) throws -> SynchronizationPlan { + let local = try loadLocalConversationFiles() + let localAttachmentData = try loadLocalAttachmentFiles() + let localTombstones = try loadTombstones() + let pendingMutationBases = try loadPendingMutationBases() + let marker = newestMarker( + newestMarker(try loadDeleteAllMarker(), snapshot.deleteAllMarker), + deleteAllMarkerOverride + ) + let tombstones = mergeTombstones(localTombstones + snapshot.tombstones + additionalTombstones) + var merged = try mergeConversations(context: MergeContext( + local: local, + snapshot: snapshot, + tombstones: tombstones, + deleteAllMarker: marker, + pendingMutationBases: pendingMutationBases, + localAttachmentData: localAttachmentData + )) + let mutatedConversation = try mutateMergedConversation( + conversationId: conversationId, + merged: &merged, + attachmentData: mutationAttachmentData, + mutation: mutation + ) + let output = try makeSyncOutput( + merged: merged, + tombstones: tombstones, + marker: marker, + snapshot: snapshot + ) + let localAttachmentKeys = try local.values.values.reduce( + into: Set() + ) { keys, conversation in + keys.formUnion(try storedAttachmentKeys(in: conversation)) + } + let localConflictAttachmentKeys = try localConflictAttachmentKeys( + local: local, + merged: merged, + tombstones: tombstones, + marker: marker + ) + return SynchronizationPlan( + output: output, + mutatedConversation: mutatedConversation, + resolvedPendingMutationIds: Set(pendingMutationBases.keys), + localAttachmentKeys: localAttachmentKeys, + localConflictAttachmentKeys: localConflictAttachmentKeys + ) + } + + func makeSyncOutput( + merged: [MergedConversation], + tombstones: [ConversationTombstone], + marker: ConversationDeleteAllMarker?, + snapshot: ConversationCloudSyncSnapshot + ) throws -> ConversationCloudSyncOutput { + let tombstones = try makeDecoder().decode( + [ConversationTombstone].self, + from: makeEncoder().encode(tombstones) + ) + let marker = try marker.map { + try makeDecoder().decode( + ConversationDeleteAllMarker.self, + from: makeEncoder().encode($0) + ) + } + try requireConflictRecoveryDownloads( + snapshot: snapshot, + merged: merged, + tombstones: tombstones, + marker: marker + ) + let output = ConversationCloudSyncOutput( + conversations: merged.map(\.conversation), + conversationData: Dictionary(uniqueKeysWithValues: merged.map { ($0.conversation.id, $0.data) }), + tombstones: tombstones, + deleteAllMarker: marker, + attachments: try resolvedAttachments(for: merged, snapshot: snapshot) + ) + try preserveRemovedCloudAttachments( + output: output, + snapshot: snapshot, + tombstones: tombstones, + marker: marker + ) + return output + } + + func preferredConversation( + local: MergedConversation, + cloud: MergedConversation + ) throws -> MergedConversation { + guard local.conversation != cloud.conversation || local.data != cloud.data else { + return MergedConversation( + conversation: local.conversation, + data: local.data, + source: .equivalent, + localInlineAttachmentData: local.localInlineAttachmentData, + cloudInlineAttachmentData: cloud.cloudInlineAttachmentData + ) + } + let localWins: Bool + if local.conversation.updatedAt == cloud.conversation.updatedAt { + localWins = cloud.data.lexicographicallyPrecedes(local.data) + } else { + localWins = local.conversation.updatedAt > cloud.conversation.updatedAt + } + let winner = localWins ? local : cloud + try preserveForRecovery( + localWins ? cloud.data : local.data, + conversationId: local.conversation.id + ) + return MergedConversation( + conversation: winner.conversation, + data: winner.data, + source: localWins ? .local : .cloud, + localInlineAttachmentData: local.localInlineAttachmentData, + cloudInlineAttachmentData: cloud.cloudInlineAttachmentData + ) + } + + func resolvedAttachments( + for merged: [MergedConversation], + snapshot: ConversationCloudSyncSnapshot + ) throws -> [CloudAttachmentKey: Data] { + var result: [CloudAttachmentKey: Data] = [:] + for item in merged { + for attachment in item.conversation.messages.flatMap(\.attachments) { + guard let key = try ConversationAttachmentPath.key( + for: attachment, + conversationId: item.conversation.id + ) else { continue } + if snapshot.attachmentPlaceholders.contains(key) { + throw CloudSyncError.requiredDownloadPending + } + let localData = try localAttachmentData(for: key) + let cloudData = snapshot.attachmentData[key] + let localInlineData = item.localInlineAttachmentData[key] + let cloudInlineData = item.cloudInlineAttachmentData[key] + let selectedData: Data? + switch item.source { + case .local: + selectedData = localInlineData ?? localData ?? cloudInlineData ?? cloudData + case .cloud, .equivalent: + selectedData = cloudInlineData ?? cloudData ?? localInlineData ?? localData + } + guard let selectedData else { throw CloudSyncError.missingAttachment } + for candidate in [localInlineData, localData, cloudInlineData, cloudData].compactMap({ $0 }) + where candidate != selectedData { + try preserveAttachmentForRecovery(candidate, key: key) + } + result[key] = selectedData + } + } + return result + } + + func requireConflictRecoveryDownloads( + snapshot: ConversationCloudSyncSnapshot, + merged: [MergedConversation], + tombstones: [ConversationTombstone], + marker: ConversationDeleteAllMarker? + ) throws { + let retainedKeys = try attachmentKeys(in: merged.map(\.conversation)) + for conversation in snapshot.conversations.values where shouldKeep( + conversation, + tombstones: tombstones, + marker: marker + ) { + let removedKeys = try attachmentKeys(in: [conversation]).subtracting(retainedKeys) + if !removedKeys.isDisjoint(with: snapshot.attachmentPlaceholders) { + throw CloudSyncError.requiredDownloadPending + } + } + } + + func attachmentKeys(in conversations: [Conversation]) throws -> Set { + var keys = Set() + for conversation in conversations { + for attachment in conversation.messages.flatMap(\.attachments) { + guard let key = try ConversationAttachmentPath.key( + for: attachment, + conversationId: conversation.id + ) else { continue } + keys.insert(key) + } + } + return keys + } + + func localConflictAttachmentKeys( + local: ConversationFiles, + merged: [MergedConversation], + tombstones: [ConversationTombstone], + marker: ConversationDeleteAllMarker? + ) throws -> Set { + let mergedById = Dictionary(uniqueKeysWithValues: merged.map { ($0.conversation.id, $0.conversation) }) + var keys = Set() + for conversation in local.values.values where shouldKeep( + conversation, + tombstones: tombstones, + marker: marker + ) { + guard let winner = mergedById[conversation.id], winner != conversation else { continue } + let winnerKeys = try attachmentKeys(in: [winner]) + keys.formUnion(try storedAttachmentKeys(in: conversation).subtracting(winnerKeys)) + } + return keys + } + + func storedAttachmentKeys(in conversation: Conversation) throws -> Set { + var keys = Set() + for attachment in conversation.messages.flatMap(\.attachments) { + if let key = try ConversationAttachmentPath.key(for: attachment) { + keys.insert(key) + } + } + return keys + } +} diff --git a/openclient-llm/Shared/Features/Chat/Repositories/ConversationSyncCoordinator.swift b/openclient-llm/Shared/Features/Chat/Repositories/ConversationSyncCoordinator.swift new file mode 100644 index 00000000..f4fcc6f2 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/Repositories/ConversationSyncCoordinator.swift @@ -0,0 +1,285 @@ +// +// ConversationSyncCoordinator.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +actor ConversationSyncCoordinator { + struct AdmissionToken: Sendable { + let generation: Int + } + + // MARK: - Properties + + private let storage: ConversationStorage + private var isSynchronizing = false + private var currentWaiters: [CheckedContinuation] = [] + private var followUpWaiters: [CheckedContinuation] = [] + private var runTask: Task? + private var mutationTasks: [UUID: Task] = [:] + private var runGeneration = 0 + private var operationGeneration = 0 + private var isCancelling = false + private var cancellationFenceCount = 0 + private var cancellationTask: Task? + private var admissionWaiters: [CheckedContinuation] = [] + + // MARK: - Init + + init(storage: ConversationStorage) { + self.storage = storage + } + + // MARK: - Public + + func synchronize() async -> ConversationSyncResult { + guard !isCancelling else { return .unavailable } + return await withCheckedContinuation { continuation in + if isSynchronizing { + followUpWaiters.append(continuation) + } else { + isSynchronizing = true + currentWaiters.append(continuation) + startRun() + } + } + } + + func cancel() async { + if let cancellationTask { + await cancellationTask.value + return + } + if isCancelling { + return + } + isCancelling = true + operationGeneration += 1 + runGeneration += 1 + let generation = runGeneration + let task = runTask + let mutations = mutationTasks + task?.cancel() + for mutation in mutations.values { + mutation.cancel() + } + let cancellationTask = Task { + await task?.value + for mutation in mutations.values { + _ = try? await mutation.value + } + self.finishCancellation(generation: generation, mutationIds: Set(mutations.keys)) + } + self.cancellationTask = cancellationTask + await cancellationTask.value + } + + func cancelAndDeleteAll() async throws { + cancellationFenceCount += 1 + await cancel() + defer { + cancellationFenceCount -= 1 + if cancellationFenceCount == 0 { + reopenAdmission() + } + } + try await storage.deleteAll(synchronize: false) + } + + func admissionToken() async -> AdmissionToken { + if !isCancelling { + return AdmissionToken(generation: operationGeneration) + } + return await withCheckedContinuation { continuation in + admissionWaiters.append(continuation) + } + } + + func setPinned( + _ isPinned: Bool, + conversationId: UUID, + synchronize: Bool, + admissionToken: AdmissionToken + ) async throws -> Conversation? { + try await performMutation(admissionToken: admissionToken) { storage in + try await storage.setPinned( + isPinned, + conversationId: conversationId, + synchronize: synchronize + ) + } + } + + func save( + _ conversation: Conversation, + expectedBase: Conversation?, + synchronize: Bool, + admissionToken: AdmissionToken + ) async throws -> Conversation { + var admissionToken = admissionToken + var shouldSynchronize = synchronize + var preservePendingBase = false + if isCancelling || admissionToken.generation != operationGeneration { + admissionToken = await self.admissionToken() + shouldSynchronize = false + preservePendingBase = true + } + guard !isCancelling, admissionToken.generation == operationGeneration else { throw CancellationError() } + let id = UUID() + let synchronizeSave = shouldSynchronize + let shouldPreservePendingBase = preservePendingBase + let task = Task { + try await storage.saveSynchronizing( + conversation, + expectedBase: expectedBase, + synchronize: synchronizeSave, + preservePendingBase: shouldPreservePendingBase + ) + } + mutationTasks[id] = task + defer { mutationTasks[id] = nil } + guard let saved = try await task.value else { throw CloudSyncError.staleConversationRevision } + return saved + } + + func rename( + _ conversationId: UUID, + title: String, + synchronize: Bool, + admissionToken: AdmissionToken + ) async throws -> Conversation? { + try await performMutation(admissionToken: admissionToken) { storage in + try await storage.rename(conversationId, title: title, synchronize: synchronize) + } + } + + func updateTags( + _ conversationId: UUID, + tags: [ConversationTag], + synchronize: Bool, + admissionToken: AdmissionToken + ) async throws -> Conversation? { + try await performMutation(admissionToken: admissionToken) { storage in + try await storage.updateTags(conversationId, tags: tags, synchronize: synchronize) + } + } + + func delete( + _ conversationId: UUID, + synchronize: Bool, + admissionToken: AdmissionToken + ) async throws { + _ = try await performMutation(admissionToken: admissionToken) { storage in + do { + try await storage.delete(conversationId, synchronize: synchronize) + } catch { + throw self.deletionError(from: error) + } + return nil + } + } + + func deleteAll( + synchronize: Bool, + admissionToken: AdmissionToken + ) async throws { + _ = try await performMutation(admissionToken: admissionToken) { storage in + do { + try await storage.deleteAll(synchronize: synchronize) + } catch { + throw self.deletionError(from: error) + } + return nil + } + } +} + +// MARK: - Private + +private extension ConversationSyncCoordinator { + func performMutation( + admissionToken: AdmissionToken, + _ operation: @escaping @Sendable (ConversationStorage) async throws -> Conversation? + ) async throws -> Conversation? { + guard !isCancelling, admissionToken.generation == operationGeneration else { throw CancellationError() } + let id = UUID() + let task = Task { + try Task.checkCancellation() + return try await operation(storage) + } + mutationTasks[id] = task + defer { mutationTasks[id] = nil } + return try await task.value + } + + nonisolated func deletionError(from error: Error) -> Error { + switch error { + case CloudSyncError.requiredDownloadPending: + ConversationSyncOperationError.pendingDownload + case CloudSyncError.containerUnavailable, CloudSyncError.containerIdentityChanged: + ConversationSyncOperationError.unavailable + default: + error + } + } + + func startRun() { + let generation = runGeneration + runTask = Task { + let result = await storage.synchronize() + finishRun(with: result, generation: generation) + } + } + + func finishRun(with result: ConversationSyncResult, generation: Int) { + guard generation == runGeneration else { return } + let completedWaiters = currentWaiters + currentWaiters = [] + for waiter in completedWaiters { + waiter.resume(returning: result) + } + + guard !followUpWaiters.isEmpty else { + isSynchronizing = false + runTask = nil + return + } + currentWaiters = followUpWaiters + followUpWaiters = [] + startRun() + } + + func finishCancellation(generation: Int, mutationIds: Set) { + guard generation == runGeneration else { return } + runTask = nil + cancellationTask = nil + let waiters = currentWaiters + followUpWaiters + currentWaiters = [] + followUpWaiters = [] + isSynchronizing = false + for waiter in waiters { + waiter.resume(returning: .unavailable) + } + for id in mutationIds { + mutationTasks[id] = nil + } + if cancellationFenceCount == 0 { + reopenAdmission() + } + } + + func reopenAdmission() { + guard isCancelling else { return } + isCancelling = false + let waiters = admissionWaiters + admissionWaiters = [] + let token = AdmissionToken(generation: operationGeneration) + for waiter in waiters { + waiter.resume(returning: token) + } + } +} diff --git a/openclient-llm/Shared/Features/Chat/UseCases/AttachmentMigrationUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/AttachmentMigrationUseCase.swift index af8b81ab..d87a0703 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/AttachmentMigrationUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/AttachmentMigrationUseCase.swift @@ -6,6 +6,7 @@ // Copyright © 2026 Arturo Carretero Calvo. All rights reserved. // +import CryptoKit import Foundation // MARK: - Protocol @@ -57,85 +58,197 @@ struct AttachmentMigrationUseCase: AttachmentMigrationUseCaseProtocol { LogManager.info("AttachmentMigrationUseCase: starting migration") let conversationsURL = baseDirectory.appendingPathComponent("Conversations", isDirectory: true) - - guard let fileURLs = try? fileManager.contentsOfDirectory( - at: conversationsURL, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles - ) else { + guard fileManager.fileExists(atPath: conversationsURL.path) else { LogManager.info("AttachmentMigrationUseCase: no conversations directory found") markDone() return } + let fileURLs: [URL] + do { + let values = try conversationsURL.resourceValues(forKeys: [.isDirectoryKey]) + guard values.isDirectory == true else { + LogManager.error("AttachmentMigrationUseCase: conversations path is not a directory") + return + } + fileURLs = try fileManager.contentsOfDirectory( + at: conversationsURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + } catch { + LogManager.error("AttachmentMigrationUseCase: failed to enumerate conversations") + return + } + var migratedCount = 0 + var hasFailure = false for url in fileURLs where url.pathExtension == "json" { let conversationId = UUID(uuidString: url.deletingPathExtension().lastPathComponent) - if migrateConversationFile(at: url, conversationId: conversationId) { + switch migrateConversationFile(at: url, conversationId: conversationId) { + case .migrated: migratedCount += 1 + case .failed: + hasFailure = true + case .unchanged: + break } } LogManager.success("AttachmentMigrationUseCase: migrated \(migratedCount) conversations") - markDone() + if !hasFailure { + markDone() + } } } // MARK: - Private private extension AttachmentMigrationUseCase { - /// Migrates a single conversation JSON file. Returns `true` if the file was modified. - @discardableResult - func migrateConversationFile(at url: URL, conversationId: UUID?) -> Bool { + enum MigrationResult { + case unchanged + case migrated + case failed + } + + struct MessageMigration { + let messages: [[String: Any]] + let attachments: [PlannedAttachment] + let hasFailure: Bool + } + + struct PlannedAttachment { + let attachment: ChatMessage.Attachment + let data: Data + let relativePath: String + } + + func migrateConversationFile(at url: URL, conversationId: UUID?) -> MigrationResult { guard let rawData = try? Data(contentsOf: url), var root = try? JSONSerialization.jsonObject(with: rawData) as? [String: Any] else { - return false + return .failed + } + + guard let messages = root["messages"] as? [[String: Any]] else { return .failed } + guard let folderId = conversationId + ?? (root["id"] as? String).flatMap(UUID.init(uuidString:)) else { + return .failed + } + if containsLegacyAttachment(in: messages), + !preserveForRecovery(rawData, conversationId: folderId) { + return .failed } - guard var messages = root["messages"] as? [[String: Any]] else { return false } + let migration = planMessages(messages, folderId: folderId) + guard !migration.attachments.isEmpty else { return migration.hasFailure ? .failed : .unchanged } + guard !migration.hasFailure else { return .failed } + guard preflight(migration.attachments), persist(migration.attachments, folderId: folderId) else { + return .failed + } + root["messages"] = migration.messages - var didModify = false - let folderId = conversationId ?? UUID() + guard let updatedData = try? JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys] + ) else { return .failed } + do { + try updatedData.write(to: url, options: .atomic) + guard try Data(contentsOf: url) == updatedData, + verifyMigratedData( + updatedData, + conversationId: folderId, + migratedAttachments: migration.attachments + ) else { + try rawData.write(to: url, options: .atomic) + return .failed + } + return .migrated + } catch { + LogManager.error("AttachmentMigrationUseCase: failed to write migrated file: \(error)") + return .failed + } + } + + func markDone() { + userDefaults.set(true, forKey: Self.migrationKey) + } + + func planMessages(_ source: [[String: Any]], folderId: UUID) -> MessageMigration { + var messages = source + var plannedAttachments: [PlannedAttachment] = [] + var hasFailure = false for messageIndex in messages.indices { guard var attachments = messages[messageIndex]["attachments"] as? [[String: Any]] else { continue } - for attachmentIndex in attachments.indices { - guard let updated = migrateAttachment(attachments[attachmentIndex], folderId: folderId) else { + let attachment = attachments[attachmentIndex] + guard attachment["data"] != nil, attachment["fileRelativePath"] == nil else { continue } + guard let planned = plannedAttachment(attachment, folderId: folderId) else { + hasFailure = true continue } + var updated = attachment + updated.removeValue(forKey: "data") + updated["fileRelativePath"] = planned.relativePath + updated["mimeType"] = planned.attachment.mimeType attachments[attachmentIndex] = updated - didModify = true + plannedAttachments.append(planned) } - messages[messageIndex]["attachments"] = attachments } + return MessageMigration(messages: messages, attachments: plannedAttachments, hasFailure: hasFailure) + } - guard didModify else { return false } - - root["messages"] = messages - - guard let updatedData = try? JSONSerialization.data( - withJSONObject: root, - options: [.prettyPrinted, .sortedKeys] - ) else { return false } + func containsLegacyAttachment(in messages: [[String: Any]]) -> Bool { + messages.contains { message in + guard let attachments = message["attachments"] as? [[String: Any]] else { return false } + return attachments.contains { $0["data"] != nil && $0["fileRelativePath"] == nil } + } + } + func preserveForRecovery(_ data: Data, conversationId: UUID) -> Bool { + let directory = baseDirectory + .appendingPathComponent("ConversationRecovery/Migrations/Attachments", isDirectory: true) + let digest = SHA256.hash(data: data).prefix(8).map { String(format: "%02x", $0) }.joined() + let url = directory.appendingPathComponent("\(conversationId.uuidString)-\(digest).json") do { - try updatedData.write(to: url, options: .atomic) - return true + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + if !fileManager.fileExists(atPath: url.path) { + try data.write(to: url, options: .atomic) + } + return try Data(contentsOf: url) == data } catch { - LogManager.error("AttachmentMigrationUseCase: failed to write migrated file: \(error)") return false } } - func markDone() { - userDefaults.set(true, forKey: Self.migrationKey) + func verifyMigratedData( + _ data: Data, + conversationId: UUID, + migratedAttachments: [PlannedAttachment] + ) -> Bool { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (root["id"] as? String).flatMap(UUID.init(uuidString:)) == conversationId, + let messages = root["messages"] as? [[String: Any]] else { + return false + } + guard messages.allSatisfy({ message in + guard let attachments = message["attachments"] as? [[String: Any]] else { return true } + return attachments.allSatisfy { attachment in + guard attachment["data"] == nil, + let path = attachment["fileRelativePath"] as? String else { return false } + let components = (path as NSString).pathComponents + return components.count == 3 + && components[0] == "Attachments" + && UUID(uuidString: components[1]) == conversationId + } + }) else { return false } + return migratedAttachments.allSatisfy { planned in + (try? attachmentRepository.load(attachment: planned.attachment)) == planned.data + } } - /// Migrates a single attachment dict. Returns the updated dict if migration was performed, nil otherwise. - func migrateAttachment(_ attachment: [String: Any], folderId: UUID) -> [String: Any]? { - // Only process legacy entries that have "data" but no "fileRelativePath" + func plannedAttachment(_ attachment: [String: Any], folderId: UUID) -> PlannedAttachment? { guard let base64String = attachment["data"] as? String, attachment["fileRelativePath"] == nil else { return nil } @@ -145,34 +258,67 @@ private extension AttachmentMigrationUseCase { return nil } - let attachmentId = (attachment["id"] as? String).flatMap(UUID.init) ?? UUID() + guard let attachmentId = (attachment["id"] as? String).flatMap(UUID.init) else { + LogManager.warning("AttachmentMigrationUseCase: invalid attachment identifier") + return nil + } let fileName = attachment["fileName"] as? String ?? "attachment" let typeRaw = attachment["type"] as? String ?? "image" let attachmentType = ChatMessage.AttachmentType(rawValue: typeRaw) ?? .image let mimeType = ChatMessage.Attachment.inferMimeType(for: attachmentType, fileName: fileName) - let placeholder = ChatMessage.Attachment( + let relativePath = ConversationAttachmentPath.relativePath( + for: ChatMessage.Attachment( + id: attachmentId, + type: attachmentType, + fileName: fileName, + mimeType: mimeType, + fileRelativePath: "" + ), + conversationId: folderId + ) + let persistedAttachment = ChatMessage.Attachment( id: attachmentId, type: attachmentType, fileName: fileName, mimeType: mimeType, - fileRelativePath: "" + fileRelativePath: relativePath ) + return PlannedAttachment(attachment: persistedAttachment, data: binaryData, relativePath: relativePath) + } - guard let relativePath = try? attachmentRepository.save( - data: binaryData, - for: placeholder, - conversationId: folderId - ) else { - LogManager.error("AttachmentMigrationUseCase: failed to save attachment \(attachmentId)") - return nil + func preflight(_ attachments: [PlannedAttachment]) -> Bool { + var dataByPath: [String: Data] = [:] + let resolver = AttachmentFileResolver(fileManager: fileManager, baseURL: baseDirectory) + for planned in attachments { + if let existing = dataByPath[planned.relativePath], existing != planned.data { + return false + } + dataByPath[planned.relativePath] = planned.data + do { + let url = try resolver.resolve(relativePath: planned.relativePath) + if fileManager.fileExists(atPath: url.path), try Data(contentsOf: url) != planned.data { + return false + } + } catch { + return false + } } + return true + } - var updated = attachment - updated.removeValue(forKey: "data") - updated["fileRelativePath"] = relativePath - updated["mimeType"] = mimeType - LogManager.debug("AttachmentMigrationUseCase: migrated attachment \(attachmentId) → \(relativePath)") - return updated + func persist(_ attachments: [PlannedAttachment], folderId: UUID) -> Bool { + for planned in attachments { + guard let savedPath = try? attachmentRepository.save( + data: planned.data, + for: planned.attachment, + conversationId: folderId + ), savedPath == planned.relativePath, + (try? attachmentRepository.load(attachment: planned.attachment)) == planned.data else { + LogManager.error("AttachmentMigrationUseCase: failed to persist attachment") + return false + } + } + return true } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/BranchConversationUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/BranchConversationUseCase.swift index ce6fb0d2..d266e3a2 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/BranchConversationUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/BranchConversationUseCase.swift @@ -20,37 +20,50 @@ enum BranchConversationError: LocalizedError { } protocol BranchConversationUseCaseProtocol: Sendable { - func execute(conversation: Conversation, fromMessageId: UUID) throws -> Conversation + func execute(conversation: Conversation, fromMessageId: UUID) async throws -> Conversation } struct BranchConversationUseCase: BranchConversationUseCaseProtocol { // MARK: - Properties private let saveConversationUseCase: SaveConversationUseCaseProtocol + private let attachmentRepository: AttachmentRepositoryProtocol // MARK: - Init - init(saveConversationUseCase: SaveConversationUseCaseProtocol = SaveConversationUseCase()) { + init( + saveConversationUseCase: SaveConversationUseCaseProtocol = SaveConversationUseCase(), + attachmentRepository: AttachmentRepositoryProtocol = AttachmentRepository() + ) { self.saveConversationUseCase = saveConversationUseCase + self.attachmentRepository = attachmentRepository } // MARK: - Execute - func execute(conversation: Conversation, fromMessageId: UUID) throws -> Conversation { + func execute(conversation: Conversation, fromMessageId: UUID) async throws -> Conversation { guard let messageIndex = conversation.messages.firstIndex(where: { $0.id == fromMessageId }) else { throw BranchConversationError.messageNotFound } - let branchedMessages = Array(conversation.messages.prefix(messageIndex + 1)) + let forkId = UUID() + let sourceMessages = Array(conversation.messages.prefix(messageIndex + 1)) let retainsSummary = conversation.contextSummaryCursorMessageId.flatMap { cursorMessageId in conversation.messages.firstIndex(where: { $0.id == cursorMessageId }) }.map { $0 <= messageIndex } ?? false + let branchedMessages = try cloneMessages(sourceMessages) + let messageIds = Dictionary(uniqueKeysWithValues: zip(sourceMessages, branchedMessages).map { pair in + (pair.0.id, pair.1.id) + }) let fork = Conversation( + id: forkId, modelId: conversation.modelId, systemPrompt: conversation.systemPrompt, contextWindowTokens: conversation.contextWindowTokens, contextSummary: retainsSummary ? conversation.contextSummary : nil, - contextSummaryCursorMessageId: retainsSummary ? conversation.contextSummaryCursorMessageId : nil, + contextSummaryCursorMessageId: retainsSummary + ? conversation.contextSummaryCursorMessageId.flatMap { messageIds[$0] } + : nil, messages: branchedMessages, modelParameters: conversation.modelParameters, isPinned: false, @@ -59,7 +72,46 @@ struct BranchConversationUseCase: BranchConversationUseCaseProtocol { branchedFromMessageId: fromMessageId ) - try saveConversationUseCase.execute(fork) - return fork + return try await saveConversationUseCase.execute(fork) + } +} + +// MARK: - Private + +private extension BranchConversationUseCase { + func cloneMessages(_ messages: [ChatMessage]) throws -> [ChatMessage] { + try messages.map { message in + var attachments = message.attachments + for attachmentIndex in attachments.indices { + let source = attachments[attachmentIndex] + let data: Data + if let transientData = source.transientData { + data = transientData + } else { + data = try attachmentRepository.load(attachment: source) + } + attachments[attachmentIndex] = ChatMessage.Attachment( + id: source.id, + type: source.type, + fileName: source.fileName, + mimeType: source.mimeType, + fileRelativePath: "", + transientData: data + ) + } + return ChatMessage( + role: message.role, + content: message.content, + reasoningContent: message.reasoningContent, + timestamp: message.timestamp, + attachments: attachments, + tokenUsage: message.tokenUsage, + webSearchResults: message.webSearchResults, + toolCalls: message.toolCalls, + toolCallId: message.toolCallId, + toolName: message.toolName, + isFavourite: message.isFavourite + ) + } } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/DeleteConversationUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/DeleteConversationUseCase.swift index 6cb5668a..fb52fbf3 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/DeleteConversationUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/DeleteConversationUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol DeleteConversationUseCaseProtocol: Sendable { - func execute(_ conversationId: UUID) throws + func execute(_ conversationId: UUID) async throws } struct DeleteConversationUseCase: DeleteConversationUseCaseProtocol { @@ -25,8 +25,8 @@ struct DeleteConversationUseCase: DeleteConversationUseCaseProtocol { // MARK: - Execute - func execute(_ conversationId: UUID) throws { - try repository.delete(conversationId) + func execute(_ conversationId: UUID) async throws { + try await repository.delete(conversationId) SpotlightManager.deindex(id: conversationId) } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/ExportBackupUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/ExportBackupUseCase.swift index 6a30e2a6..c0b90c85 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/ExportBackupUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/ExportBackupUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol ExportBackupUseCaseProtocol: Sendable { - func execute() throws -> Data + func execute() async throws -> Data } struct ExportBackupUseCase: ExportBackupUseCaseProtocol { @@ -30,7 +30,7 @@ struct ExportBackupUseCase: ExportBackupUseCaseProtocol { // MARK: - Execute - func execute() throws -> Data { - try exportConversationsUseCase.execute(loadConversationsUseCase.execute()) + func execute() async throws -> Data { + try exportConversationsUseCase.execute(await loadConversationsUseCase.execute()) } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/ExportConversationsUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/ExportConversationsUseCase.swift index 6c8c3b95..12a8b197 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/ExportConversationsUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/ExportConversationsUseCase.swift @@ -47,10 +47,15 @@ struct ExportConversationsUseCase: ExportConversationsUseCaseProtocol { ) -> [ConversationExportDocument.ExportedAttachment] { conversation.messages.flatMap { message in message.attachments.compactMap { attachment in - guard !attachment.fileRelativePath.isEmpty, - let data = try? attachmentRepository.load(attachment: attachment) else { - return nil + let data: Data? + if let transientData = attachment.transientData { + data = transientData + } else if !attachment.fileRelativePath.isEmpty { + data = try? attachmentRepository.load(attachment: attachment) + } else { + data = nil } + guard let data else { return nil } return .init( messageId: message.id, attachmentId: attachment.id, diff --git a/openclient-llm/Shared/Features/Chat/UseCases/ImportConversationsUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/ImportConversationsUseCase.swift index 92402085..9792eea1 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/ImportConversationsUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/ImportConversationsUseCase.swift @@ -9,43 +9,37 @@ import Foundation protocol ImportConversationsUseCaseProtocol: Sendable { - func execute(_ data: Data) throws -> ImportConversationsResult + func execute(_ data: Data) async throws -> ImportConversationsResult } struct ImportConversationsUseCase: ImportConversationsUseCaseProtocol { // MARK: - Properties private let saveConversationUseCase: SaveConversationUseCaseProtocol - private let deleteConversationUseCase: DeleteConversationUseCaseProtocol private let loadConversationsUseCase: LoadConversationsUseCaseProtocol - private let attachmentRepository: AttachmentRepositoryProtocol // MARK: - Init init( saveConversationUseCase: SaveConversationUseCaseProtocol = SaveConversationUseCase(), - deleteConversationUseCase: DeleteConversationUseCaseProtocol = DeleteConversationUseCase(), - loadConversationsUseCase: LoadConversationsUseCaseProtocol = LoadConversationsUseCase(), - attachmentRepository: AttachmentRepositoryProtocol = AttachmentRepository() + loadConversationsUseCase: LoadConversationsUseCaseProtocol = LoadConversationsUseCase() ) { self.saveConversationUseCase = saveConversationUseCase - self.deleteConversationUseCase = deleteConversationUseCase self.loadConversationsUseCase = loadConversationsUseCase - self.attachmentRepository = attachmentRepository } // MARK: - Execute - func execute(_ data: Data) throws -> ImportConversationsResult { + func execute(_ data: Data) async throws -> ImportConversationsResult { let document = try decodeDocument(from: data) try validate(document) let context = ImportContext( conversationIds: makeConversationIds(for: document), messageIds: makeMessageIds(for: document), attachmentData: makeAttachmentData(for: document), - tagColors: try makeTagColors(for: document) + tagColors: try await makeTagColors(for: document) ) - return try importDocument(document, context: context) + return try await importDocument(document, context: context) } } @@ -148,9 +142,9 @@ private extension ImportConversationsUseCase { } } - func makeTagColors(for document: ConversationExportDocument) throws -> [String: TagColor] { + func makeTagColors(for document: ConversationExportDocument) async throws -> [String: TagColor] { let importedTags = document.conversations.flatMap(\.conversation.tags) - return (try loadConversationsUseCase.execute().flatMap(\.tags) + importedTags) + return (try await loadConversationsUseCase.execute().flatMap(\.tags) + importedTags) .reduce(into: [String: TagColor]()) { colors, tag in if colors[tag.name] == nil { colors[tag.name] = tag.color @@ -161,54 +155,26 @@ private extension ImportConversationsUseCase { func importDocument( _ document: ConversationExportDocument, context: ImportContext - ) throws -> ImportConversationsResult { + ) async throws -> ImportConversationsResult { var result = ImportConversationsResult( importedConversationCount: 0, restoredAttachmentCount: 0, skippedAttachmentCount: 0 ) - var savedConversationIds: [UUID] = [] - do { - for exportedConversation in document.conversations { - guard let conversationId = context.conversationIds[exportedConversation.conversation.id] else { - throw ImportConversationsError.invalidDocument - } - let imported = try importConversation(exportedConversation, context: context) - savedConversationIds.append(conversationId) - result = ImportConversationsResult( - importedConversationCount: result.importedConversationCount + 1, - restoredAttachmentCount: result.restoredAttachmentCount + imported.restoredAttachmentCount, - skippedAttachmentCount: result.skippedAttachmentCount + imported.skippedAttachmentCount - ) - } - } catch { - rollbackConversations(savedConversationIds) - throw error + var restoredConversations: [Conversation] = [] + for exportedConversation in document.conversations { + let restored = try restoreConversation(exportedConversation.conversation, context: context) + restoredConversations.append(restored.conversation) + result = ImportConversationsResult( + importedConversationCount: result.importedConversationCount + 1, + restoredAttachmentCount: result.restoredAttachmentCount + restored.attachments.count, + skippedAttachmentCount: result.skippedAttachmentCount + restored.skippedAttachmentCount + ) } + _ = try await saveConversationUseCase.executeImportBatch(restoredConversations) return result } - func rollbackConversations(_ conversationIds: [UUID]) { - conversationIds.reversed().forEach { try? deleteConversationUseCase.execute($0) } - } - - func importConversation( - _ exportedConversation: ConversationExportDocument.ExportedConversation, - context: ImportContext - ) throws -> (restoredAttachmentCount: Int, skippedAttachmentCount: Int) { - let restored = try restoreConversation( - exportedConversation.conversation, - context: context - ) - do { - try saveConversationUseCase.execute(restored.conversation) - return (restored.attachments.count, restored.skippedAttachmentCount) - } catch { - restored.attachments.forEach { try? attachmentRepository.delete(attachment: $0) } - throw error - } - } - func restoreConversation( _ conversation: Conversation, context: ImportContext @@ -217,19 +183,12 @@ private extension ImportConversationsUseCase { throw ImportConversationsError.invalidDocument } var attachmentRestoration = AttachmentRestoration() - let messages: [ChatMessage] - do { - messages = try conversation.messages.map { message in - try restoreMessage( - message, - conversationId: conversationId, - context: context, - attachmentRestoration: &attachmentRestoration - ) - } - } catch { - attachmentRestoration.saved.forEach { try? attachmentRepository.delete(attachment: $0) } - throw error + let messages = try conversation.messages.map { message in + try restoreMessage( + message, + context: context, + attachmentRestoration: &attachmentRestoration + ) } return RestoredConversation( conversation: Conversation( @@ -249,7 +208,7 @@ private extension ImportConversationsUseCase { parentConversationId: conversation.parentConversationId.flatMap { context.conversationIds[$0] }, branchedFromMessageId: conversation.branchedFromMessageId.flatMap { context.messageIds[$0] }, createdAt: conversation.createdAt, - updatedAt: conversation.updatedAt + updatedAt: Date() ), attachments: attachmentRestoration.saved, skippedAttachmentCount: attachmentRestoration.skippedCount @@ -264,17 +223,15 @@ private extension ImportConversationsUseCase { func restoreMessage( _ message: ChatMessage, - conversationId: UUID, context: ImportContext, attachmentRestoration: inout AttachmentRestoration ) throws -> ChatMessage { guard let messageId = context.messageIds[message.id] else { throw ImportConversationsError.invalidDocument } - let attachments = try restoreAttachments( + let attachments = restoreAttachments( message.attachments, messageId: message.id, - conversationId: conversationId, context: context, attachmentRestoration: &attachmentRestoration ) @@ -297,11 +254,10 @@ private extension ImportConversationsUseCase { func restoreAttachments( _ attachments: [ChatMessage.Attachment], messageId: UUID, - conversationId: UUID, context: ImportContext, attachmentRestoration: inout AttachmentRestoration - ) throws -> [ChatMessage.Attachment] { - try attachments.compactMap { attachment in + ) -> [ChatMessage.Attachment] { + attachments.compactMap { attachment in guard let encodedData = context.attachmentData[messageId]?[attachment.id], let data = Data(base64Encoded: encodedData) else { attachmentRestoration.skippedCount += 1 @@ -312,22 +268,11 @@ private extension ImportConversationsUseCase { type: attachment.type, fileName: attachment.fileName, mimeType: attachment.mimeType, - fileRelativePath: "" - ) - let path = try attachmentRepository.save( - data: data, - for: importedAttachment, - conversationId: conversationId - ) - let persistedAttachment = ChatMessage.Attachment( - id: importedAttachment.id, - type: importedAttachment.type, - fileName: importedAttachment.fileName, - mimeType: importedAttachment.mimeType, - fileRelativePath: path + fileRelativePath: "", + transientData: data ) - attachmentRestoration.saved.append(persistedAttachment) - return persistedAttachment + attachmentRestoration.saved.append(importedAttachment) + return importedAttachment } } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/LoadConversationsUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/LoadConversationsUseCase.swift index 3adaba7d..98de8767 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/LoadConversationsUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/LoadConversationsUseCase.swift @@ -9,8 +9,8 @@ import Foundation protocol LoadConversationsUseCaseProtocol: Sendable { - func execute() throws -> [Conversation] - func executeLocally() throws -> [Conversation] + func execute() async throws -> [Conversation] + func executeLocally() async throws -> [Conversation] } struct LoadConversationsUseCase: LoadConversationsUseCaseProtocol { @@ -26,13 +26,13 @@ struct LoadConversationsUseCase: LoadConversationsUseCaseProtocol { // MARK: - Execute - func execute() throws -> [Conversation] { - let conversations = try repository.loadAll() + func execute() async throws -> [Conversation] { + let conversations = try await repository.loadAll() return normalizeTags(in: conversations) } - func executeLocally() throws -> [Conversation] { - let conversations = try repository.loadLocal() + func executeLocally() async throws -> [Conversation] { + let conversations = try await repository.loadLocal() return normalizeTags(in: conversations) } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/PinConversationUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/PinConversationUseCase.swift index 078982c8..54cc4106 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/PinConversationUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/PinConversationUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol PinConversationUseCaseProtocol: Sendable { - func execute(_ conversationId: UUID, isPinned: Bool) throws + func execute(_ conversationId: UUID, isPinned: Bool) async throws } struct PinConversationUseCase: PinConversationUseCaseProtocol { @@ -25,11 +25,7 @@ struct PinConversationUseCase: PinConversationUseCaseProtocol { // MARK: - Execute - func execute(_ conversationId: UUID, isPinned: Bool) throws { - var conversations = try repository.loadAll() - guard let index = conversations.firstIndex(where: { $0.id == conversationId }) else { return } - conversations[index].isPinned = isPinned - conversations[index].updatedAt = Date() - try repository.save(conversations[index]) + func execute(_ conversationId: UUID, isPinned: Bool) async throws { + _ = try await repository.setPinned(isPinned, conversationId: conversationId) } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/RenameConversationUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/RenameConversationUseCase.swift index 94cc8766..065c0d36 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/RenameConversationUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/RenameConversationUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol RenameConversationUseCaseProtocol: Sendable { - func execute(_ conversationId: UUID, newTitle: String) throws + func execute(_ conversationId: UUID, newTitle: String) async throws } struct RenameConversationUseCase: RenameConversationUseCaseProtocol { @@ -25,11 +25,7 @@ struct RenameConversationUseCase: RenameConversationUseCaseProtocol { // MARK: - Execute - func execute(_ conversationId: UUID, newTitle: String) throws { - var conversations = try repository.loadAll() - guard let index = conversations.firstIndex(where: { $0.id == conversationId }) else { return } - conversations[index].title = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) - conversations[index].updatedAt = Date() - try repository.save(conversations[index]) + func execute(_ conversationId: UUID, newTitle: String) async throws { + _ = try await repository.rename(conversationId, title: newTitle) } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/SaveConversationUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/SaveConversationUseCase.swift index e1405b8e..7999120c 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/SaveConversationUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/SaveConversationUseCase.swift @@ -9,7 +9,16 @@ import Foundation protocol SaveConversationUseCaseProtocol: Sendable { - func execute(_ conversation: Conversation) throws + @discardableResult + func execute(_ conversation: Conversation, expectedBase: Conversation?) async throws -> Conversation + func executeImportBatch(_ conversations: [Conversation]) async throws -> [Conversation] +} + +extension SaveConversationUseCaseProtocol { + @discardableResult + func execute(_ conversation: Conversation) async throws -> Conversation { + try await execute(conversation, expectedBase: nil) + } } struct SaveConversationUseCase: SaveConversationUseCaseProtocol { @@ -25,8 +34,16 @@ struct SaveConversationUseCase: SaveConversationUseCaseProtocol { // MARK: - Execute - func execute(_ conversation: Conversation) throws { - try repository.save(conversation) - SpotlightManager.index(conversation) + @discardableResult + func execute(_ conversation: Conversation, expectedBase: Conversation?) async throws -> Conversation { + let saved = try await repository.save(conversation, expectedBase: expectedBase) + SpotlightManager.index(saved) + return saved + } + + func executeImportBatch(_ conversations: [Conversation]) async throws -> [Conversation] { + let saved = try await repository.importBatch(conversations) + saved.forEach(SpotlightManager.index) + return saved } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/SyncConversationsUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/SyncConversationsUseCase.swift index 6e35d17e..5a71cce1 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/SyncConversationsUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/SyncConversationsUseCase.swift @@ -9,7 +9,8 @@ import Foundation protocol SyncConversationsUseCaseProtocol: Sendable { @discardableResult - func execute() -> ConversationSyncResult + func execute() async -> ConversationSyncResult + func cancel() async } struct SyncConversationsUseCase: SyncConversationsUseCaseProtocol { @@ -26,7 +27,11 @@ struct SyncConversationsUseCase: SyncConversationsUseCaseProtocol { // MARK: - Execute @discardableResult - func execute() -> ConversationSyncResult { - repository.synchronize() + func execute() async -> ConversationSyncResult { + await repository.synchronize() + } + + func cancel() async { + await repository.cancelSynchronization() } } diff --git a/openclient-llm/Shared/Features/Chat/UseCases/UpdateConversationTagsUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/UpdateConversationTagsUseCase.swift index 8c7b2968..02f7d4f2 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/UpdateConversationTagsUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/UpdateConversationTagsUseCase.swift @@ -10,7 +10,7 @@ import Foundation protocol UpdateConversationTagsUseCaseProtocol: Sendable { @discardableResult - func execute(_ conversationId: UUID, tags: [ConversationTag]) throws -> [ConversationTag] + func execute(_ conversationId: UUID, tags: [ConversationTag]) async throws -> [ConversationTag] } struct UpdateConversationTagsUseCase: UpdateConversationTagsUseCaseProtocol { @@ -27,23 +27,7 @@ struct UpdateConversationTagsUseCase: UpdateConversationTagsUseCaseProtocol { // MARK: - Execute @discardableResult - func execute(_ conversationId: UUID, tags: [ConversationTag]) throws -> [ConversationTag] { - var conversations = try repository.loadAll() - guard let index = conversations.firstIndex(where: { $0.id == conversationId }) else { return [] } - let colorsByName = conversations.flatMap(\.tags).reduce(into: [String: TagColor]()) { colors, tag in - if colors[tag.name] == nil { - colors[tag.name] = tag.color - } - } - var tagNames = Set() - let normalizedTags = tags.compactMap { tag -> ConversationTag? in - let name = tag.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, tagNames.insert(name).inserted else { return nil } - return ConversationTag(name: name, color: colorsByName[name] ?? tag.color) - } - conversations[index].tags = normalizedTags - conversations[index].updatedAt = Date() - try repository.save(conversations[index]) - return normalizedTags + func execute(_ conversationId: UUID, tags: [ConversationTag]) async throws -> [ConversationTag] { + try await repository.updateTags(conversationId, tags: tags)?.tags ?? [] } } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift index fbbf4a32..118fff01 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift @@ -29,7 +29,7 @@ extension ChatViewModel { case .loaded(var currentState) = state else { return } applyAgentEvent(event, to: ¤tState, assistantMessageId: context.assistantId) state = .loaded(currentState) - if case .transcriptAppended = event { persistConversation() } + if case .transcriptAppended = event { await persistConversation() } } await handleAgentStreamSuccess(context.assistantId, modelId: context.modelId) @@ -47,7 +47,7 @@ extension ChatViewModel { currentState.errorMessage = error.localizedDescription state = .loaded(currentState) scheduleErrorDismiss() - persistConversation() + await persistConversation() streamingBackgroundUseCase.end() completeActiveStream(context.assistantId) } @@ -217,7 +217,7 @@ private extension ChatViewModel { refreshContextUsage(in: &finalState) state = .loaded(finalState) LogManager.success("performAgentStreaming completed model=\(modelId)") - let didPersist = persistConversation() + let didPersist = await persistConversation() streamingBackgroundUseCase.end() completeActiveStream(assistantId) if didPersist { scheduleCompactionIfNeeded() } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Attachments.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Attachments.swift index b7fe24b4..6799e1d2 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Attachments.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Attachments.swift @@ -22,10 +22,6 @@ extension ChatViewModel { func removeAttachment(_ id: UUID) { guard case .loaded(var loadedState) = state else { return } - if !isPrivateChat, - let attachment = loadedState.pendingAttachments.first(where: { $0.id == id }) { - try? attachmentRepository.delete(attachment: attachment) - } loadedState.pendingAttachments.removeAll { $0.id == id } state = .loaded(loadedState) } @@ -71,40 +67,15 @@ private extension ChatViewModel { mimeType: String ) { guard case .loaded(var loadedState) = state else { return } - if isPrivateChat { - loadedState.pendingAttachments.append(ChatMessage.Attachment( - type: type, - fileName: fileName, - mimeType: mimeType, - fileRelativePath: "", - transientData: data - )) - state = .loaded(loadedState) - return - } - let folderId = loadedState.conversation?.id ?? loadedState.pendingSessionId - let attachmentId = UUID() - let placeholder = ChatMessage.Attachment( - id: attachmentId, + let attachment = ChatMessage.Attachment( type: type, fileName: fileName, mimeType: mimeType, - fileRelativePath: "" + fileRelativePath: "", + transientData: data ) - do { - let relativePath = try attachmentRepository.save(data: data, for: placeholder, conversationId: folderId) - let saved = ChatMessage.Attachment( - id: attachmentId, - type: type, - fileName: fileName, - mimeType: mimeType, - fileRelativePath: relativePath - ) - loadedState.pendingAttachments.append(saved) - state = .loaded(loadedState) - } catch { - LogManager.error("addAttachment failed to save to disk: \(error)") - } + loadedState.pendingAttachments.append(attachment) + state = .loaded(loadedState) } func finishPreparingAttachment(errorMessage: String? = nil) { diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+EditExport.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+EditExport.swift index afc5c4c8..daa49f08 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+EditExport.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+EditExport.swift @@ -138,23 +138,27 @@ extension ChatViewModel { } func forkConversation(fromMessage messageId: UUID) { - guard case .loaded(var loadedState) = state, + guard case .loaded(let loadedState) = state, let conversation = loadedState.conversation else { return } - do { - let fork = try branchConversationUseCase.execute( - conversation: conversation, - fromMessageId: messageId - ) - loadedState.branchedConversation = fork - state = .loaded(loadedState) - onForkCreated?(fork) - LogManager.success("forkConversation fromMessage=\(messageId) newId=\(fork.id)") - } catch { - loadedState.errorMessage = error.localizedDescription - state = .loaded(loadedState) - LogManager.error("forkConversation failed: \(error)") - scheduleErrorDismiss() + Task { + do { + let fork = try await branchConversationUseCase.execute( + conversation: conversation, + fromMessageId: messageId + ) + guard case .loaded(var currentState) = state else { return } + currentState.branchedConversation = fork + state = .loaded(currentState) + onForkCreated?(fork) + LogManager.success("forkConversation fromMessage=\(messageId) newId=\(fork.id)") + } catch { + guard case .loaded(var currentState) = state else { return } + currentState.errorMessage = error.localizedDescription + state = .loaded(currentState) + LogManager.error("forkConversation failed: \(error)") + scheduleErrorDismiss() + } } } @@ -191,7 +195,7 @@ extension ChatViewModel { loadedState.messages[index].isFavourite.toggle() state = .loaded(loadedState) - persistConversation() + scheduleConversationPersistence() LogManager.debug("toggleFavourite id=\(id) isFavourite=\(loadedState.messages[index].isFavourite)") } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift index 5f44478a..4b232fb6 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Helpers.swift @@ -49,7 +49,7 @@ extension ChatViewModel { loadedState.conversation?.contextWindowTokens = normalizedTokens refreshContextUsage(in: &loadedState) state = .loaded(loadedState) - persistConversation() + scheduleConversationPersistence() } func isActiveStream(_ assistantMessageId: UUID) -> Bool { @@ -67,7 +67,7 @@ extension ChatViewModel { compactionTask = nil } - func cancelActiveStreaming() { + func cancelActiveStreaming(shouldPersist: Bool = true) { streamTask?.cancel() streamTask = nil activeAssistantMessageId = nil @@ -79,7 +79,9 @@ extension ChatViewModel { loadedState.activeToolCallIds = [] refreshContextUsage(in: &loadedState) state = .loaded(loadedState) - persistConversation() + if shouldPersist { + scheduleConversationPersistence() + } } func stopStreaming() { @@ -100,7 +102,7 @@ extension ChatViewModel { currentState.activeToolCallIds = [] self.refreshContextUsage(in: ¤tState) self.state = .loaded(currentState) - self.persistConversation() + self.scheduleConversationPersistence() Task { await self.notifyStreamingCompletedUseCase.executeExpired() } } } @@ -212,10 +214,23 @@ extension ChatViewModel { } @discardableResult - func persistConversation() -> Bool { - guard !isPrivateChat else { return false } + func persistConversation() async -> Bool { + guard let snapshot = conversationForPersistence() else { return false } + return await enqueuePersistence(of: snapshot).value.didPersist + } + + func scheduleConversationPersistence() { + guard let snapshot = conversationForPersistence() else { return } + enqueuePersistence(of: snapshot) + } + + private func conversationForPersistence() -> (conversation: Conversation, expectedBase: Conversation?)? { + guard !isPrivateChat else { return nil } guard case .loaded(var loadedState) = state, - var conversation = loadedState.conversation else { return false } + var conversation = loadedState.conversation else { return nil } + let expectedBase = queuedPersistenceConversation?.id == conversation.id + ? queuedPersistenceConversation + : persistenceBase conversation.messages = loadedState.messages conversation.systemPrompt = loadedState.systemPrompt @@ -227,16 +242,152 @@ extension ChatViewModel { } loadedState.conversation = conversation state = .loaded(loadedState) + return (conversation, expectedBase) + } + + @discardableResult + private func enqueuePersistence( + of snapshot: (conversation: Conversation, expectedBase: Conversation?) + ) -> Task { + let previousTask = persistenceTask + persistenceGeneration += 1 + let generation = persistenceGeneration + let resetGeneration = persistenceResetGeneration + queuedPersistenceConversation = snapshot.conversation + let task = Task { [weak self] in + let previousResult = await withTaskCancellationHandler { + await previousTask?.value + } onCancel: { + previousTask?.cancel() + } + guard !Task.isCancelled, let self, + resetGeneration == persistenceResetGeneration else { + return PersistenceResult(didPersist: false, durableConversation: nil) + } + return await persistSnapshot( + snapshot, + previousResult: previousResult, + generation: generation, + resetGeneration: resetGeneration + ) + } + persistenceTask = task + return task + } + private func persistSnapshot( + _ snapshot: (conversation: Conversation, expectedBase: Conversation?), + previousResult: PersistenceResult?, + generation: Int, + resetGeneration: Int + ) async -> PersistenceResult { do { - try saveConversationUseCase.execute(conversation) + let durableBase = previousResult?.durableConversation?.id == snapshot.conversation.id + ? previousResult?.durableConversation + : persistenceBase?.id == snapshot.conversation.id + ? persistenceBase + : snapshot.expectedBase + let submittedConversation: Conversation + if let localBase = snapshot.expectedBase, let durableBase, localBase != durableBase { + submittedConversation = try rebasePersistenceConversation( + snapshot.conversation, + base: localBase, + onto: durableBase + ) + } else { + submittedConversation = snapshot.conversation + } + let persisted = try await saveConversationUseCase.execute( + submittedConversation, + expectedBase: durableBase + ) + guard !Task.isCancelled, resetGeneration == persistenceResetGeneration else { + return PersistenceResult(didPersist: false, durableConversation: nil) + } + applyPersistedConversation(persisted, submittedConversation: submittedConversation) NotificationCenter.default.post(name: .conversationDidUpdate, object: nil) onConversationUpdated?() - return true + finishPersistenceIfCurrent(generation) + return PersistenceResult(didPersist: true, durableConversation: persisted) } catch { LogManager.error("persistConversation failed: \(error)") - return false + finishPersistenceIfCurrent(generation) + return PersistenceResult(didPersist: false, durableConversation: nil) + } + } + + private func finishPersistenceIfCurrent(_ generation: Int) { + guard generation == persistenceGeneration else { return } + queuedPersistenceConversation = nil + persistenceTask = nil + } + + func applyPersistedConversation( + _ persistedConversation: Conversation, + submittedConversation: Conversation + ) { + guard case .loaded(var loadedState) = state, + loadedState.conversation?.id == persistedConversation.id else { return } + persistenceBase = persistedConversation + var desiredConversation = loadedState.conversation ?? submittedConversation + desiredConversation.messages = loadedState.messages + desiredConversation.systemPrompt = loadedState.systemPrompt + desiredConversation.modelParameters = loadedState.modelParameters + desiredConversation.contextWindowTokens = loadedState.contextWindowTokens + if let selectedModel = loadedState.selectedModel { + desiredConversation.modelId = selectedModel.id + } + guard var mergedConversation = try? rebasePersistenceConversation( + desiredConversation, + base: submittedConversation, + onto: persistedConversation + ) else { + return + } + mergedConversation.updatedAt = max(desiredConversation.updatedAt, persistedConversation.updatedAt) + loadedState.conversation = mergedConversation + loadedState.messages = mergedConversation.messages + loadedState.systemPrompt = mergedConversation.systemPrompt + loadedState.modelParameters = mergedConversation.modelParameters + loadedState.contextWindowTokens = mergedConversation.contextWindowTokens + if let model = loadedState.availableModels.first(where: { $0.id == mergedConversation.modelId }) { + loadedState.selectedModel = model + } + refreshContextUsage(in: &loadedState) + state = .loaded(loadedState) + } + + func rebasePersistenceConversation( + _ incoming: Conversation, + base: Conversation, + onto current: Conversation + ) throws -> Conversation { + var normalizedIncoming = incoming + var normalizedBase = base + var normalizedCurrent = current + for baseIndex in normalizedBase.messages.indices { + let baseMessage = normalizedBase.messages[baseIndex] + guard let incomingIndex = normalizedIncoming.messages.firstIndex(where: { $0.id == baseMessage.id }), + let currentIndex = normalizedCurrent.messages.firstIndex(where: { $0.id == baseMessage.id }) else { + continue + } + let incomingMessage = normalizedIncoming.messages[incomingIndex] + let currentMessage = normalizedCurrent.messages[currentIndex] + if incomingMessage != baseMessage { + normalizedCurrent.messages[currentIndex] = incomingMessage + normalizedBase.messages[baseIndex] = incomingMessage + } else if currentMessage != baseMessage { + normalizedBase.messages[baseIndex] = currentMessage + normalizedIncoming.messages[incomingIndex] = currentMessage + } } + var rebased = try ConversationRebaser.rebase( + normalizedIncoming, + base: normalizedBase, + onto: normalizedCurrent + ) + rebased.updatedAt = max(incoming.updatedAt, current.updatedAt) + return rebased } func scheduleCompactionIfNeeded() { @@ -268,7 +419,7 @@ extension ChatViewModel { currentState.conversation?.contextSummaryCursorMessageId = compacted.cursorMessageId refreshContextUsage(in: ¤tState) state = .loaded(currentState) - let didPersist = persistConversation() + let didPersist = await persistConversation() compactionTask = nil if didPersist { scheduleCompactionIfNeeded() } } catch is CancellationError { @@ -334,49 +485,15 @@ extension ChatViewModel { func generatedImageAttachment( data: Data, mimeType: String = "image/png", - state: LoadedState + state _: LoadedState ) -> ChatMessage.Attachment? { - if isPrivateChat { - return ChatMessage.Attachment( - type: .image, - fileName: String(localized: "Generated Image"), - mimeType: mimeType, - fileRelativePath: "", - transientData: data - ) - } - let attachmentID = UUID() - let placeholder = ChatMessage.Attachment( - id: attachmentID, + ChatMessage.Attachment( type: .image, fileName: String(localized: "Generated Image"), mimeType: mimeType, - fileRelativePath: "" - ) - guard let relativePath = try? attachmentRepository.save( - data: data, - for: placeholder, - conversationId: state.conversation?.id ?? state.pendingSessionId - ) else { - LogManager.error("generatedImageAttachment: failed to save image") - return nil - } - return ChatMessage.Attachment( - id: attachmentID, - type: .image, - fileName: String(localized: "Generated Image"), - mimeType: mimeType, - fileRelativePath: relativePath + fileRelativePath: "", + transientData: data ) } - func scheduleErrorDismiss() { - errorDismissTask?.cancel() - errorDismissTask = Task { - try? await Task.sleep(for: .seconds(3)) - guard !Task.isCancelled, case .loaded(var currentState) = state else { return } - currentState.errorMessage = nil - state = .loaded(currentState) - } - } } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+ImageGeneration.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+ImageGeneration.swift index 9798b52a..a1808b01 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+ImageGeneration.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+ImageGeneration.swift @@ -32,7 +32,7 @@ extension ChatViewModel { currentState.isStreaming = false state = .loaded(currentState) LogManager.success("performImageGeneration completed model=\(context.modelId)") - persistConversation() + await persistConversation() streamingBackgroundUseCase.end() completeActiveStream(assistantMessageId) await notifyStreamingCompletedUseCase.execute() @@ -50,7 +50,7 @@ extension ChatViewModel { currentState.errorMessage = error.localizedDescription state = .loaded(currentState) scheduleErrorDismiss() - persistConversation() + await persistConversation() streamingBackgroundUseCase.end() completeActiveStream(assistantMessageId) } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Message.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Message.swift index 6951f87e..4712ff71 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Message.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Message.swift @@ -66,6 +66,7 @@ extension ChatViewModel { func prepareMessageState(text: String, model: LLMModel, loadedState: inout LoadedState) -> UUID { if loadedState.conversation == nil, !isPrivateChat { loadedState.conversation = Conversation( + id: loadedState.pendingSessionId, modelId: model.id, systemPrompt: loadedState.systemPrompt, contextWindowTokens: loadedState.contextWindowTokens diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Streaming.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Streaming.swift index f01c89c3..a293a951 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Streaming.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Streaming.swift @@ -58,7 +58,7 @@ extension ChatViewModel { currentState.errorMessage = error.localizedDescription state = .loaded(currentState) scheduleErrorDismiss() - persistConversation() + await persistConversation() streamingBackgroundUseCase.end() completeActiveStream(assistantMessageId) } @@ -96,7 +96,7 @@ private extension ChatViewModel { refreshContextUsage(in: ¤tState) state = .loaded(currentState) LogManager.success("performStreaming completed model=\(model)") - let didPersist = persistConversation() + let didPersist = await persistConversation() streamingBackgroundUseCase.end() completeActiveStream(assistantMessageId) if didPersist { scheduleCompactionIfNeeded() } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel.swift index cad87f19..f7617645 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel.swift @@ -87,6 +87,11 @@ final class ChatViewModel { var isLoadingMCPTools: Bool = false } + struct PersistenceResult { + let didPersist: Bool + let durableConversation: Conversation? + } + var state: State var onConversationUpdated: (() -> Void)? @@ -123,6 +128,11 @@ final class ChatViewModel { let compactConversationUseCase: CompactConversationUseCaseProtocol var streamTask: Task? var compactionTask: Task? + var persistenceTask: Task? + var persistenceBase: Conversation? + var queuedPersistenceConversation: Conversation? + var persistenceGeneration = 0 + var persistenceResetGeneration = 0 private var loadTask: Task? var activeAssistantMessageId: UUID? var errorDismissTask: Task? @@ -167,6 +177,11 @@ final class ChatViewModel { ) { self.state = state self.pendingConversation = conversation + if case .loaded(let loadedState) = state { + self.persistenceBase = loadedState.conversation ?? conversation + } else { + self.persistenceBase = conversation + } self.isPrivateChat = isPrivateChat self.fetchModelsUseCase = fetchModelsUseCase self.prepareImageAttachmentUseCase = prepareImageAttachmentUseCase @@ -261,6 +276,16 @@ final class ChatViewModel { return } } + + func resetAfterAppDataReset() { + cancelActiveStreaming(shouldPersist: false) + persistenceResetGeneration += 1 + persistenceTask?.cancel() + persistenceTask = nil + queuedPersistenceConversation = nil + persistenceBase = nil + loadInitialData() + } } // MARK: - Private @@ -324,6 +349,7 @@ private extension ChatViewModel { pendingConversation = conversation return } + persistenceBase = conversation loadedState.conversation = conversation loadedState.messages = conversation.messages loadedState.systemPrompt = conversation.systemPrompt @@ -350,7 +376,7 @@ private extension ChatViewModel { if loadedState.conversation != nil { loadedState.conversation?.modelId = model.id state = .loaded(loadedState) - persistConversation() + scheduleConversationPersistence() } } @@ -363,7 +389,7 @@ private extension ChatViewModel { } refreshContextUsage(in: &loadedState) state = .loaded(loadedState) - persistConversation() + scheduleConversationPersistence() } func updateModelParameters(_ parameters: ModelParameters) { @@ -374,7 +400,7 @@ private extension ChatViewModel { loadedState.conversation?.modelParameters = parameters } state = .loaded(loadedState) - persistConversation() + scheduleConversationPersistence() } func speakMessage(_ message: ChatMessage) { @@ -422,8 +448,25 @@ private extension ChatViewModel { .notifications(named: .appDataDidReset) for await _ in notifications { guard let self else { return } - await MainActor.run { self.loadInitialData() } + await MainActor.run { + self.resetAfterAppDataReset() + } } } } + +} + +// MARK: - Error State + +extension ChatViewModel { + func scheduleErrorDismiss() { + errorDismissTask?.cancel() + errorDismissTask = Task { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled, case .loaded(var currentState) = state else { return } + currentState.errorMessage = nil + state = .loaded(currentState) + } + } } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Filter.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Filter.swift new file mode 100644 index 00000000..6b4249f9 --- /dev/null +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Filter.swift @@ -0,0 +1,33 @@ +// +// ConversationListViewModel+Filter.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +extension ConversationListViewModel { + func applySearchFilter(_ loadedState: inout LoadedState) { + var base = loadedState.conversations + + if let tag = loadedState.activeTagFilter, + loadedState.conversations.contains(where: { $0.tags.contains(where: { $0.name == tag }) }) { + base = base.filter { $0.tags.contains(where: { $0.name == tag }) } + } else { + loadedState.activeTagFilter = nil + } + + let query = loadedState.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { + loadedState.filteredConversations = base + return + } + + loadedState.filteredConversations = base.filter { conversation in + conversation.title.lowercased().contains(query) + || conversation.messages.contains { $0.content.lowercased().contains(query) } + } + } +} diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Observe.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Observe.swift index 360be4f0..85f9b7a7 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Observe.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel+Observe.swift @@ -29,24 +29,9 @@ extension ConversationListViewModel { .notifications(named: .conversationDidUpdate) for await _ in notifications { guard let self else { return } - await MainActor.run { self.reloadConversations() } + await self.reloadConversations() } } } - func observeCloudConversationChanges() { - Task { [weak self] in - let notifications = NotificationCenter.default - .notifications(named: .conversationCloudDidChange) - for await _ in notifications { - guard let self else { return } - self.cloudChangeTask?.cancel() - self.cloudChangeTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(500)) - guard !Task.isCancelled else { return } - self?.synchronizeAndReloadConversations() - } - } - } - } } diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel.swift index 8c38762a..492dfffd 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ConversationListViewModel.swift @@ -102,11 +102,10 @@ final class ConversationListViewModel { private let exportBackupUseCase: ExportBackupUseCaseProtocol private let importConversationsUseCase: ImportConversationsUseCaseProtocol private let settingsManager: SettingsManagerProtocol - private let conversationCloudObserver: ConversationCloudObserving - private var errorDismissTask: Task? + private let cloudRetryDelays: [Duration] + var errorDismissTask: Task? var hasStartedInitialLoad = false - var cloudChangeTask: Task? var cloudRetryTask: Task? var onConversationSelected: ((Conversation?) -> Void)? var onPrivateChatSelected: (() -> Void)? @@ -125,7 +124,7 @@ final class ConversationListViewModel { exportBackupUseCase: ExportBackupUseCaseProtocol = ExportBackupUseCase(), importConversationsUseCase: ImportConversationsUseCaseProtocol = ImportConversationsUseCase(), settingsManager: SettingsManagerProtocol = SettingsManager(), - conversationCloudObserver: ConversationCloudObserving? = nil + cloudRetryDelays: [Duration] = [.seconds(1), .seconds(2), .seconds(4), .seconds(8)] ) { self.state = state self.loadConversationsUseCase = loadConversationsUseCase @@ -138,11 +137,9 @@ final class ConversationListViewModel { self.exportBackupUseCase = exportBackupUseCase self.importConversationsUseCase = importConversationsUseCase self.settingsManager = settingsManager - self.conversationCloudObserver = conversationCloudObserver - ?? ConversationCloudObserver(settingsManager: settingsManager) + self.cloudRetryDelays = cloudRetryDelays observeAppDataReset() observeConversationUpdated() - observeCloudConversationChanges() } // MARK: - Input functions @@ -167,31 +164,29 @@ final class ConversationListViewModel { } func refresh() { - synchronizeAndReloadConversations() + Task { await synchronizeAndReloadConversations() } } func refreshAsync() async { - synchronizeAndReloadConversations() - await Task.yield() + await synchronizeAndReloadConversations() } func loadData() { guard !hasStartedInitialLoad else { return } hasStartedInitialLoad = true state = .loading - conversationCloudObserver.start() Task { do { - let conversations = try loadConversationsUseCase.executeLocally() + let conversations = try await loadConversationsUseCase.executeLocally() state = .loaded(LoadedState( conversations: conversations, filteredConversations: conversations )) - // Let SwiftUI render local data before doing synchronous iCloud work. + // Let SwiftUI render local data before starting iCloud work. await Task.yield() - synchronizeAndReloadConversations() + await synchronizeAndReloadConversations() } catch { state = .loaded(LoadedState(errorMessage: error.localizedDescription)) scheduleErrorDismiss() @@ -211,12 +206,11 @@ final class ConversationListViewModel { } } - func reloadConversations() { + func reloadConversations() async { guard case .loaded(var loadedState) = state else { return } - conversationCloudObserver.start() do { - loadedState.conversations = try loadConversationsUseCase.executeLocally() + loadedState.conversations = try await loadConversationsUseCase.executeLocally() loadedState.errorMessage = nil applySearchFilter(&loadedState) state = .loaded(loadedState) @@ -227,11 +221,11 @@ final class ConversationListViewModel { } } - func synchronizeAndReloadConversations(scheduleRetry: Bool = true) { - let result = syncConversationsUseCase.execute() - reloadConversations() + func synchronizeAndReloadConversations(retryAttempt: Int = 0) async { + let result = await syncConversationsUseCase.execute() + await reloadConversations() - guard result == .pendingDownload, scheduleRetry else { + guard result == .pendingDownload, retryAttempt < cloudRetryDelays.count else { if result != .pendingDownload { cloudRetryTask?.cancel() cloudRetryTask = nil @@ -240,9 +234,11 @@ final class ConversationListViewModel { } cloudRetryTask?.cancel() cloudRetryTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - self?.synchronizeAndReloadConversations(scheduleRetry: false) + guard let self else { return } + try? await Task.sleep(for: self.cloudRetryDelays[retryAttempt]) + guard !Task.isCancelled, + self.settingsManager.getIsCloudSyncEnabled() else { return } + await self.synchronizeAndReloadConversations(retryAttempt: retryAttempt + 1) } } } @@ -255,13 +251,13 @@ private extension ConversationListViewModel { case .tapped(let conversation): selectConversation(conversation) case .deleted(let id): - deleteConversation(id) + Task { await deleteConversation(id) } case .pinToggled(let id): - togglePin(id) + Task { await togglePin(id) } case .tagsUpdated(let id, let tags): - updateTags(id, tags: tags) + Task { await updateTags(id, tags: tags) } case .titleEdited(let id, let title): - renameConversation(id, newTitle: title) + Task { await renameConversation(id, newTitle: title) } } } @@ -277,11 +273,11 @@ private extension ConversationListViewModel { func handleBackupEvent(_ event: BackupEvent) { switch event { case .exportTapped: - exportBackup() + Task { await exportBackup() } case .dataConsumed: clearBackupData() case .imported(let data): - importBackup(data) + Task { await importBackup(data) } case .resultConsumed: clearImportResult() case .errorConsumed: @@ -313,11 +309,10 @@ private extension ConversationListViewModel { onConversationSelected?(conversation) } - func deleteConversation(_ id: UUID) { - guard case .loaded(var loadedState) = state else { return } - + func deleteConversation(_ id: UUID) async { do { - try deleteConversationUseCase.execute(id) + try await deleteConversationUseCase.execute(id) + guard case .loaded(var loadedState) = state else { return } loadedState.conversations.removeAll { $0.id == id } if loadedState.selectedConversation?.id == id { loadedState.selectedConversation = nil @@ -326,6 +321,7 @@ private extension ConversationListViewModel { applySearchFilter(&loadedState) state = .loaded(loadedState) } catch { + guard case .loaded(var loadedState) = state else { return } loadedState.errorMessage = error.localizedDescription state = .loaded(loadedState) scheduleErrorDismiss() @@ -339,57 +335,35 @@ private extension ConversationListViewModel { state = .loaded(loadedState) } - func applySearchFilter(_ loadedState: inout LoadedState) { - var base = loadedState.conversations - - if let tag = loadedState.activeTagFilter, - loadedState.conversations.contains(where: { $0.tags.contains(where: { $0.name == tag }) }) { - base = base.filter { $0.tags.contains(where: { $0.name == tag }) } - } else { - loadedState.activeTagFilter = nil - } - - let query = loadedState.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !query.isEmpty else { - loadedState.filteredConversations = base - return - } - - loadedState.filteredConversations = base.filter { conversation in - if conversation.title.lowercased().contains(query) { - return true - } - return conversation.messages.contains { message in - message.content.lowercased().contains(query) - } - } - } - - func togglePin(_ id: UUID) { - guard case .loaded(var loadedState) = state else { return } - guard let index = loadedState.conversations.firstIndex(where: { $0.id == id }) else { return } - let newValue = !loadedState.conversations[index].isPinned + func togglePin(_ id: UUID) async { + guard case .loaded(let initialState) = state, + let conversation = initialState.conversations.first(where: { $0.id == id }) else { return } + let newValue = !conversation.isPinned do { - try pinConversationUseCase.execute(id, isPinned: newValue) + try await pinConversationUseCase.execute(id, isPinned: newValue) + guard case .loaded(var loadedState) = state, + let index = loadedState.conversations.firstIndex(where: { $0.id == id }) else { return } loadedState.conversations[index].isPinned = newValue applySearchFilter(&loadedState) state = .loaded(loadedState) } catch { + guard case .loaded(var loadedState) = state else { return } loadedState.errorMessage = error.localizedDescription state = .loaded(loadedState) scheduleErrorDismiss() } } - func updateTags(_ id: UUID, tags: [ConversationTag]) { - guard case .loaded(var loadedState) = state else { return } - guard let index = loadedState.conversations.firstIndex(where: { $0.id == id }) else { return } + func updateTags(_ id: UUID, tags: [ConversationTag]) async { do { - let savedTags = try updateConversationTagsUseCase.execute(id, tags: tags) + let savedTags = try await updateConversationTagsUseCase.execute(id, tags: tags) + guard case .loaded(var loadedState) = state, + let index = loadedState.conversations.firstIndex(where: { $0.id == id }) else { return } loadedState.conversations[index].tags = savedTags applySearchFilter(&loadedState) state = .loaded(loadedState) } catch { + guard case .loaded(var loadedState) = state else { return } loadedState.errorMessage = error.localizedDescription state = .loaded(loadedState) scheduleErrorDismiss() @@ -403,31 +377,36 @@ private extension ConversationListViewModel { state = .loaded(loadedState) } - func renameConversation(_ id: UUID, newTitle: String) { - guard case .loaded(var loadedState) = state else { return } + func renameConversation(_ id: UUID, newTitle: String) async { let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard let index = loadedState.conversations.firstIndex(where: { $0.id == id }) else { return } + guard !trimmed.isEmpty, + case .loaded(let initialState) = state, + initialState.conversations.contains(where: { $0.id == id }) else { return } do { - try renameConversationUseCase.execute(id, newTitle: trimmed) + try await renameConversationUseCase.execute(id, newTitle: trimmed) + guard case .loaded(var loadedState) = state, + let index = loadedState.conversations.firstIndex(where: { $0.id == id }) else { return } loadedState.conversations[index].title = trimmed loadedState.conversations[index].updatedAt = Date() applySearchFilter(&loadedState) state = .loaded(loadedState) } catch { + guard case .loaded(var loadedState) = state else { return } loadedState.errorMessage = error.localizedDescription state = .loaded(loadedState) scheduleErrorDismiss() } } - func exportBackup() { - guard case .loaded(var loadedState) = state else { return } + func exportBackup() async { do { - loadedState.backupData = try exportBackupUseCase.execute() + let data = try await exportBackupUseCase.execute() + guard case .loaded(var loadedState) = state else { return } + loadedState.backupData = data loadedState.errorMessage = nil state = .loaded(loadedState) } catch { + guard case .loaded(var loadedState) = state else { return } showError(error, in: &loadedState) } } @@ -438,17 +417,19 @@ private extension ConversationListViewModel { state = .loaded(loadedState) } - func importBackup(_ data: Data) { - guard case .loaded(var loadedState) = state else { return } + func importBackup(_ data: Data) async { do { - let result = try importConversationsUseCase.execute(data) - loadedState.conversations = try loadConversationsUseCase.executeLocally() + let result = try await importConversationsUseCase.execute(data) + let conversations = try await loadConversationsUseCase.executeLocally() + guard case .loaded(var loadedState) = state else { return } + loadedState.conversations = conversations loadedState.importResult = result loadedState.errorMessage = nil applySearchFilter(&loadedState) state = .loaded(loadedState) NotificationCenter.default.post(name: .conversationDidUpdate, object: nil) } catch { + guard case .loaded(var loadedState) = state else { return } showError(error, in: &loadedState) } } @@ -494,4 +475,5 @@ private extension ConversationListViewModel { state = .loaded(currentState) } } + } diff --git a/openclient-llm/Shared/Features/Home/ViewModels/HomeViewModel.swift b/openclient-llm/Shared/Features/Home/ViewModels/HomeViewModel.swift index 0e25ca80..77b89787 100644 --- a/openclient-llm/Shared/Features/Home/ViewModels/HomeViewModel.swift +++ b/openclient-llm/Shared/Features/Home/ViewModels/HomeViewModel.swift @@ -122,7 +122,7 @@ private extension HomeViewModel { func resolveSpotlightConversation(id: UUID) { Task { - guard let conversations = try? loadConversationsUseCase.execute(), + guard let conversations = try? await loadConversationsUseCase.execute(), let conversation = conversations.first(where: { $0.id == id }) else { return } pendingConversation = conversation } diff --git a/openclient-llm/Shared/Features/Launch/UseCases/ResetAppDataUseCase.swift b/openclient-llm/Shared/Features/Launch/UseCases/ResetAppDataUseCase.swift index 9bbff822..30e175de 100644 --- a/openclient-llm/Shared/Features/Launch/UseCases/ResetAppDataUseCase.swift +++ b/openclient-llm/Shared/Features/Launch/UseCases/ResetAppDataUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol ResetAppDataUseCaseProtocol: Sendable { - func execute() + func execute() async throws } struct ResetAppDataUseCase: ResetAppDataUseCaseProtocol { @@ -19,6 +19,7 @@ struct ResetAppDataUseCase: ResetAppDataUseCaseProtocol { private let conversationRepository: ConversationRepositoryProtocol private let userProfileManager: UserProfileManagerProtocol private let memoryManager: MemoryManagerProtocol + private let categoryOperationGate: CloudCategoryOperationGate // MARK: - Init @@ -26,21 +27,25 @@ struct ResetAppDataUseCase: ResetAppDataUseCaseProtocol { settingsManager: SettingsManagerProtocol = SettingsManager(), conversationRepository: ConversationRepositoryProtocol = ConversationRepository(), userProfileManager: UserProfileManagerProtocol = UserProfileManager(), - memoryManager: MemoryManagerProtocol = MemoryManager() + memoryManager: MemoryManagerProtocol = MemoryManager(), + categoryOperationGate: CloudCategoryOperationGate = .shared ) { self.settingsManager = settingsManager self.conversationRepository = conversationRepository self.userProfileManager = userProfileManager self.memoryManager = memoryManager + self.categoryOperationGate = categoryOperationGate } // MARK: - Execute - func execute() { - // Disable cloud sync first so subsequent deletes do NOT touch iCloud. - settingsManager.deleteAll() - try? conversationRepository.deleteAll() - userProfileManager.deleteLocalProfile() - memoryManager.deleteAll() + func execute() async throws { + try await categoryOperationGate.fence { + // Disable cloud sync while profile cloud operations remain fenced. + await settingsManager.deleteAll() + try await conversationRepository.cancelSynchronizationAndDeleteAll() + try await userProfileManager.deleteLocalProfile() + try await memoryManager.deleteAll() + } } } diff --git a/openclient-llm/Shared/Features/Launch/ViewModels/LaunchViewModel.swift b/openclient-llm/Shared/Features/Launch/ViewModels/LaunchViewModel.swift index 7f794495..fadda92c 100644 --- a/openclient-llm/Shared/Features/Launch/ViewModels/LaunchViewModel.swift +++ b/openclient-llm/Shared/Features/Launch/ViewModels/LaunchViewModel.swift @@ -18,6 +18,7 @@ final class LaunchViewModel { case onboardingCompleted case availableUpdateDismissed case remoteBannerDismissed + case resetRetried } enum State: Equatable { @@ -26,6 +27,7 @@ final class LaunchViewModel { case home case maintenance case forceUpdate(RemoteConfig.PlatformUpdate) + case resetFailed } private(set) var state: State @@ -77,11 +79,11 @@ final class LaunchViewModel { attachmentMigrationUseCase.execute() let isCompleted = checkOnboardingUseCase.execute() - if !isCompleted { - resetAppDataUseCase.execute() + if isCompleted { + startLaunch(isOnboardingCompleted: true) + } else { + resetBeforeOnboarding() } - - startLaunch(isOnboardingCompleted: isCompleted) case .onboardingCompleted: state = .home case .availableUpdateDismissed: @@ -90,6 +92,9 @@ final class LaunchViewModel { guard let remoteBanner else { return } settingsManager.setDismissedRemoteBannerKey(remoteBanner.id) self.remoteBanner = nil + case .resetRetried: + state = .loading + resetBeforeOnboarding() } } @@ -103,6 +108,18 @@ final class LaunchViewModel { } } + func resetBeforeOnboarding() { + Task { + do { + try await resetAppDataUseCase.execute() + startLaunch(isOnboardingCompleted: false) + } catch { + LogManager.error("Initial app data reset failed") + state = .resetFailed + } + } + } + func startLaunch(isOnboardingCompleted: Bool) { Task { [weak self, launchDelay] in guard let self else { return } diff --git a/openclient-llm/Shared/Features/Launch/Views/LaunchView.swift b/openclient-llm/Shared/Features/Launch/Views/LaunchView.swift index 1ccdf5c5..c1047340 100644 --- a/openclient-llm/Shared/Features/Launch/Views/LaunchView.swift +++ b/openclient-llm/Shared/Features/Launch/Views/LaunchView.swift @@ -68,6 +68,8 @@ struct LaunchView: View { MaintenanceView() case .forceUpdate(let update): ForceUpdateView(update: update) + case .resetFailed: + resetFailureView } } } @@ -111,6 +113,11 @@ struct LaunchView: View { ForceUpdateView(update: update) .transition(.opacity) } + + if viewModel.state == .resetFailed { + resetFailureView + .transition(.opacity) + } } .animation(.smooth, value: viewModel.state) } @@ -129,6 +136,19 @@ private extension LaunchView { ) } + var resetFailureView: some View { + ContentUnavailableView { + Label(String(localized: "App Data Reset Failed"), systemImage: "exclamationmark.triangle") + } description: { + Text(String(localized: "Some local data could not be reset. No remaining data was discarded.")) + } actions: { + Button(String(localized: "Retry")) { + viewModel.send(.resetRetried) + } + .buttonStyle(.borderedProminent) + } + } + func handleRemoteBannerAction() { guard let remoteBanner = viewModel.remoteBanner else { return } @@ -164,7 +184,7 @@ private extension LaunchView { var shouldCoverMacOSHome: Bool { switch viewModel.state { - case .loading, .maintenance, .forceUpdate: + case .loading, .maintenance, .forceUpdate, .resetFailed: true case .onboarding, .home: false diff --git a/openclient-llm/Shared/Features/PromptTemplates/Models/PromptTemplate.swift b/openclient-llm/Shared/Features/PromptTemplates/Models/PromptTemplate.swift index 506a2dbc..f8d2dca5 100644 --- a/openclient-llm/Shared/Features/PromptTemplates/Models/PromptTemplate.swift +++ b/openclient-llm/Shared/Features/PromptTemplates/Models/PromptTemplate.swift @@ -8,7 +8,7 @@ import Foundation -struct PromptTemplate: Identifiable, Equatable, Sendable, Codable { +nonisolated struct PromptTemplate: Identifiable, Equatable, Sendable, Codable { // MARK: - Properties let id: UUID @@ -16,6 +16,7 @@ struct PromptTemplate: Identifiable, Equatable, Sendable, Codable { var content: String let isBuiltIn: Bool let createdAt: Date + let updatedAt: Date // MARK: - Init @@ -24,12 +25,32 @@ struct PromptTemplate: Identifiable, Equatable, Sendable, Codable { title: String, content: String, isBuiltIn: Bool = false, - createdAt: Date = Date() + createdAt: Date = Date(), + updatedAt: Date? = nil ) { self.id = id self.title = title self.content = content self.isBuiltIn = isBuiltIn self.createdAt = createdAt + self.updatedAt = updatedAt ?? createdAt } + + // MARK: - Codable + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + content = try container.decode(String.self, forKey: .content) + isBuiltIn = try container.decode(Bool.self, forKey: .isBuiltIn) + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt + } +} + +nonisolated struct PromptTemplateCloudSnapshot: Sendable { + let templates: [PromptTemplate] + let templateData: [UUID: Data] + let deletionMarkers: [UUID: CloudDeletionMarker] } diff --git a/openclient-llm/Shared/Features/PromptTemplates/Repositories/PromptTemplateRepository.swift b/openclient-llm/Shared/Features/PromptTemplates/Repositories/PromptTemplateRepository.swift index 6ba03935..4f629561 100644 --- a/openclient-llm/Shared/Features/PromptTemplates/Repositories/PromptTemplateRepository.swift +++ b/openclient-llm/Shared/Features/PromptTemplates/Repositories/PromptTemplateRepository.swift @@ -6,6 +6,7 @@ // Copyright © 2026 Arturo Carretero Calvo. All rights reserved. // +import CryptoKit import Foundation // Stable UUIDs for built-in templates — never change; used to identify them across launches @@ -20,9 +21,9 @@ private enum BuiltInTemplateID { } protocol PromptTemplateRepositoryProtocol: Sendable { - func loadAll() throws -> [PromptTemplate] - func save(_ template: PromptTemplate) throws - func delete(_ templateId: UUID) throws + func loadAll() async throws -> [PromptTemplate] + func save(_ template: PromptTemplate) async throws + func delete(_ templateId: UUID) async throws } struct PromptTemplateRepository: PromptTemplateRepositoryProtocol { @@ -38,66 +39,89 @@ struct PromptTemplateRepository: PromptTemplateRepositoryProtocol { init( fileManager: FileManager = .default, settingsManager: SettingsManagerProtocol = SettingsManager(), - cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager() + cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager(), + directoryURL: URL? = nil ) { self.fileManager = fileManager let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - self.directoryURL = documentsURL.appendingPathComponent("PromptTemplates", isDirectory: true) + self.directoryURL = directoryURL + ?? documentsURL.appendingPathComponent("PromptTemplates", isDirectory: true) self.settingsManager = settingsManager self.cloudSyncManager = cloudSyncManager } // MARK: - Public - func loadAll() throws -> [PromptTemplate] { + func loadAll() async throws -> [PromptTemplate] { LogManager.debug("loadAll prompt templates") try ensureDirectoryExists() - var localCustom = try loadCustomTemplates() + var deletionMarkers = try loadDeletionMarkers() + let localTemplates = try loadCustomTemplates() + var mergedTemplates = localTemplates.filter { shouldKeep($0.template, markers: deletionMarkers) } if settingsManager.getIsCloudSyncEnabled() { - let cloudTemplates = (try? cloudSyncManager.loadTemplatesFromCloud()) ?? [] - let cloudIds = cloudSyncManager.allCloudTemplateIds() - - localCustom = mergeTemplates(local: localCustom, cloud: cloudTemplates, cloudIds: cloudIds) - - if cloudIds != nil { - let mergedIds = Set(localCustom.map(\.id)) - cleanupLocalFiles(keeping: mergedIds) - } - - for template in localCustom { - try saveLocal(template) + try await retryCloudDeletions(deletionMarkers) + let snapshot = try await cloudSyncManager.loadTemplatesFromCloud() + deletionMarkers = mergeDeletionMarkers(local: deletionMarkers, cloud: snapshot.deletionMarkers) + try saveDeletionMarkers(deletionMarkers) + mergedTemplates = try mergeTemplates( + local: localTemplates, + cloud: storedCloudTemplates(snapshot), + markers: deletionMarkers + ) + try persistLocalOutput(mergedTemplates) + try await cloudSyncManager.syncTemplatesToCloud(mergedTemplates.map(\.template)) + for storedTemplate in mergedTemplates where shouldSupersedeMarker( + storedTemplate.template, + markers: deletionMarkers + ) { + try removeDeletionMarker(for: storedTemplate.template.id) } + } else { + try persistLocalOutput(mergedTemplates) } - let all = builtIns() + localCustom.sorted { $0.createdAt < $1.createdAt } + let customTemplates = mergedTemplates.map(\.template).sorted { $0.createdAt < $1.createdAt } + let all = builtIns() + customTemplates LogManager.success("loadAll returned \(all.count) prompt templates") return all } - func save(_ template: PromptTemplate) throws { + func save(_ template: PromptTemplate) async throws { LogManager.debug("save prompt template id=\(template.id) title='\(template.title)'") guard !template.isBuiltIn else { return } try ensureDirectoryExists() - try saveLocal(template) + let revisedTemplate = templateWithCurrentRevision(template) + try saveLocal(revisedTemplate) if settingsManager.getIsCloudSyncEnabled() { - try? cloudSyncManager.syncTemplatesToCloud([template]) + try await retryCloudDeletions(try loadDeletionMarkers()) + try await cloudSyncManager.syncTemplatesToCloud([revisedTemplate]) } + try removeDeletionMarker(for: revisedTemplate.id) - LogManager.success("saved prompt template id=\(template.id)") + LogManager.success("saved prompt template id=\(revisedTemplate.id)") } - func delete(_ templateId: UUID) throws { + func delete(_ templateId: UUID) async throws { LogManager.debug("delete prompt template id=\(templateId)") let fileURL = directoryURL.appendingPathComponent("\(templateId.uuidString).json") - guard fileManager.fileExists(atPath: fileURL.path) else { return } - try fileManager.removeItem(at: fileURL) + let localRevision = try? decoder().decode( + PromptTemplate.self, + from: Data(contentsOf: fileURL) + ).updatedAt + let markerRevision = try loadDeletionMarkers()[templateId]?.deletedAt + let deletionFloor = max(localRevision ?? .distantPast, markerRevision ?? .distantPast) + let deletedAt = nextRevision(after: deletionFloor) + try saveDeletionMarker(CloudDeletionMarker(id: templateId, deletedAt: deletedAt)) + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } LogManager.success("deleted prompt template id=\(templateId)") if settingsManager.getIsCloudSyncEnabled() { - try? cloudSyncManager.deleteTemplateFromCloud(templateId) + try await cloudSyncManager.deleteTemplateFromCloud(templateId, deletedAt: deletedAt) } } } @@ -105,12 +129,25 @@ struct PromptTemplateRepository: PromptTemplateRepositoryProtocol { // MARK: - Private private extension PromptTemplateRepository { + struct StoredTemplate { + let template: PromptTemplate + let data: Data + } + + var deletionDirectoryURL: URL { + directoryURL.appendingPathComponent(".DeletionMetadata", isDirectory: true) + } + + var recoveryDirectoryURL: URL { + directoryURL.deletingLastPathComponent().appendingPathComponent("PromptTemplateRecovery", isDirectory: true) + } + func ensureDirectoryExists() throws { guard !fileManager.fileExists(atPath: directoryURL.path) else { return } try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) } - func loadCustomTemplates() throws -> [PromptTemplate] { + func loadCustomTemplates() throws -> [StoredTemplate] { let contents = try fileManager.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: nil, @@ -122,7 +159,10 @@ private extension PromptTemplateRepository { .filter { $0.pathExtension == "json" } .compactMap { url in guard let data = try? Data(contentsOf: url) else { return nil } - return try? decoder.decode(PromptTemplate.self, from: data) + guard let template = try? decoder.decode(PromptTemplate.self, from: data), + UUID(uuidString: url.deletingPathExtension().lastPathComponent) == template.id, + !template.isBuiltIn else { return nil } + return StoredTemplate(template: template, data: data) } } @@ -135,44 +175,157 @@ private extension PromptTemplateRepository { try data.write(to: fileURL, options: .atomic) } + func loadDeletionMarkers() throws -> [UUID: CloudDeletionMarker] { + guard fileManager.fileExists(atPath: deletionDirectoryURL.path) else { return [:] } + let urls = try fileManager.contentsOfDirectory(at: deletionDirectoryURL, includingPropertiesForKeys: nil) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try Dictionary(uniqueKeysWithValues: urls.compactMap { url in + guard url.pathExtension == "json" else { return nil } + let marker = try decoder.decode(CloudDeletionMarker.self, from: Data(contentsOf: url)) + return (marker.id, marker) + }) + } + + func saveDeletionMarker(_ marker: CloudDeletionMarker) throws { + try fileManager.createDirectory(at: deletionDirectoryURL, withIntermediateDirectories: true) + let url = deletionDirectoryURL.appendingPathComponent("\(marker.id.uuidString).json") + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let existing = try? decoder().decode(CloudDeletionMarker.self, from: Data(contentsOf: url)), + existing.deletedAt >= marker.deletedAt { + return + } + try encoder.encode(marker).write(to: url, options: .atomic) + } + + func saveDeletionMarkers(_ markers: [UUID: CloudDeletionMarker]) throws { + for marker in markers.values { + try saveDeletionMarker(marker) + } + } + + func removeDeletionMarker(for id: UUID) throws { + let url = deletionDirectoryURL.appendingPathComponent("\(id.uuidString).json") + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } + + func retryCloudDeletions(_ markers: [UUID: CloudDeletionMarker]) async throws { + for marker in markers.values { + try await cloudSyncManager.deleteTemplateFromCloud(marker.id, deletedAt: marker.deletedAt) + } + } + func mergeTemplates( - local: [PromptTemplate], - cloud: [PromptTemplate], - cloudIds: Set? - ) -> [PromptTemplate] { - var merged: [UUID: PromptTemplate] = [:] - - for template in local { - if let cloudIds { - guard cloudIds.contains(template.id) else { continue } + local: [StoredTemplate], + cloud: [StoredTemplate], + markers: [UUID: CloudDeletionMarker] + ) throws -> [StoredTemplate] { + let localById = Dictionary(uniqueKeysWithValues: local.map { ($0.template.id, $0) }) + let cloudById = Dictionary(uniqueKeysWithValues: cloud.map { ($0.template.id, $0) }) + let ids = Set(localById.keys).union(cloudById.keys) + return try ids.compactMap { id in + let localCandidate = localById[id].flatMap { + shouldKeep($0.template, markers: markers) ? $0 : nil } - merged[template.id] = template + let cloudCandidate = cloudById[id].flatMap { + shouldKeep($0.template, markers: markers) ? $0 : nil + } + guard let localCandidate, let cloudCandidate else { + return localCandidate ?? cloudCandidate + } + guard localCandidate.data != cloudCandidate.data else { return localCandidate } + let winner = preferredTemplate(local: localCandidate, cloud: cloudCandidate) + try preserveForRecovery(winner.data == localCandidate.data ? cloudCandidate : localCandidate) + return winner + } + } + + func preferredTemplate(local: StoredTemplate, cloud: StoredTemplate) -> StoredTemplate { + if local.template.updatedAt == cloud.template.updatedAt { + return cloud.data.lexicographicallyPrecedes(local.data) ? local : cloud + } + return local.template.updatedAt > cloud.template.updatedAt ? local : cloud + } + + func storedCloudTemplates(_ snapshot: PromptTemplateCloudSnapshot) -> [StoredTemplate] { + snapshot.templates.compactMap { template in + guard !template.isBuiltIn, let data = snapshot.templateData[template.id] else { return nil } + return StoredTemplate(template: template, data: data) } + } - for cloudTemplate in cloud { - // Cloud wins on conflict (most recently created custom template takes precedence) - merged[cloudTemplate.id] = cloudTemplate + func mergeDeletionMarkers( + local: [UUID: CloudDeletionMarker], + cloud: [UUID: CloudDeletionMarker] + ) -> [UUID: CloudDeletionMarker] { + cloud.reduce(into: local) { result, entry in + if result[entry.key]?.deletedAt ?? .distantPast < entry.value.deletedAt { + result[entry.key] = entry.value + } } + } - return Array(merged.values) + func shouldKeep(_ template: PromptTemplate, markers: [UUID: CloudDeletionMarker]) -> Bool { + guard let marker = markers[template.id] else { return true } + return template.updatedAt > marker.deletedAt } - func cleanupLocalFiles(keeping ids: Set) { - guard let fileURLs = try? fileManager.contentsOfDirectory( + func shouldSupersedeMarker(_ template: PromptTemplate, markers: [UUID: CloudDeletionMarker]) -> Bool { + guard let marker = markers[template.id] else { return false } + return template.updatedAt > marker.deletedAt + } + + func persistLocalOutput(_ templates: [StoredTemplate]) throws { + let ids = Set(templates.map(\.template.id)) + for storedTemplate in templates { + try saveLocal(storedTemplate.template) + } + let urls = try fileManager.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles - ) else { return } + ) + for url in urls where url.pathExtension == "json" { + guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent), + !ids.contains(id) else { continue } + try fileManager.removeItem(at: url) + } + } - for url in fileURLs where url.pathExtension == "json" { - if let uuid = UUID(uuidString: url.deletingPathExtension().lastPathComponent), - !ids.contains(uuid) { - try? fileManager.removeItem(at: url) - LogManager.debug("Cleaned up local template file: \(uuid)") - } + func preserveForRecovery(_ storedTemplate: StoredTemplate) throws { + try fileManager.createDirectory(at: recoveryDirectoryURL, withIntermediateDirectories: true) + let digest = SHA256.hash(data: storedTemplate.data).prefix(8).map { String(format: "%02x", $0) }.joined() + let url = recoveryDirectoryURL.appendingPathComponent("\(storedTemplate.template.id.uuidString)-\(digest).json") + if let existing = try? Data(contentsOf: url), existing == storedTemplate.data { return } + try storedTemplate.data.write(to: url, options: .atomic) + guard try Data(contentsOf: url) == storedTemplate.data else { + throw CocoaError(.fileWriteUnknown) } } + func templateWithCurrentRevision(_ template: PromptTemplate) -> PromptTemplate { + PromptTemplate( + id: template.id, + title: template.title, + content: template.content, + isBuiltIn: template.isBuiltIn, + createdAt: template.createdAt, + updatedAt: max(template.updatedAt, Date()) + ) + } + + func nextRevision(after revision: Date) -> Date { + max(Date(), revision.addingTimeInterval(1)) + } + + func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + func builtIns() -> [PromptTemplate] { let codingContent = String(localized: """ You are an expert software engineer. Help with code, explain concepts clearly, \ diff --git a/openclient-llm/Shared/Features/PromptTemplates/UseCases/DeletePromptTemplateUseCase.swift b/openclient-llm/Shared/Features/PromptTemplates/UseCases/DeletePromptTemplateUseCase.swift index a038a9cc..dae9f50a 100644 --- a/openclient-llm/Shared/Features/PromptTemplates/UseCases/DeletePromptTemplateUseCase.swift +++ b/openclient-llm/Shared/Features/PromptTemplates/UseCases/DeletePromptTemplateUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol DeletePromptTemplateUseCaseProtocol: Sendable { - func execute(_ templateId: UUID) throws + func execute(_ templateId: UUID) async throws } struct DeletePromptTemplateUseCase: DeletePromptTemplateUseCaseProtocol { @@ -25,7 +25,7 @@ struct DeletePromptTemplateUseCase: DeletePromptTemplateUseCaseProtocol { // MARK: - Execute - func execute(_ templateId: UUID) throws { - try repository.delete(templateId) + func execute(_ templateId: UUID) async throws { + try await repository.delete(templateId) } } diff --git a/openclient-llm/Shared/Features/PromptTemplates/UseCases/LoadPromptTemplatesUseCase.swift b/openclient-llm/Shared/Features/PromptTemplates/UseCases/LoadPromptTemplatesUseCase.swift index 00f15cbd..d96f87da 100644 --- a/openclient-llm/Shared/Features/PromptTemplates/UseCases/LoadPromptTemplatesUseCase.swift +++ b/openclient-llm/Shared/Features/PromptTemplates/UseCases/LoadPromptTemplatesUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol LoadPromptTemplatesUseCaseProtocol: Sendable { - func execute() throws -> [PromptTemplate] + func execute() async throws -> [PromptTemplate] } struct LoadPromptTemplatesUseCase: LoadPromptTemplatesUseCaseProtocol { @@ -25,7 +25,7 @@ struct LoadPromptTemplatesUseCase: LoadPromptTemplatesUseCaseProtocol { // MARK: - Execute - func execute() throws -> [PromptTemplate] { - try repository.loadAll() + func execute() async throws -> [PromptTemplate] { + try await repository.loadAll() } } diff --git a/openclient-llm/Shared/Features/PromptTemplates/UseCases/SavePromptTemplateUseCase.swift b/openclient-llm/Shared/Features/PromptTemplates/UseCases/SavePromptTemplateUseCase.swift index 61d9ddd2..353912e7 100644 --- a/openclient-llm/Shared/Features/PromptTemplates/UseCases/SavePromptTemplateUseCase.swift +++ b/openclient-llm/Shared/Features/PromptTemplates/UseCases/SavePromptTemplateUseCase.swift @@ -9,7 +9,7 @@ import Foundation protocol SavePromptTemplateUseCaseProtocol: Sendable { - func execute(_ template: PromptTemplate) throws + func execute(_ template: PromptTemplate) async throws } struct SavePromptTemplateUseCase: SavePromptTemplateUseCaseProtocol { @@ -25,7 +25,7 @@ struct SavePromptTemplateUseCase: SavePromptTemplateUseCaseProtocol { // MARK: - Execute - func execute(_ template: PromptTemplate) throws { - try repository.save(template) + func execute(_ template: PromptTemplate) async throws { + try await repository.save(template) } } diff --git a/openclient-llm/Shared/Features/PromptTemplates/ViewModels/PromptTemplatesViewModel.swift b/openclient-llm/Shared/Features/PromptTemplates/ViewModels/PromptTemplatesViewModel.swift index d96dbca4..a7e02c03 100644 --- a/openclient-llm/Shared/Features/PromptTemplates/ViewModels/PromptTemplatesViewModel.swift +++ b/openclient-llm/Shared/Features/PromptTemplates/ViewModels/PromptTemplatesViewModel.swift @@ -71,13 +71,19 @@ final class PromptTemplatesViewModel { private extension PromptTemplatesViewModel { func loadTemplates() { - do { - let all = try loadTemplatesUseCase.execute() - let builtIns = all.filter(\.isBuiltIn).sorted { $0.title < $1.title } - let custom = all.filter { !$0.isBuiltIn }.sorted { $0.title < $1.title } - state = .loaded(.init(builtInTemplates: builtIns, customTemplates: custom)) - } catch { - state = .loaded(.init(builtInTemplates: [], customTemplates: [], errorMessage: error.localizedDescription)) + Task { + do { + let all = try await loadTemplatesUseCase.execute() + let builtIns = all.filter(\.isBuiltIn).sorted { $0.title < $1.title } + let custom = all.filter { !$0.isBuiltIn }.sorted { $0.title < $1.title } + state = .loaded(.init(builtInTemplates: builtIns, customTemplates: custom)) + } catch { + state = .loaded(.init( + builtInTemplates: [], + customTemplates: [], + errorMessage: error.localizedDescription + )) + } } } @@ -89,34 +95,39 @@ private extension PromptTemplatesViewModel { title: title, content: content, isBuiltIn: false, - createdAt: editing.createdAt + createdAt: editing.createdAt, + updatedAt: Date() ) } else { template = PromptTemplate(title: title, content: content) } - do { - try saveTemplateUseCase.execute(template) - if editingTemplate == nil { - appReviewManager.requestReview() - } - loadTemplates() - } catch { - if case .loaded(var loadedState) = state { - loadedState.errorMessage = error.localizedDescription - state = .loaded(loadedState) + Task { + do { + try await saveTemplateUseCase.execute(template) + if editingTemplate == nil { + appReviewManager.requestReview() + } + loadTemplates() + } catch { + if case .loaded(var loadedState) = state { + loadedState.errorMessage = error.localizedDescription + state = .loaded(loadedState) + } } } } func deleteTemplate(_ template: PromptTemplate) { guard !template.isBuiltIn else { return } - do { - try deleteTemplateUseCase.execute(template.id) - loadTemplates() - } catch { - if case .loaded(var loadedState) = state { - loadedState.errorMessage = error.localizedDescription - state = .loaded(loadedState) + Task { + do { + try await deleteTemplateUseCase.execute(template.id) + loadTemplates() + } catch { + if case .loaded(var loadedState) = state { + loadedState.errorMessage = error.localizedDescription + state = .loaded(loadedState) + } } } } diff --git a/openclient-llm/Shared/Features/Settings/Models/AppSynchronizationResult.swift b/openclient-llm/Shared/Features/Settings/Models/AppSynchronizationResult.swift new file mode 100644 index 00000000..b0a86d9b --- /dev/null +++ b/openclient-llm/Shared/Features/Settings/Models/AppSynchronizationResult.swift @@ -0,0 +1,36 @@ +// +// AppSynchronizationResult.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated struct AppSynchronizationResult: Equatable, Sendable { + enum Category: CaseIterable, Hashable, Sendable { + case conversations + case profile + case memory + case promptTemplates + } + + enum Outcome: Equatable, Sendable { + case synchronized + case pendingDownload + case unavailable + case conflict + case failed + } + + let outcomes: [Category: Outcome] + + var isSuccessful: Bool { + Category.allCases.allSatisfy { outcomes[$0] == .synchronized } + } + + func categories(with outcome: Outcome) -> Set { + Set(Category.allCases.filter { outcomes[$0] == outcome }) + } +} diff --git a/openclient-llm/Shared/Features/Settings/Models/CloudUserProfileState.swift b/openclient-llm/Shared/Features/Settings/Models/CloudUserProfileState.swift new file mode 100644 index 00000000..79577d71 --- /dev/null +++ b/openclient-llm/Shared/Features/Settings/Models/CloudUserProfileState.swift @@ -0,0 +1,15 @@ +// +// CloudUserProfileState.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +nonisolated enum CloudUserProfileState: Equatable, Sendable { + case missing + case profile(UserProfile) + case deleted(CloudDeletionMarker) +} diff --git a/openclient-llm/Shared/Features/Settings/Models/MemoryItem.swift b/openclient-llm/Shared/Features/Settings/Models/MemoryItem.swift index ad682c73..cc6e1200 100644 --- a/openclient-llm/Shared/Features/Settings/Models/MemoryItem.swift +++ b/openclient-llm/Shared/Features/Settings/Models/MemoryItem.swift @@ -8,7 +8,7 @@ import Foundation -struct MemoryItem: Identifiable, Equatable, Sendable, Codable { +nonisolated struct MemoryItem: Identifiable, Equatable, Sendable, Codable { // MARK: - Properties enum Source: String, Codable, Sendable, Equatable { @@ -21,6 +21,7 @@ struct MemoryItem: Identifiable, Equatable, Sendable, Codable { var isEnabled: Bool let createdAt: Date let source: Source + var updatedAt: Date // MARK: - Init @@ -29,12 +30,45 @@ struct MemoryItem: Identifiable, Equatable, Sendable, Codable { content: String, isEnabled: Bool = true, createdAt: Date = Date(), - source: Source = .user + source: Source = .user, + updatedAt: Date? = nil ) { self.id = id self.content = content self.isEnabled = isEnabled self.createdAt = createdAt self.source = source + self.updatedAt = updatedAt ?? createdAt + } + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case id + case content + case isEnabled + case createdAt + case source + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + content = try container.decode(String.self, forKey: .content) + isEnabled = try container.decode(Bool.self, forKey: .isEnabled) + createdAt = try container.decode(Date.self, forKey: .createdAt) + source = try container.decode(Source.self, forKey: .source) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(content, forKey: .content) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(source, forKey: .source) + try container.encode(updatedAt, forKey: .updatedAt) } } diff --git a/openclient-llm/Shared/Features/Settings/Models/UserProfile.swift b/openclient-llm/Shared/Features/Settings/Models/UserProfile.swift index d3b5c8f2..d9d13abb 100644 --- a/openclient-llm/Shared/Features/Settings/Models/UserProfile.swift +++ b/openclient-llm/Shared/Features/Settings/Models/UserProfile.swift @@ -8,19 +8,34 @@ import Foundation -struct UserProfile: Equatable, Sendable, Codable { +nonisolated struct UserProfile: Equatable, Sendable, Codable { // MARK: - Properties var name: String var profileDescription: String var extraInfo: String + var modifiedAt: Date // MARK: - Init - init(name: String = "", profileDescription: String = "", extraInfo: String = "") { + init( + name: String = "", + profileDescription: String = "", + extraInfo: String = "", + modifiedAt: Date = Date() + ) { self.name = name self.profileDescription = profileDescription self.extraInfo = extraInfo + self.modifiedAt = modifiedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + profileDescription = try container.decode(String.self, forKey: .profileDescription) + extraInfo = try container.decode(String.self, forKey: .extraInfo) + modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? .distantPast } // MARK: - Computed diff --git a/openclient-llm/Shared/Features/Settings/UseCases/SynchronizeAppDataUseCase.swift b/openclient-llm/Shared/Features/Settings/UseCases/SynchronizeAppDataUseCase.swift new file mode 100644 index 00000000..1dcef1a8 --- /dev/null +++ b/openclient-llm/Shared/Features/Settings/UseCases/SynchronizeAppDataUseCase.swift @@ -0,0 +1,120 @@ +// +// SynchronizeAppDataUseCase.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +protocol SynchronizeAppDataUseCaseProtocol: Sendable { + func execute() async -> AppSynchronizationResult + func cancel() async +} + +struct SynchronizeAppDataUseCase: SynchronizeAppDataUseCaseProtocol { + // MARK: - Properties + + private let syncConversationsUseCase: SyncConversationsUseCaseProtocol + private let userProfileManager: UserProfileManagerProtocol + private let memoryManager: MemoryManagerProtocol + private let promptTemplateRepository: PromptTemplateRepositoryProtocol + + // MARK: - Init + + init( + syncConversationsUseCase: SyncConversationsUseCaseProtocol = SyncConversationsUseCase(), + userProfileManager: UserProfileManagerProtocol = UserProfileManager(), + memoryManager: MemoryManagerProtocol = MemoryManager(), + promptTemplateRepository: PromptTemplateRepositoryProtocol = PromptTemplateRepository() + ) { + self.syncConversationsUseCase = syncConversationsUseCase + self.userProfileManager = userProfileManager + self.memoryManager = memoryManager + self.promptTemplateRepository = promptTemplateRepository + } + + // MARK: - Execute + + func execute() async -> AppSynchronizationResult { + var outcomes: [AppSynchronizationResult.Category: AppSynchronizationResult.Outcome] = [:] + outcomes[.conversations] = await conversationOutcome() + guard !Task.isCancelled else { return AppSynchronizationResult(outcomes: outcomes) } + outcomes[.profile] = await profileOutcome() + guard !Task.isCancelled else { return AppSynchronizationResult(outcomes: outcomes) } + outcomes[.memory] = await throwingOutcome { try await memoryManager.synchronize() } + guard !Task.isCancelled else { return AppSynchronizationResult(outcomes: outcomes) } + outcomes[.promptTemplates] = await throwingOutcome { _ = try await promptTemplateRepository.loadAll() } + return AppSynchronizationResult(outcomes: outcomes) + } + + func cancel() async { + await syncConversationsUseCase.cancel() + } +} + +// MARK: - Private + +private extension SynchronizeAppDataUseCase { + func conversationOutcome() async -> AppSynchronizationResult.Outcome { + switch await syncConversationsUseCase.execute() { + case .synchronized: + .synchronized + case .pendingDownload: + .pendingDownload + case .unavailable: + .unavailable + case .failed: + .failed + } + } + + func profileOutcome() async -> AppSynchronizationResult.Outcome { + do { + let cloudState = try await userProfileManager.getCloudProfileState() + let localProfile = userProfileManager.getLocalProfile() + + switch cloudState { + case .missing, .deleted: + guard !localProfile.isEmpty else { return .synchronized } + try await userProfileManager.resolveCloudSyncConflict(keepLocal: true) + case .profile(let cloudProfile): + if localProfile == cloudProfile { return .synchronized } + if localProfile.isEmpty || cloudProfile.modifiedAt > localProfile.modifiedAt { + try await userProfileManager.resolveCloudSyncConflict(keepLocal: false) + } else if localProfile.modifiedAt > cloudProfile.modifiedAt { + try await userProfileManager.resolveCloudSyncConflict(keepLocal: true) + } else { + return .conflict + } + } + return .synchronized + } catch { + return outcome(for: error) + } + } + + func throwingOutcome( + _ operation: @escaping @MainActor () async throws -> Void + ) async -> AppSynchronizationResult.Outcome { + do { + try await operation() + return .synchronized + } catch { + return outcome(for: error) + } + } + + func outcome(for error: Error) -> AppSynchronizationResult.Outcome { + guard let cloudError = error as? CloudSyncError else { return .failed } + switch cloudError { + case .requiredDownloadPending: + return .pendingDownload + case .containerUnavailable, .containerIdentityChanged: + return .unavailable + default: + return .failed + } + } +} diff --git a/openclient-llm/Shared/Features/Settings/ViewModels/MemoryViewModel.swift b/openclient-llm/Shared/Features/Settings/ViewModels/MemoryViewModel.swift index 2f392073..71914bcb 100644 --- a/openclient-llm/Shared/Features/Settings/ViewModels/MemoryViewModel.swift +++ b/openclient-llm/Shared/Features/Settings/ViewModels/MemoryViewModel.swift @@ -19,6 +19,7 @@ final class MemoryViewModel { case editItem(id: UUID, content: String) case toggleItem(id: UUID) case deleteItem(id: UUID) + case retrySynchronization } enum State: Equatable { @@ -28,6 +29,8 @@ final class MemoryViewModel { struct LoadedState: Equatable { var items: [MemoryItem] = [] + var errorMessage: String? + var isSynchronizing: Bool = false } private(set) var state: State @@ -54,31 +57,32 @@ final class MemoryViewModel { switch event { case .viewAppeared: loadItems() + synchronizeItems() startObservingCloudChanges() case .addItem(let content): let trimmed = content.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } let item = MemoryItem(content: trimmed, source: .user) - memoryManager.add(item) - appReviewManager.requestReview() - loadItems() + performMutation { [memoryManager, appReviewManager] in + try await memoryManager.add(item) + appReviewManager.requestReview() + } case .editItem(let id, let content): let trimmed = content.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, case .loaded(let loadedState) = state, var existing = loadedState.items.first(where: { $0.id == id }) else { return } existing.content = trimmed - memoryManager.update(existing) - loadItems() + performMutation { [memoryManager] in try await memoryManager.update(existing) } case .toggleItem(let id): guard case .loaded(let loadedState) = state, var existing = loadedState.items.first(where: { $0.id == id }) else { return } existing.isEnabled.toggle() - memoryManager.update(existing) - loadItems() + performMutation { [memoryManager] in try await memoryManager.update(existing) } case .deleteItem(let id): - memoryManager.delete(id: id) - loadItems() + performMutation { [memoryManager] in try await memoryManager.delete(id: id) } + case .retrySynchronization: + synchronizeItems() } } } @@ -91,6 +95,40 @@ private extension MemoryViewModel { state = .loaded(LoadedState(items: items)) } + func synchronizeItems() { + guard case .loaded(var loadedState) = state, !loadedState.isSynchronizing else { return } + loadedState.isSynchronizing = true + state = .loaded(loadedState) + Task { [weak self] in + guard let self else { return } + do { + try await memoryManager.synchronize() + loadItems() + } catch { + updateFailure(String(localized: "Memory could not be synchronized. Your local items are retained.")) + } + } + } + + func performMutation(_ mutation: @escaping @MainActor () async throws -> Void) { + Task { [weak self] in + do { + try await mutation() + self?.loadItems() + } catch { + self?.updateFailure(String(localized: "The memory change could not be saved. Please try again.")) + } + } + } + + func updateFailure(_ message: String) { + guard case .loaded(var loadedState) = state else { return } + loadedState.items = memoryManager.getItems().sorted { $0.createdAt > $1.createdAt } + loadedState.errorMessage = message + loadedState.isSynchronizing = false + state = .loaded(loadedState) + } + func startObservingCloudChanges() { cloudSyncTask?.cancel() cloudSyncTask = Task { [weak self] in diff --git a/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+CloudSync.swift b/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+CloudSync.swift new file mode 100644 index 00000000..e79bfba2 --- /dev/null +++ b/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+CloudSync.swift @@ -0,0 +1,159 @@ +// +// SettingsViewModel+CloudSync.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +extension SettingsViewModel { + func handleCloudSyncEvent(_ event: Event) { + switch event { + case .cloudSyncToggled(let enabled): + toggleCloudSync(enabled) + case .cloudSyncConflictResolved(let keepLocal): + resolveCloudSyncConflict(keepLocal: keepLocal) + case .cloudSyncConflictCancelled: + cancelCloudSyncToggle() + case .syncNowTapped: + synchronizeAppData() + default: + break + } + } + + func refreshCloudAvailability() async { + let isAvailable = await cloudSyncManager.checkCloudAvailability() + guard case .loaded(var loadedState) = state else { return } + loadedState.isCloudAvailable = isAvailable + loadedState.isCloudSyncEnabled = settingsManager.getIsCloudSyncEnabled() + state = .loaded(loadedState) + } +} + +// MARK: - Private + +private extension SettingsViewModel { + func toggleCloudSync(_ enabled: Bool) { + guard case .loaded(var loadedState) = state else { return } + + if enabled { + loadedState.isSynchronizing = true + state = .loaded(loadedState) + Task { [weak self] in + await self?.enableCloudSyncAfterPreflight() + } + } else { + disableCloudSync(loadedState: loadedState) + } + } + + func disableCloudSync(loadedState: LoadedState) { + settingsManager.setIsCloudSyncEnabled(false) + var pendingState = loadedState + pendingState.synchronizationResult = nil + pendingState.isSynchronizing = false + state = .loaded(pendingState) + synchronizationTask?.cancel() + synchronizationTask = nil + Task { [weak self] in + guard let self else { return } + await synchronizeAppDataUseCase.cancel() + guard !settingsManager.getIsCloudSyncEnabled(), + case .loaded(var currentState) = state else { return } + currentState.isCloudSyncEnabled = false + currentState.synchronizationResult = nil + currentState.isSynchronizing = false + state = .loaded(currentState) + } + } + + func resolveCloudSyncConflict(keepLocal: Bool) { + guard case .loaded(var loadedState) = state else { return } + loadedState.showCloudSyncConflictAlert = false + loadedState.isSynchronizing = true + state = .loaded(loadedState) + Task { [weak self] in + guard let self else { return } + do { + try await userProfileManager.resolveCloudSyncConflict(keepLocal: keepLocal) + finishCloudSyncEnablement() + } catch { + updateCloudPreflightFailure(error) + } + } + } + + func cancelCloudSyncToggle() { + guard case .loaded(var loadedState) = state else { return } + loadedState.showCloudSyncConflictAlert = false + state = .loaded(loadedState) + } + + func synchronizeAppData() { + guard case .loaded(let loadedState) = state, + loadedState.isCloudSyncEnabled, + !loadedState.isSynchronizing else { return } + var synchronizingState = loadedState + synchronizingState.isSynchronizing = true + state = .loaded(synchronizingState) + synchronizationTask = Task { [weak self] in + guard let self else { return } + let result = await synchronizeAppDataUseCase.execute() + guard !Task.isCancelled else { return } + guard case .loaded(var currentState) = state, currentState.isCloudSyncEnabled else { return } + currentState.synchronizationResult = result + currentState.isSynchronizing = false + currentState.showCloudSyncConflictAlert = !result.categories(with: .conflict).isEmpty + state = .loaded(currentState) + synchronizationTask = nil + } + } + + func enableCloudSyncAfterPreflight() async { + do { + let cloudState = try await userProfileManager.getCloudProfileState() + let localProfile = userProfileManager.getLocalProfile() + guard case .loaded(var loadedState) = state else { return } + if case .profile(let cloudProfile) = cloudState, + !localProfile.isEmpty, + !cloudProfile.isEmpty, + localProfile != cloudProfile { + loadedState.showCloudSyncConflictAlert = true + loadedState.isSynchronizing = false + state = .loaded(loadedState) + return + } + if !localProfile.isEmpty, cloudState != .profile(localProfile) { + try await userProfileManager.resolveCloudSyncConflict(keepLocal: true) + } else if localProfile.isEmpty, case .profile(let cloudProfile) = cloudState, !cloudProfile.isEmpty { + try await userProfileManager.resolveCloudSyncConflict(keepLocal: false) + } + finishCloudSyncEnablement() + } catch { + updateCloudPreflightFailure(error) + } + } + + func finishCloudSyncEnablement() { + guard case .loaded(var loadedState) = state else { return } + settingsManager.setIsCloudSyncEnabled(true) + loadedState.isCloudSyncEnabled = true + loadedState.isSynchronizing = false + state = .loaded(loadedState) + synchronizeAppData() + } + + func updateCloudPreflightFailure(_ error: Error) { + guard case .loaded(var loadedState) = state else { return } + loadedState.isCloudSyncEnabled = false + let outcome: AppSynchronizationResult.Outcome = (error as? CloudSyncError) == .requiredDownloadPending + ? .pendingDownload + : .failed + loadedState.synchronizationResult = AppSynchronizationResult(outcomes: [.profile: outcome]) + loadedState.isSynchronizing = false + state = .loaded(loadedState) + } +} diff --git a/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+Events.swift b/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+Events.swift new file mode 100644 index 00000000..24832880 --- /dev/null +++ b/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel+Events.swift @@ -0,0 +1,95 @@ +// +// SettingsViewModel+Events.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 10/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import Foundation + +extension SettingsViewModel { + // MARK: - Input + + func send(_ event: Event) { + switch event { + case .cloudSyncToggled, .cloudSyncConflictResolved, .cloudSyncConflictCancelled, + .syncNowTapped: + handleCloudSyncEvent(event) + case .showTokenUsageToggled, .privacyScreenToggled: + handlePreferenceToggleEvent(event) + case .webSearchToolNameChanged, .webSearchMaxResultsChanged, .fetchSearchToolsTapped, + .fetchMCPToolsTapped, .mcpToolToggled: + handleServerDiscoveryEvent(event) + default: + handleCoreEvent(event) + } + } + + // MARK: - Private + + private func handleCoreEvent(_ event: Event) { + switch event { + case .viewAppeared: + loadSettings() + case .serverURLChanged(let url): + updateServerURL(url) + case .apiKeyChanged(let key): + updateAPIKey(key) + case .testConnectionTapped: + testConnection() + case .saveTapped: + saveSettings() + case .cloudAvailabilityRefresh: + Task { [weak self] in + await self?.refreshCloudAvailability() + } + case .resetConfirmed: + resetApp() + case .requestNotificationPermissionTapped, .notificationStatusRefresh: + handleNotificationEvent(event) + default: + break + } + } + + private func requestNotificationPermission() { + Task { + await notificationPermissionUseCase.execute() + refreshNotificationStatus() + } + } + + private func handleNotificationEvent(_ event: Event) { + switch event { + case .requestNotificationPermissionTapped: + requestNotificationPermission() + case .notificationStatusRefresh: + refreshNotificationStatus() + default: + break + } + } + + private func handleServerDiscoveryEvent(_ event: Event) { + handleWebSearchEvent(event) + handleMCPEvent(event) + } + + private func handleMCPEvent(_ event: Event) { + switch event { + case .fetchMCPToolsTapped: + fetchMCPTools() + case .mcpToolToggled(let toolId, let enabled): + toggleMCPTool(toolId: toolId, enabled: enabled) + default: + break + } + } + + func enabledMCPToolIds(savedIds: [String], tools: [MCPToolInfo]) -> Set { + let currentIds = Set(tools.map(\.id)) + let legacyIds = Dictionary(uniqueKeysWithValues: tools.map { ($0.prefixedName, $0.id) }) + return Set(savedIds.compactMap { currentIds.contains($0) ? $0 : legacyIds[$0] }) + } +} diff --git a/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel.swift b/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel.swift index 1334429d..4ca39284 100644 --- a/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/openclient-llm/Shared/Features/Settings/ViewModels/SettingsViewModel.swift @@ -22,7 +22,8 @@ final class SettingsViewModel { case cloudSyncToggled(Bool) case cloudSyncConflictResolved(keepLocal: Bool) case cloudSyncConflictCancelled - case syncConversationsTapped + case syncNowTapped + case cloudAvailabilityRefresh case showTokenUsageToggled(Bool) case webSearchToolNameChanged(String) case webSearchMaxResultsChanged(Int) @@ -57,7 +58,9 @@ final class SettingsViewModel { var showLiteLLMHint: Bool = false var notificationPermissionStatus: NotificationPermissionStatus = .notDetermined var isPrivacyScreenEnabled: Bool = true - var conversationSyncResult: ConversationSyncResult? + var synchronizationResult: AppSynchronizationResult? + var isSynchronizing: Bool = false + var resetErrorMessage: String? var availableMCPTools: [MCPToolInfo] = [] var availableMCPServers: [MCPServerInfo] = [] var enabledMCPToolIds: Set = [] @@ -72,20 +75,21 @@ final class SettingsViewModel { case failure(String) } - private(set) var state: State + var state: State private let saveServerConfigurationUseCase: SaveServerConfigurationUseCaseProtocol private let testServerConnectionUseCase: TestServerConnectionUseCaseProtocol private let checkLiteLLMHealthUseCase: CheckLiteLLMHealthUseCaseProtocol private let fetchSearchToolsUseCase: FetchSearchToolsUseCaseProtocol private let fetchMCPToolsUseCase: FetchMCPToolsUseCaseProtocol - private let settingsManager: SettingsManagerProtocol - private let cloudSyncManager: CloudSyncManagerProtocol - private let syncConversationsUseCase: SyncConversationsUseCaseProtocol - private let userProfileManager: UserProfileManagerProtocol + let settingsManager: SettingsManagerProtocol + let cloudSyncManager: CloudSyncManagerProtocol + let synchronizeAppDataUseCase: SynchronizeAppDataUseCaseProtocol + let userProfileManager: UserProfileManagerProtocol private let resetAppUseCase: ResetAppDataUseCaseProtocol private let checkNotificationPermissionUseCase: NotificationStatusCheckProtocol - private let notificationPermissionUseCase: NotificationPermissionUseCaseProtocol + let notificationPermissionUseCase: NotificationPermissionUseCaseProtocol + var synchronizationTask: Task? // MARK: - Init @@ -98,7 +102,7 @@ final class SettingsViewModel { fetchMCPToolsUseCase: FetchMCPToolsUseCaseProtocol = FetchMCPToolsUseCase(), settingsManager: SettingsManagerProtocol = SettingsManager(), cloudSyncManager: CloudSyncManagerProtocol = CloudSyncManager(), - syncConversationsUseCase: SyncConversationsUseCaseProtocol = SyncConversationsUseCase(), + synchronizeAppDataUseCase: SynchronizeAppDataUseCaseProtocol = SynchronizeAppDataUseCase(), userProfileManager: UserProfileManagerProtocol = UserProfileManager(), resetAppUseCase: ResetAppDataUseCaseProtocol = ResetAppDataUseCase(), checkNotificationPermissionUseCase: NotificationStatusCheckProtocol = CheckNotificationPermissionUseCase(), @@ -112,45 +116,17 @@ final class SettingsViewModel { self.fetchMCPToolsUseCase = fetchMCPToolsUseCase self.settingsManager = settingsManager self.cloudSyncManager = cloudSyncManager - self.syncConversationsUseCase = syncConversationsUseCase + self.synchronizeAppDataUseCase = synchronizeAppDataUseCase self.userProfileManager = userProfileManager self.resetAppUseCase = resetAppUseCase self.checkNotificationPermissionUseCase = checkNotificationPermissionUseCase self.notificationPermissionUseCase = notificationPermissionUseCase } - - // MARK: - Input functions - - func send(_ event: Event) { - switch event { - case .viewAppeared: - loadSettings() - case .serverURLChanged(let url): - updateServerURL(url) - case .apiKeyChanged(let key): - updateAPIKey(key) - case .testConnectionTapped: - testConnection() - case .saveTapped: - saveSettings() - case .cloudSyncToggled, .cloudSyncConflictResolved, .cloudSyncConflictCancelled, .syncConversationsTapped: - handleCloudSyncEvent(event) - case .showTokenUsageToggled, .privacyScreenToggled: - handlePreferenceToggleEvent(event) - case .webSearchToolNameChanged, .webSearchMaxResultsChanged, .fetchSearchToolsTapped, - .fetchMCPToolsTapped, .mcpToolToggled: - handleServerDiscoveryEvent(event) - case .resetConfirmed: - resetApp() - case .requestNotificationPermissionTapped, .notificationStatusRefresh: - handleNotificationEvent(event) - } - } } -// MARK: - Private +// MARK: - Internal -private extension SettingsViewModel { +extension SettingsViewModel { func loadSettings() { #if DEBUG let savedServerURL = settingsManager.getServerBaseURL() @@ -162,7 +138,7 @@ private extension SettingsViewModel { serverURL: getServerBaseURL, apiKey: settingsManager.getAPIKey(), isCloudSyncEnabled: settingsManager.getIsCloudSyncEnabled(), - isCloudAvailable: cloudSyncManager.isCloudAvailable(), + isCloudAvailable: false, showTokenUsage: settingsManager.getShowTokenUsage(), webSearchToolName: settingsManager.getWebSearchToolName(), webSearchMaxResults: settingsManager.getWebSearchMaxResults(), @@ -171,6 +147,9 @@ private extension SettingsViewModel { enabledMCPToolIds: Set(settingsManager.getEnabledMCPToolIds()) ) state = .loaded(loadedState) + Task { [weak self] in + await self?.refreshCloudAvailability() + } let serverURL = loadedState.serverURL if !serverURL.isEmpty { Task { @@ -243,53 +222,6 @@ private extension SettingsViewModel { state = .loaded(currentState) } - func toggleCloudSync(_ enabled: Bool) { - guard case .loaded(var loadedState) = state else { return } - - if enabled { - let localProfile = userProfileManager.getLocalProfile() - let cloudProfile = userProfileManager.getCloudProfile() - - // Both local and cloud have non-empty profiles → ask user which to keep. - if !localProfile.isEmpty, let cloud = cloudProfile, !cloud.isEmpty, localProfile != cloud { - loadedState.showCloudSyncConflictAlert = true - state = .loaded(loadedState) - return - } - - // Only local has data → push to cloud. - settingsManager.setIsCloudSyncEnabled(true) - loadedState.isCloudSyncEnabled = true - loadedState.conversationSyncResult = syncConversationsUseCase.execute() - state = .loaded(loadedState) - - if !localProfile.isEmpty && (cloudProfile?.isEmpty ?? true) { - userProfileManager.resolveCloudSyncConflict(keepLocal: true) - } - } else { - settingsManager.setIsCloudSyncEnabled(false) - loadedState.isCloudSyncEnabled = false - loadedState.conversationSyncResult = nil - state = .loaded(loadedState) - } - } - - func resolveCloudSyncConflict(keepLocal: Bool) { - guard case .loaded(var loadedState) = state else { return } - settingsManager.setIsCloudSyncEnabled(true) - userProfileManager.resolveCloudSyncConflict(keepLocal: keepLocal) - loadedState.isCloudSyncEnabled = true - loadedState.conversationSyncResult = syncConversationsUseCase.execute() - loadedState.showCloudSyncConflictAlert = false - state = .loaded(loadedState) - } - - func cancelCloudSyncToggle() { - guard case .loaded(var loadedState) = state else { return } - loadedState.showCloudSyncConflictAlert = false - state = .loaded(loadedState) - } - func toggleShowTokenUsage(_ show: Bool) { guard case .loaded(var loadedState) = state else { return } settingsManager.setShowTokenUsage(show) @@ -335,27 +267,6 @@ private extension SettingsViewModel { state = .loaded(loadedState) } - func handleCloudSyncEvent(_ event: Event) { - switch event { - case .cloudSyncToggled(let enabled): - toggleCloudSync(enabled) - case .cloudSyncConflictResolved(let keepLocal): - resolveCloudSyncConflict(keepLocal: keepLocal) - case .cloudSyncConflictCancelled: - cancelCloudSyncToggle() - case .syncConversationsTapped: - synchronizeConversations() - default: - break - } - } - - func synchronizeConversations() { - guard case .loaded(var loadedState) = state, loadedState.isCloudSyncEnabled else { return } - loadedState.conversationSyncResult = syncConversationsUseCase.execute() - state = .loaded(loadedState) - } - func handleWebSearchEvent(_ event: Event) { switch event { case .webSearchToolNameChanged(let name): @@ -401,9 +312,23 @@ private extension SettingsViewModel { } func resetApp() { - resetAppUseCase.execute() - loadSettings() - NotificationCenter.default.post(name: .appDataDidReset, object: nil) + guard case .loaded(var loadedState) = state else { return } + loadedState.resetErrorMessage = nil + state = .loaded(loadedState) + Task { + do { + try await resetAppUseCase.execute() + loadSettings() + NotificationCenter.default.post(name: .appDataDidReset, object: nil) + } catch { + LogManager.error("App data reset failed") + guard case .loaded(var currentState) = state else { return } + currentState.resetErrorMessage = String( + localized: "App data could not be completely reset. Your remaining data was not discarded." + ) + state = .loaded(currentState) + } + } } func refreshNotificationStatus() { @@ -415,39 +340,6 @@ private extension SettingsViewModel { } } - func requestNotificationPermission() { - Task { - await notificationPermissionUseCase.execute() - refreshNotificationStatus() - } - } - - func handleNotificationEvent(_ event: Event) { - switch event { - case .requestNotificationPermissionTapped: - requestNotificationPermission() - case .notificationStatusRefresh: - refreshNotificationStatus() - default: - break - } - } - - func handleServerDiscoveryEvent(_ event: Event) { - handleWebSearchEvent(event) - handleMCPEvent(event) - } - - func handleMCPEvent(_ event: Event) { - switch event { - case .fetchMCPToolsTapped: - fetchMCPTools() - case .mcpToolToggled(let toolId, let enabled): - toggleMCPTool(toolId: toolId, enabled: enabled) - default: - break - } - } func fetchMCPTools() { guard case .loaded(let loadedState) = state, !loadedState.isLoadingMCPTools else { return } var update = loadedState @@ -491,9 +383,4 @@ private extension SettingsViewModel { settingsManager.setEnabledMCPToolIds(Array(loadedState.enabledMCPToolIds)) state = .loaded(loadedState) } - func enabledMCPToolIds(savedIds: [String], tools: [MCPToolInfo]) -> Set { - let currentIds = Set(tools.map(\.id)) - let legacyIds = Dictionary(uniqueKeysWithValues: tools.map { ($0.prefixedName, $0.id) }) - return Set(savedIds.compactMap { currentIds.contains($0) ? $0 : legacyIds[$0] }) - } } diff --git a/openclient-llm/Shared/Features/Settings/ViewModels/UserProfileViewModel.swift b/openclient-llm/Shared/Features/Settings/ViewModels/UserProfileViewModel.swift index 78774452..2779bdc7 100644 --- a/openclient-llm/Shared/Features/Settings/ViewModels/UserProfileViewModel.swift +++ b/openclient-llm/Shared/Features/Settings/ViewModels/UserProfileViewModel.swift @@ -93,14 +93,20 @@ private extension UserProfileViewModel { profileDescription: description, extraInfo: extraInfo ) - userProfileManager.saveProfile(profile) - guard case .loaded(var loadedState) = state else { return } - loadedState.name = name - loadedState.profileDescription = description - loadedState.extraInfo = extraInfo - loadedState.originalName = name - loadedState.originalDescription = description - loadedState.originalExtraInfo = extraInfo - state = .loaded(loadedState) + Task { + do { + try await userProfileManager.saveProfile(profile) + } catch { + return + } + guard case .loaded(var loadedState) = state else { return } + loadedState.name = name + loadedState.profileDescription = description + loadedState.extraInfo = extraInfo + loadedState.originalName = name + loadedState.originalDescription = description + loadedState.originalExtraInfo = extraInfo + state = .loaded(loadedState) + } } } diff --git a/openclient-llm/Shared/Features/Settings/Views/MemoryView.swift b/openclient-llm/Shared/Features/Settings/Views/MemoryView.swift index bcc5330a..b10ab07a 100644 --- a/openclient-llm/Shared/Features/Settings/Views/MemoryView.swift +++ b/openclient-llm/Shared/Features/Settings/Views/MemoryView.swift @@ -68,7 +68,21 @@ struct MemoryView: View { private extension MemoryView { func loadedView(_ loadedState: MemoryViewModel.LoadedState) -> some View { - Group { + VStack(spacing: 0) { + if let errorMessage = loadedState.errorMessage { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + Spacer() + Button(String(localized: "Retry")) { + viewModel.send(.retrySynchronization) + } + .disabled(loadedState.isSynchronizing) + } + .padding() + } + if loadedState.items.isEmpty { emptyState } else { diff --git a/openclient-llm/Shared/Features/Settings/Views/SettingsView+CloudSync.swift b/openclient-llm/Shared/Features/Settings/Views/SettingsView+CloudSync.swift new file mode 100644 index 00000000..1f1ac3e0 --- /dev/null +++ b/openclient-llm/Shared/Features/Settings/Views/SettingsView+CloudSync.swift @@ -0,0 +1,126 @@ +// +// SettingsView+CloudSync.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 11/08/2026. +// Copyright © 2026 Arturo Carretero Calvo. All rights reserved. +// + +import SwiftUI + +extension SettingsView { + func cloudSyncSection(_ loadedState: SettingsViewModel.LoadedState) -> some View { + Section { + Toggle(isOn: Binding( + get: { loadedState.isCloudSyncEnabled }, + set: { viewModel.send(.cloudSyncToggled($0)) } + )) { + Label(String(localized: "iCloud Sync"), systemImage: "icloud") + } + .disabled(!loadedState.isCloudAvailable && !loadedState.isCloudSyncEnabled) + + if loadedState.isCloudSyncEnabled { + Button { + synchronizeAppData() + } label: { + HStack(spacing: 8) { + if loadedState.isSynchronizing { + ProgressView() + .controlSize(.small) + } + Text(loadedState.isSynchronizing + ? String(localized: "Synchronizing...") + : String(localized: "Sync Now")) + } + } + .disabled(loadedState.isSynchronizing) + } + + if !loadedState.isCloudAvailable { + Label( + String(localized: "Sign in to iCloud to enable sync"), + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text(String(localized: "Sync")) + } footer: { + VStack(alignment: .leading, spacing: 4) { + ForEach(cloudSyncMessages(loadedState.synchronizationResult), id: \.self) { message in + Text(message) + } + } + } + } + + func cloudSyncMessages(_ result: AppSynchronizationResult?) -> [String] { + guard let result else { + return [String( + localized: """ + Sync conversations, personal context, memory, and prompt templates across your devices \ + via iCloud. + """ + )] + } + + if result.isSuccessful { + return [String( + localized: "Conversations, personal context, memory, and prompt templates are synchronized via iCloud." + )] + } + + var messages: [String] = [] + appendSyncMessage( + to: &messages, + categories: result.categories(with: .pendingDownload), + format: String(localized: "Waiting for iCloud downloads: %@.") + ) + appendSyncMessage( + to: &messages, + categories: result.categories(with: .unavailable), + format: String(localized: "iCloud is unavailable for: %@. Local changes are retained.") + ) + appendSyncMessage( + to: &messages, + categories: result.categories(with: .failed), + format: String(localized: "Some data could not be synchronized: %@. Local changes are retained.") + ) + if !result.categories(with: .conflict).isEmpty { + messages.append(String(localized: "Personal context needs conflict resolution before it can synchronize.")) + } + return messages + } +} + +// MARK: - Private + +private extension SettingsView { + func appendSyncMessage( + to messages: inout [String], + categories: Set, + format: String + ) { + guard !categories.isEmpty else { return } + messages.append(String(format: format, localizedCategoryList(categories))) + } + + func localizedCategoryList(_ categories: Set) -> String { + let names = AppSynchronizationResult.Category.allCases + .filter(categories.contains) + .map { category in + switch category { + case .conversations: + String(localized: "Conversations and attachments") + case .profile: + String(localized: "Personal context") + case .memory: + String(localized: "Memory") + case .promptTemplates: + String(localized: "Prompt templates") + } + } + return ListFormatter.localizedString(byJoining: names) + } +} diff --git a/openclient-llm/Shared/Features/Settings/Views/SettingsView.swift b/openclient-llm/Shared/Features/Settings/Views/SettingsView.swift index 810c65d3..478c77e4 100644 --- a/openclient-llm/Shared/Features/Settings/Views/SettingsView.swift +++ b/openclient-llm/Shared/Features/Settings/Views/SettingsView.swift @@ -56,6 +56,11 @@ struct SettingsView: View { settingsContent #endif } + + func synchronizeAppData() { + shouldRequestReviewAfterSync = true + viewModel.send(.syncNowTapped) + } } // MARK: - Private @@ -163,12 +168,18 @@ private extension SettingsView { .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { viewModel.send(.notificationStatusRefresh) + viewModel.send(.cloudAvailabilityRefresh) } } .onChange(of: viewModel.state) { _, newState in if case .loaded(let loadedState) = newState { serverURL = loadedState.serverURL apiKey = loadedState.apiKey + if !loadedState.isSynchronizing, + let result = loadedState.synchronizationResult, + !result.isSuccessful { + shouldRequestReviewAfterSync = false + } } } .onDisappear(perform: requestReviewAfterSuccessfulSyncIfNeeded) @@ -337,52 +348,6 @@ private extension SettingsView { } } - func cloudSyncSection(_ loadedState: SettingsViewModel.LoadedState) -> some View { - Section { - Toggle(isOn: Binding( - get: { loadedState.isCloudSyncEnabled }, - set: { viewModel.send(.cloudSyncToggled($0)) } - )) { - Label(String(localized: "iCloud Sync"), systemImage: "icloud") - } - .disabled(!loadedState.isCloudAvailable) - - if loadedState.isCloudSyncEnabled { - Button(String(localized: "Sync Now")) { - synchronizeConversations() - } - } - - if !loadedState.isCloudAvailable { - Label( - String(localized: "Sign in to iCloud to enable sync"), - systemImage: "exclamationmark.triangle" - ) - .font(.caption) - .foregroundStyle(.secondary) - } - } header: { - Text(String(localized: "Sync")) - } footer: { - Text(cloudSyncFooter(loadedState.conversationSyncResult)) - } - } - - func cloudSyncFooter(_ result: ConversationSyncResult?) -> String { - switch result { - case .synchronized: - return String(localized: "Conversations are synchronized across your devices via iCloud.") - case .pendingDownload: - return String(localized: "iCloud is downloading changes. Sync will continue automatically.") - case .unavailable: - return String(localized: "iCloud is unavailable. Your changes will stay on this device until sync resumes.") - case .failed: - return String(localized: "Sync could not finish. Your changes remain safely stored on this device.") - case nil: - return String(localized: "Sync conversations across your devices via iCloud.") - } - } - func chatSection(_ loadedState: SettingsViewModel.LoadedState) -> some View { Section { Toggle(isOn: Binding( @@ -456,13 +421,6 @@ private extension SettingsView { } } - func synchronizeConversations() { - viewModel.send(.syncConversationsTapped) - guard case .loaded(let loadedState) = viewModel.state, - loadedState.conversationSyncResult == .synchronized else { return } - shouldRequestReviewAfterSync = true - } - func requestReviewAfterSuccessfulSyncIfNeeded() { guard shouldRequestReviewAfterSync else { return } shouldRequestReviewAfterSync = false @@ -478,12 +436,24 @@ private extension SettingsView { .foregroundStyle(.red) } .buttonStyle(.plain) + + if let resetErrorMessage = currentLoadedState?.resetErrorMessage { + Label(resetErrorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } } header: { Text(String(localized: "App Data")) } footer: { Text(String(localized: "Deletes all local settings and credentials. iCloud data will not be affected.")) } } + + var currentLoadedState: SettingsViewModel.LoadedState? { + guard case .loaded(let loadedState) = viewModel.state else { return nil } + return loadedState + } + } #Preview { diff --git a/openclient-llm/Shared/Resources/Localizable.xcstrings b/openclient-llm/Shared/Resources/Localizable.xcstrings index 051abe7e..db6e7f61 100644 --- a/openclient-llm/Shared/Resources/Localizable.xcstrings +++ b/openclient-llm/Shared/Resources/Localizable.xcstrings @@ -1,34759 +1,34886 @@ { - "version" : "1.2", "sourceLanguage" : "en", "strings" : { - "Long-press any message and tap \"Add to Favourites\" to save it here." : { + "" : { + "shouldTranslate" : false + }, + "·" : { + "shouldTranslate" : false + }, + "%.1f — %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$.1f — %2$@" + } + } + }, + "shouldTranslate" : false + }, + "%.2f" : { + "comment" : "A label displaying the current value of the topP parameter.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f" + } + } + }, + "shouldTranslate" : false + }, + "%@ tokens, %lld percent" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Halte eine Nachricht gedrückt und tippe auf „Zu Favoriten hinzufügen“, um sie hier zu speichern.", - "state" : "translated" + "state" : "translated", + "value" : "%1$@ Token, %2$lld Prozent" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mantén pulsado cualquier mensaje y toca \"Añadir a Favoritos\" para guardarlo aquí.", - "state" : "translated" + "state" : "translated", + "value" : "%1$@ διακριτικά, %2$lld τοις εκατό" } }, - "ja" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "メッセージを長押しして「お気に入りに追加」をタップすると、ここに保存されます。" + "state" : "new", + "value" : "%1$@ tokens, %2$lld percent" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tieni premuto un messaggio e tocca \"Aggiungi ai Preferiti\" per salvarlo qui." + "value" : "%1$@ fichas, %2$lld por ciento" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Pressione longamente qualquer mensagem e toque em \"Adicionar aos Favoritos\" para guardá-la aqui.", - "state" : "translated" + "state" : "translated", + "value" : "%1$@ jetons, %2$lld pour cent" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Long-press any message and tap \"Add to Favorites\" to save it here." + "value" : "%1$@ token, %2$lld percentuale" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Appuyez longuement sur un message et touchez « Ajouter aux favoris » pour l’enregistrer ici.", - "state" : "translated" + "state" : "translated", + "value" : "%1$@ トークン、%2$lld パーセント" } }, "nl" : { "stringUnit" : { - "value" : "Houd een bericht ingedrukt en tik op \"Toevoegen aan favorieten\" om het hier op te slaan.", - "state" : "translated" + "state" : "translated", + "value" : "%1$@ tokens, %2$lld procent" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πατήστε παρατεταμένα οποιοδήποτε μήνυμα και επιλέξτε «Προσθήκη στα Αγαπημένα» για να το αποθηκεύσετε εδώ.", - "state" : "translated" + "state" : "translated", + "value" : "%1$@ tokens, %2$lld por cento" } }, "sv" : { "stringUnit" : { - "value" : "Tryck länge på ett meddelande och tryck på \"Lägg till i favoriter\" för att spara det här.", - "state" : "translated" + "state" : "translated", + "value" : "%1$@ tokens, %2$lld procent" + } + } + } + }, + "%lld" : { + "comment" : "A label displaying the number of search results. The argument is the number of search results.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" } } }, - "comment" : "A description of the action to add a message to the favourites." + "shouldTranslate" : false }, - "Import Complete" : { + "%lld attachment(s)" : { + "comment" : "A label that shows the number of attachments and a paperclip icon.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η εισαγωγή ολοκληρώθηκε", - "state" : "translated" + "state" : "translated", + "value" : "%lld Anhang/Anhänge" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Importación completada", - "state" : "translated" + "state" : "translated", + "value" : "%lld συνημμένο(α)" } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Import abgeschlossen", - "state" : "translated" + "state" : "translated", + "value" : "%lld attachment(s)" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Importazione completata", - "state" : "translated" + "state" : "translated", + "value" : "%lld archivo(s) adjunto(s)" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Importação concluída" + "value" : "%lld pièce(s) jointe(s)" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Import Complete" + "value" : "%lld allegato(i)" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Importation terminée", - "state" : "translated" + "state" : "translated", + "value" : "%lld 件の添付ファイル" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Import voltooid" + "value" : "%lld bijlage(n)" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "インポート完了", - "state" : "translated" + "state" : "translated", + "value" : "%lld anexo(s)" } }, "sv" : { "stringUnit" : { - "value" : "Import klar", - "state" : "translated" + "state" : "translated", + "value" : "%lld bilaga(or)" } } } }, - "%lld search tool(s) available on your server." : { + "%lld compacted · %lld excluded" : { + "comment" : "A description of the number of messages that were compacted or excluded from the context. The first argument is the number of compacted messages. The second argument is the number of excluded messages.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "%lld sökverktyg tillgängliga på din server." + "value" : "%1$lld komprimiert · %2$lld ausgeschlossen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%lld herramienta(s) de búsqueda disponibles en su servidor.", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld συμπιεσμένα · %2$lld εξαιρέθηκαν" } }, - "el" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "%lld διαθέσιμο(α) εργαλείο(α) αναζήτησης στον διακομιστή σας." + "state" : "new", + "value" : "%1$lld compacted · %2$lld excluded" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "%lld strumento\/i di ricerca disponibili sul tuo server.", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld compactados · %2$lld excluidos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%lld ferramenta(s) de pesquisa disponíveis no seu servidor." + "value" : "%1$lld compactés · %2$lld exclus" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%lld search tool(s) available on your server.", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld compattati · %2$lld esclusi" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%lld zoekhulpmiddel(en) beschikbaar op uw server.", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld 件を圧縮 · %2$lld 件を除外" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "%lld outil(s) de recherche disponible(s) sur votre serveur.", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld gecomprimeerd · %2$lld uitgesloten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "%lld Suchwerkzeug(e) auf Ihrem Server verfügbar.", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld compactados · %2$lld excluídos" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "サーバーに %lld 個の検索ツールが利用可能です。", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld komprimerade · %2$lld uteslutna" } } - }, - "comment" : "A footer that shows the number of search tools available on the user's server. The argument is the number of search tools." + } }, - "Prompt Library" : { + "%lld messages compacted" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Βιβλιοθήκη Ερωτημάτων" + "value" : "%lld Nachrichten komprimiert" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "プロンプトライブラリ" + "value" : "%lld συμπιεσμένα μηνύματα" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Biblioteca de prompts" + "value" : "%lld messages compacted" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Libreria di Prompt", - "state" : "translated" + "state" : "translated", + "value" : "%lld mensajes compactados" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Biblioteca de Prompts", - "state" : "translated" + "state" : "translated", + "value" : "%lld messages compactés" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Prompt Library", - "state" : "translated" + "state" : "translated", + "value" : "%lld messaggi compressi" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Promptbibliotheek", - "state" : "translated" + "state" : "translated", + "value" : "%lld 件のメッセージを圧縮しました" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Bibliothèque de prompts", - "state" : "translated" + "state" : "translated", + "value" : "%lld berichten samengevoegd" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Prompt-Bibliothek", - "state" : "translated" + "state" : "translated", + "value" : "%lld mensagens compactadas" } }, "sv" : { "stringUnit" : { - "value" : "Promptbibliotek", - "state" : "translated" + "state" : "translated", + "value" : "%lld meddelanden komprimerade" } } - }, - "comment" : "A title for a screen that lists and creates custom input prompts." + } }, - "%@ tokens, %lld percent" : { + "%lld messages excluded from this request" : { + "comment" : "A message indicating that a certain number of messages have been excluded from a request. The argument is the number of messages that have been excluded.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ トークン、%2$lld パーセント" + "value" : "%lld Nachrichten von dieser Anfrage ausgeschlossen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%1$@ fichas, %2$lld por ciento", - "state" : "translated" + "state" : "translated", + "value" : "%lld μηνύματα εξαιρέθηκαν από αυτό το αίτημα" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ διακριτικά, %2$lld τοις εκατό" + "value" : "%lld messages excluded from this request" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "%1$@ token, %2$lld percentuale", - "state" : "translated" + "state" : "translated", + "value" : "%lld mensajes excluidos de esta solicitud" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "%1$@ tokens, %2$lld por cento", - "state" : "translated" + "state" : "translated", + "value" : "%lld messages exclus de cette requête" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%1$@ tokens, %2$lld percent", - "state" : "new" + "state" : "translated", + "value" : "%lld messaggi esclusi da questa richiesta" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%1$@ tokens, %2$lld procent", - "state" : "translated" + "state" : "translated", + "value" : "このリクエストから %lld 件のメッセージが除外されました" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "%1$@ jetons, %2$lld pour cent", - "state" : "translated" + "state" : "translated", + "value" : "%lld berichten uitgesloten van dit verzoek" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "%1$@ Token, %2$lld Prozent", - "state" : "translated" + "state" : "translated", + "value" : "%lld mensagens excluídas deste pedido" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ tokens, %2$lld procent" + "value" : "%lld meddelanden uteslutna från denna förfrågan" } } } }, - "Describe your suggestion in detail..." : { + "%lld of %lld MCP tool(s) enabled. Tools can also be managed from the chat input bar." : { + "comment" : "A footer that shows the number of MCP tools that are enabled.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Beskriv ditt förslag i detalj..." + "value" : "%1$lld von %2$lld MCP-Werkzeugen aktiviert. Werkzeuge können auch über die Chat-Eingabeleiste verwaltet werden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Describe tu sugerencia en detalle...", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld από %2$lld εργαλεία MCP ενεργοποιημένα. Τα εργαλεία μπορούν επίσης να διαχειριστούν από τη γραμμή εισαγωγής συνομιλίας." } }, - "ja" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "提案の詳細を説明してください..." + "state" : "new", + "value" : "%1$lld of %2$lld MCP tool(s) enabled. Tools can also be managed from the chat input bar." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Descrivi la tua proposta in dettaglio...", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld de %2$lld herramienta(s) MCP activada(s). Las herramientas también se pueden gestionar desde la barra de entrada del chat." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Descreva a sua sugestão em detalhe...", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld sur %2$lld outil(s) MCP activé(s). Les outils peuvent également être gérés depuis la barre de saisie du chat." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Describe your suggestion in detail...", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld di %2$lld strumenti MCP abilitati. Gli strumenti possono essere gestiti anche dalla barra di input della chat." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Beschrijf uw suggestie in detail...", - "state" : "translated" + "state" : "translated", + "value" : "%2$lld 個中 %1$lld 個の MCP ツールが有効です。ツールはチャット入力バーからも管理できます。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Décrivez votre suggestion en détail..." + "value" : "%1$lld van %2$lld MCP-hulpmiddel(en) ingeschakeld. Hulpmiddelen kunnen ook worden beheerd via de chatinvoerbalk." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Περιγράψτε την πρότασή σας λεπτομερώς...", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld de %2$lld ferramenta(s) MCP ativada(s). As ferramentas também podem ser geridas a partir da barra de entrada do chat." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Beschreiben Sie Ihren Vorschlag im Detail...", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld av %2$lld MCP-verktyg aktiverade. Verktyg kan också hanteras från chattinmatningsfältet." } } } }, - "Loading suggestions..." : { + "%lld search tool(s) available on your server." : { + "comment" : "A footer that shows the number of search tools available on the user's server. The argument is the number of search tools.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Laddar förslag...", - "state" : "translated" + "state" : "translated", + "value" : "%lld Suchwerkzeug(e) auf Ihrem Server verfügbar." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cargando sugerencias...", - "state" : "translated" + "state" : "translated", + "value" : "%lld διαθέσιμο(α) εργαλείο(α) αναζήτησης στον διακομιστή σας." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "提案を読み込み中..." + "value" : "%lld search tool(s) available on your server." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Caricamento suggerimenti..." + "value" : "%lld herramienta(s) de búsqueda disponibles en su servidor." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A carregar sugestões...", - "state" : "translated" + "state" : "translated", + "value" : "%lld outil(s) de recherche disponible(s) sur votre serveur." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Loading suggestions..." + "value" : "%lld strumento/i di ricerca disponibili sul tuo server." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Chargement des suggestions...", - "state" : "translated" + "state" : "translated", + "value" : "サーバーに %lld 個の検索ツールが利用可能です。" } }, "nl" : { "stringUnit" : { - "value" : "Suggesties laden...", - "state" : "translated" + "state" : "translated", + "value" : "%lld zoekhulpmiddel(en) beschikbaar op uw server." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Φόρτωση προτάσεων...", - "state" : "translated" + "state" : "translated", + "value" : "%lld ferramenta(s) de pesquisa disponíveis no seu servidor." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Vorschläge werden geladen...", - "state" : "translated" + "state" : "translated", + "value" : "%lld sökverktyg tillgängliga på din server." } } } }, - "Purple" : { + "%lld server(s) available" : { + "comment" : "A label that shows the number of MCP servers available. The argument is the number of servers.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Μωβ" + "value" : "%lld Server verfügbar" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Púrpura", - "state" : "translated" + "state" : "translated", + "value" : "%lld διακομιστής(ες) διαθέσιμος(οι)" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Lila" + "value" : "%lld server(s) available" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Viola", - "state" : "translated" + "state" : "translated", + "value" : "%lld servidor(es) disponibles" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Roxo" + "value" : "%lld serveur(s) disponible(s)" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Purple", - "state" : "translated" + "state" : "translated", + "value" : "%lld server disponibili" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Paars", - "state" : "translated" + "state" : "translated", + "value" : "%lld 台のサーバーが利用可能" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Violet", - "state" : "translated" + "state" : "translated", + "value" : "%lld server(s) beschikbaar" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "パープル", - "state" : "translated" + "state" : "translated", + "value" : "%lld servidor(es) disponível(eis)" } }, "sv" : { "stringUnit" : { - "value" : "Lila", - "state" : "translated" + "state" : "translated", + "value" : "%lld server tillgängliga" } } - }, - "comment" : "Name of a tag color." + } }, - "Author" : { + "%lld sources" : { + "comment" : "A label that displays the number of sources found in a search result. The argument is the number of sources.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Autor", - "state" : "translated" + "state" : "translated", + "value" : "%lld Quellen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Autor" + "value" : "%lld πηγές" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Συγγραφέας" + "value" : "%lld sources" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Autore", - "state" : "translated" + "state" : "translated", + "value" : "%lld fuentes" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Autor", - "state" : "translated" + "state" : "translated", + "value" : "%lld sources" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Author" + "value" : "%lld fonti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Auteur", - "state" : "translated" + "state" : "translated", + "value" : "%lld 件のソース" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Auteur", - "state" : "translated" + "state" : "translated", + "value" : "%lld bronnen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "作成者", - "state" : "translated" + "state" : "translated", + "value" : "%lld fontes" } }, "sv" : { "stringUnit" : { - "value" : "Författare", - "state" : "translated" + "state" : "translated", + "value" : "%lld källor" } } } }, - "Choose the right model" : { + "%lld tokens" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld tokens" + } + } + }, + "shouldTranslate" : false + }, + "%lld tool(s) available" : { + "comment" : "A label that shows the number of MCP tools available.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Wähle das richtige Modell", - "state" : "translated" + "state" : "translated", + "value" : "%lld Werkzeug(e) verfügbar" } }, "el" : { "stringUnit" : { - "value" : "Επιλέξτε το σωστό μοντέλο", - "state" : "translated" + "state" : "translated", + "value" : "%lld διαθέσιμο(α) εργαλείο(α)" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Elige el modelo correcto", - "state" : "translated" + "state" : "translated", + "value" : "%lld tool(s) available" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Scegli il modello giusto", - "state" : "translated" + "state" : "translated", + "value" : "%lld herramienta(s) disponible(s)" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Escolha o modelo correto" + "value" : "%lld outil(s) disponible(s)" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Choose the right model" + "value" : "%lld strumento(i) disponibile(i)" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Choisissez le bon modèle", - "state" : "translated" + "state" : "translated", + "value" : "%lld 個のツールが利用可能" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kies het juiste model" + "value" : "%lld gereedschap(en) beschikbaar" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "適切なモデルを選択する", - "state" : "translated" + "state" : "translated", + "value" : "%lld ferramenta(s) disponível(is)" } }, "sv" : { "stringUnit" : { - "value" : "Välj rätt modell", - "state" : "translated" + "state" : "translated", + "value" : "%lld verktyg tillgängliga" + } + } + } + }, + "%lld." : { + "comment" : "A label that shows the index of a search result. The argument is the index of the search result.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld." } } }, - "comment" : "A title for a tip that explains how to select a model for a conversation." + "shouldTranslate" : false }, - "We're making a few improvements. Please try again later." : { + "%lld/%lld" : { + "comment" : "A label showing the current character count and the maximum allowed.", "localizations" : { - "el" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld/%2$lld" + } + } + }, + "shouldTranslate" : false + }, + "~$%.4f" : { + "comment" : "A monetary value displayed in the chat interface.", + "shouldTranslate" : false + }, + "$%.4f / 1K tokens" : { + "comment" : "A label that shows the cost of input in USD per 1K tokens.", + "shouldTranslate" : false + }, + "1 source" : { + "comment" : "A label that indicates that there is 1 source.", + "localizations" : { + "de" : { "stringUnit" : { - "value" : "Κάνουμε μερικές βελτιώσεις. Δοκιμάστε ξανά αργότερα.", - "state" : "translated" + "state" : "translated", + "value" : "1 Quelle" } }, - "ja" : { + "el" : { "stringUnit" : { - "value" : "いくつか改善を行っています。しばらくしてからもう一度お試しください。", - "state" : "translated" + "state" : "translated", + "value" : "1 πηγή" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Estamos realizando algunas mejoras. Vuelve a intentarlo más tarde." + "value" : "1 source" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Stiamo apportando alcuni miglioramenti. Riprova più tardi.", - "state" : "translated" + "state" : "translated", + "value" : "1 fuente" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Estamos a fazer algumas melhorias. Tente novamente mais tarde." + "value" : "1 source" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "We're making a few improvements. Please try again later.", - "state" : "translated" + "state" : "translated", + "value" : "1 fonte" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nous apportons quelques améliorations. Veuillez réessayer plus tard.", - "state" : "translated" + "state" : "translated", + "value" : "1つのソース" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "We voeren enkele verbeteringen door. Probeer het later opnieuw." + "value" : "1 bron" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Wir nehmen einige Verbesserungen vor. Bitte versuchen Sie es später erneut.", - "state" : "translated" + "state" : "translated", + "value" : "1 fonte" } }, "sv" : { "stringUnit" : { - "value" : "Vi gör några förbättringar. Försök igen senare.", - "state" : "translated" + "state" : "translated", + "value" : "1 källa" } } - }, - "comment" : "A message displayed when the app is under maintenance." + } }, - "Right-click a message to edit, regenerate, branch, or save it as a favourite." : { + "A brief description about yourself" : { + "comment" : "A placeholder for a user's description.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κάντε δεξί κλικ σε ένα μήνυμα για να το επεξεργαστείτε, αναγεννήσετε, διακλαδώσετε ή αποθηκεύσετε ως αγαπημένο.", - "state" : "translated" + "state" : "translated", + "value" : "Eine kurze Beschreibung von dir" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Haz clic derecho en un mensaje para editarlo, regenerarlo, ramificarlo o guardarlo como favorito.", - "state" : "translated" + "state" : "translated", + "value" : "Μια σύντομη περιγραφή για εσάς" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Högerklicka på ett meddelande för att redigera, generera om, skapa en gren eller spara det som favorit." + "value" : "A brief description about yourself" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Fai clic con il tasto destro su un messaggio per modificarlo, rigenerarlo, creare un ramo o salvarlo tra i preferiti." + "value" : "Una breve descripción sobre ti mismo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Clique com o botão direito numa mensagem para editar, regenerar, ramificar ou guardar como favorito.", - "state" : "translated" + "state" : "translated", + "value" : "Une brève description de vous-même" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Right-click a message to edit, regenerate, branch, or save it as a favorite." + "value" : "Una breve descrizione di te stesso" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Cliquez droit sur un message pour le modifier, régénérer, créer une branche ou l’enregistrer en favori.", - "state" : "translated" + "state" : "translated", + "value" : "あなたについての簡単な説明" } }, "nl" : { "stringUnit" : { - "value" : "Klik met de rechtermuisknop op een bericht om het te bewerken, opnieuw te genereren, vertakken of als favoriet op te slaan.", - "state" : "translated" + "state" : "translated", + "value" : "Een korte beschrijving over jezelf" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メッセージを右クリックして編集、再生成、分岐、またはお気に入りに保存します。", - "state" : "translated" + "state" : "translated", + "value" : "Uma breve descrição sobre si próprio" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Klicken Sie mit der rechten Maustaste auf eine Nachricht, um sie zu bearbeiten, neu zu generieren, zu verzweigen oder als Favorit zu speichern.", - "state" : "translated" + "state" : "translated", + "value" : "En kort beskrivning om dig själv" } } } }, - "Tap + to get started" : { + "A brief description about yourself. Max 500 characters." : { + "comment" : "A description of the field that allows the user to add a", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Πατήστε + για να ξεκινήσετε", - "state" : "translated" + "state" : "translated", + "value" : "Eine kurze Beschreibung von dir. Maximal 500 Zeichen." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Toca + para comenzar" + "value" : "Μια σύντομη περιγραφή για εσάς. Μέγιστο 500 χαρακτήρες." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "開始するには+をタップしてください" + "value" : "A brief description about yourself. Max 500 characters." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tocca + per iniziare", - "state" : "translated" + "state" : "translated", + "value" : "Una breve descripción sobre ti. Máximo 500 caracteres." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Toque + para começar" + "value" : "Une brève description de vous-même. Max 500 caractères." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Tap + to get started", - "state" : "translated" + "state" : "translated", + "value" : "Una breve descrizione di te stesso. Max 500 caratteri." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Tik op + om te beginnen", - "state" : "translated" + "state" : "translated", + "value" : "自分についての簡単な説明。最大500文字まで。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Appuyez sur + pour commencer", - "state" : "translated" + "state" : "translated", + "value" : "Een korte beschrijving van jezelf. Maximaal 500 tekens." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Tippe auf +, um zu beginnen", - "state" : "translated" + "state" : "translated", + "value" : "Uma breve descrição sobre si. Máx. 500 caracteres." } }, "sv" : { "stringUnit" : { - "value" : "Tryck på + för att börja", - "state" : "translated" + "state" : "translated", + "value" : "En kort beskrivning om dig själv. Max 500 tecken." } } } }, - "Recent Conversations" : { + "A network error occurred. Please try again." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Πρόσφατες Συνομιλίες", - "state" : "translated" + "state" : "translated", + "value" : "Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Conversaciones recientes", - "state" : "translated" + "state" : "translated", + "value" : "Παρουσιάστηκε σφάλμα δικτύου. Παρακαλώ δοκιμάστε ξανά." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "最近の会話" + "value" : "A network error occurred. Please try again." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Conversazioni recenti", - "state" : "translated" + "state" : "translated", + "value" : "Ocurrió un error de red. Por favor, inténtalo de nuevo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Conversas Recentes", - "state" : "translated" + "state" : "translated", + "value" : "Une erreur réseau est survenue. Veuillez réessayer." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Recent Conversations", - "state" : "translated" + "state" : "translated", + "value" : "Si è verificato un errore di rete. Riprova." } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Recente gesprekken" + "value" : "ネットワークエラーが発生しました。もう一度お試しください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Conversations récentes" + "value" : "Er is een netwerkfout opgetreden. Probeer het opnieuw." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Letzte Unterhaltungen", - "state" : "translated" + "state" : "translated", + "value" : "Ocorreu um erro de rede. Por favor, tente novamente." } }, "sv" : { "stringUnit" : { - "value" : "Senaste konversationer", - "state" : "translated" + "state" : "translated", + "value" : "Ett nätverksfel uppstod. Försök igen." } } - }, - "comment" : "Title of the widget." + } }, - "Refresh" : { + "A synchronized conversation attachment has an invalid path." : { + "comment" : "Error description for a missing attachment.", + "isCommentAutoGenerated" : true + }, + "A synchronized conversation attachment is missing." : { + "comment" : "Error message when a required attachment for a synchronized conversation is missing.", + "isCommentAutoGenerated" : true + }, + "A synchronized conversation contains invalid data." : { + "comment" : "Error message when a conversation in the cloud has invalid data.", + "isCommentAutoGenerated" : true + }, + "About" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ανανέωση" + "value" : "Info" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Actualizar", - "state" : "translated" + "state" : "translated", + "value" : "Σχετικά" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Aktualisieren" + "value" : "About" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiorna", - "state" : "translated" + "state" : "translated", + "value" : "Acerca de" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Atualizar" + "value" : "À propos" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Refresh", - "state" : "translated" + "state" : "translated", + "value" : "Informazioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vernieuwen", - "state" : "translated" + "state" : "translated", + "value" : "情報" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Actualiser", - "state" : "translated" + "state" : "translated", + "value" : "Over" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "更新", - "state" : "translated" + "state" : "translated", + "value" : "Acerca" } }, "sv" : { "stringUnit" : { - "value" : "Uppdatera", - "state" : "translated" + "state" : "translated", + "value" : "Om" } } } }, - "Maximum of 3 tags reached. Remove one to add another." : { + "Accepted" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Έχετε φτάσει το μέγιστο όριο των 3 ετικετών. Αφαιρέστε μία για να προσθέσετε άλλη.", - "state" : "translated" + "state" : "translated", + "value" : "Akzeptiert" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Se alcanzó el máximo de 3 etiquetas. Elimina una para añadir otra.", - "state" : "translated" + "state" : "translated", + "value" : "Αποδεκτό" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "タグは最大3つまでです。追加するには1つ削除してください。" + "value" : "Accepted" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Raggiunto il massimo di 3 tag. Rimuovi uno per aggiungerne un altro." + "value" : "Aceptado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Máximo de 3 etiquetas atingido. Remova uma para adicionar outra." + "value" : "Accepté" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Maximum of 3 tags reached. Remove one to add another.", - "state" : "translated" + "state" : "translated", + "value" : "Accettato" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nombre maximum de 3 tags atteint. Supprimez-en un pour en ajouter un autre.", - "state" : "translated" + "state" : "translated", + "value" : "承認済み" } }, "nl" : { "stringUnit" : { - "value" : "Maximum van 3 tags bereikt. Verwijder er één om een nieuwe toe te voegen.", - "state" : "translated" + "state" : "translated", + "value" : "Geaccepteerd" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Maximal 3 Tags erreicht. Entferne einen, um einen weiteren hinzuzufügen.", - "state" : "translated" + "state" : "translated", + "value" : "Aceite" } }, "sv" : { "stringUnit" : { - "value" : "Maximalt 3 taggar nådda. Ta bort en för att lägga till en annan.", - "state" : "translated" + "state" : "translated", + "value" : "Accepterad" } } - }, - "comment" : "A message displayed when the user tries to add a tag when they've already reached the maximum of 3." + } }, - "Optimised for LiteLLM. Any OpenAI-compatible server also works." : { + "Add" : { + "comment" : "A button that adds a tag.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Optimiert für LiteLLM. Jeder OpenAI-kompatible Server funktioniert ebenfalls.", - "state" : "translated" + "state" : "translated", + "value" : "Hinzufügen" } }, "el" : { "stringUnit" : { - "value" : "Βελτιστοποιημένο για LiteLLM. Λειτουργεί επίσης με οποιονδήποτε διακομιστή συμβατό με OpenAI.", - "state" : "translated" + "state" : "translated", + "value" : "Προσθήκη" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Optimizado para LiteLLM. También funciona con cualquier servidor compatible con OpenAI." + "value" : "Add" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Ottimizzato per LiteLLM. Funziona anche con qualsiasi server compatibile OpenAI.", - "state" : "translated" + "state" : "translated", + "value" : "Añadir" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Otimizado para LiteLLM. Qualquer servidor compatível com OpenAI também funciona." + "value" : "Ajouter" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Optimized for LiteLLM. Any OpenAI-compatible server also works.", - "state" : "translated" + "state" : "translated", + "value" : "Aggiungi" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Optimisé pour LiteLLM. Tout serveur compatible OpenAI fonctionne également.", - "state" : "translated" + "state" : "translated", + "value" : "追加" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geoptimaliseerd voor LiteLLM. Elke OpenAI-compatibele server werkt ook." + "value" : "Toevoegen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "LiteLLMに最適化。OpenAI互換のサーバーも利用可能。", - "state" : "translated" + "state" : "translated", + "value" : "Adicionar" } }, "sv" : { "stringUnit" : { - "value" : "Optimerad för LiteLLM. Fungerar även med alla OpenAI-kompatibla servrar.", - "state" : "translated" + "state" : "translated", + "value" : "Lägg till" } } - }, - "comment" : "A hint that describes the benefits of using a LiteLLM server." + } }, - "Nucleus sampling. Lower values make output more focused." : { + "Add a comment" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Nukleussampling. Lägre värden gör resultatet mer fokuserat.", - "state" : "translated" + "state" : "translated", + "value" : "Kommentar hinzufügen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Muestreo de núcleo. Valores más bajos hacen que la salida sea más enfocada.", - "state" : "translated" + "state" : "translated", + "value" : "Προσθήκη σχολίου" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Δειγματοληψία πυρήνα. Οι χαμηλότερες τιμές κάνουν την έξοδο πιο εστιασμένη." + "value" : "Add a comment" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Campionamento a nucleo. Valori più bassi rendono l'output più focalizzato." + "value" : "Agregar un comentario" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Amostragem por núcleo. Valores mais baixos tornam a saída mais focada.", - "state" : "translated" + "state" : "translated", + "value" : "Ajouter un commentaire" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Nucleus sampling. Lower values make the output more focused." + "value" : "Aggiungi un commento" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Échantillonnage nucleus. Des valeurs plus basses rendent la sortie plus ciblée.", - "state" : "translated" + "state" : "translated", + "value" : "コメントを追加" } }, "nl" : { "stringUnit" : { - "value" : "Nucleus sampling. Lagere waarden maken de output gerichter.", - "state" : "translated" + "state" : "translated", + "value" : "Een opmerking toevoegen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Nucleus-Sampling. Niedrigere Werte machen die Ausgabe fokussierter.", - "state" : "translated" + "state" : "translated", + "value" : "Adicionar um comentário" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "ニュークレオスサンプリング。値を低くすると出力がより集中します。", - "state" : "translated" + "state" : "translated", + "value" : "Lägg till en kommentar" } } } }, - "Transcribing..." : { + "Add an **Open URLs** action." : { + "comment" : "Step 2 of creating a shortcut using the Shortcuts app.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "文字起こし中...", - "state" : "translated" + "state" : "translated", + "value" : "Füge eine Aktion **URLs öffnen** hinzu." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Transcribiendo..." + "value" : "Προσθέστε μια ενέργεια **Άνοιγμα URL**." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Transkribiere..." + "value" : "Add an **Open URLs** action" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Trascrizione in corso...", - "state" : "translated" + "state" : "translated", + "value" : "Agregar una acción **Abrir URLs**." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A transcrever...", - "state" : "translated" + "state" : "translated", + "value" : "Ajouter une action **Ouvrir des URL**." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Transcribing...", - "state" : "translated" + "state" : "translated", + "value" : "Aggiungi un’azione **Apri URL**." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Transcription en cours...", - "state" : "translated" + "state" : "translated", + "value" : "**URLを開く**アクションを追加してください。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bezig met transcriberen..." + "value" : "Voeg een **Open URL's**-actie toe." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Μεταγραφή σε εξέλιξη...", - "state" : "translated" + "state" : "translated", + "value" : "Adicionar uma ação **Abrir URLs**." } }, "sv" : { "stringUnit" : { - "value" : "Transkriberar...", - "state" : "translated" + "state" : "translated", + "value" : "Lägg till en åtgärd för **Öppna URL:er**." } } - }, - "comment" : "A placeholder text displayed when the user is recording audio." + } }, - "Enter your LiteLLM proxy URL, the gateway to any AI model." : { + "Add images and documents" : { + "comment" : "A description of how to add images and documents to a conversation.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Geben Sie Ihre LiteLLM-Proxy-URL ein, das Tor zu jedem KI-Modell.", - "state" : "translated" + "state" : "translated", + "value" : "Bilder und Dokumente hinzufügen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Introduce la URL de tu proxy LiteLLM, la puerta de acceso a cualquier modelo de IA.", - "state" : "translated" + "state" : "translated", + "value" : "Προσθήκη εικόνων και εγγράφων" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εισαγάγετε το URL διακομιστή μεσολάβησης LiteLLM, την πύλη σε οποιοδήποτε μοντέλο AI." + "value" : "Add images and documents" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inserisci l’URL del proxy LiteLLM, il gateway per qualsiasi modello AI.", - "state" : "translated" + "state" : "translated", + "value" : "Agregar imágenes y documentos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Introduza a URL do seu proxy LiteLLM, a porta de entrada para qualquer modelo de IA." + "value" : "Ajouter des images et des documents" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Enter your LiteLLM proxy URL, the gateway to any AI model.", - "state" : "translated" + "state" : "translated", + "value" : "Aggiungi immagini e documenti" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Entrez l’URL de votre proxy LiteLLM, la passerelle vers n’importe quel modèle d’IA.", - "state" : "translated" + "state" : "translated", + "value" : "画像とドキュメントを追加" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voer uw LiteLLM-proxy-URL in, de toegangspoort tot elk AI-model." + "value" : "Afbeeldingen en documenten toevoegen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "LiteLLMプロキシURLを入力してください。これはあらゆるAIモデルへのゲートウェイです。", - "state" : "translated" + "state" : "translated", + "value" : "Adicionar imagens e documentos" } }, "sv" : { "stringUnit" : { - "value" : "Ange din LiteLLM-proxy-URL, porten till vilken AI-modell som helst.", - "state" : "translated" + "state" : "translated", + "value" : "Lägg till bilder och dokument" } } - }, - "comment" : "A description of the purpose of the server URL field." + } }, - "Media & Files" : { + "Add tag..." : { + "comment" : "A placeholder for a text field that adds a tag to a conversation.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Medien & Dateien", - "state" : "translated" + "state" : "translated", + "value" : "Tag hinzufügen..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Medios y archivos", - "state" : "translated" + "state" : "translated", + "value" : "Προσθήκη ετικέτας..." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "メディアとファイル" + "value" : "Add tag..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Media e file", - "state" : "translated" + "state" : "translated", + "value" : "Agregar etiqueta..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Média e Ficheiros" + "value" : "Ajouter un tag..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Media & Files", - "state" : "translated" + "state" : "translated", + "value" : "Aggiungi tag..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Media en bestanden", - "state" : "translated" + "state" : "translated", + "value" : "タグを追加..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Médias et fichiers" + "value" : "Tag toevoegen..." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Μέσα & Αρχεία", - "state" : "translated" + "state" : "translated", + "value" : "Adicionar etiqueta..." } }, "sv" : { "stringUnit" : { - "value" : "Media och filer", - "state" : "translated" + "state" : "translated", + "value" : "Lägg till tagg..." } } - }, - "comment" : "A button that displays a sheet for selecting and viewing media files and attachments." + } }, - "Local" : { + "Add things you want the assistant to remember across all conversations." : { + "comment" : "A description of the feature that allows the user to add items to their memory.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "ローカル" + "value" : "Fügen Sie Dinge hinzu, an die sich der Assistent in allen Gesprächen erinnern soll." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Local" + "value" : "Προσθέστε πράγματα που θέλετε ο βοηθός να θυμάται σε όλες τις συνομιλίες." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Lokal" + "value" : "Add items you want the assistant to remember across all conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Locale", - "state" : "translated" + "state" : "translated", + "value" : "Agrega cosas que quieres que el asistente recuerde en todas las conversaciones." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Localização", - "state" : "translated" + "state" : "translated", + "value" : "Ajoutez des éléments que vous souhaitez que l’assistant retienne dans toutes les conversations." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Local", - "state" : "translated" + "state" : "translated", + "value" : "Aggiungi elementi che vuoi che l’assistente ricordi in tutte le conversazioni." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Lokaal", - "state" : "translated" + "state" : "translated", + "value" : "アシスタントにすべての会話で記憶してほしい内容を追加してください" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Local", - "state" : "translated" + "state" : "translated", + "value" : "Voeg dingen toe die de assistent in alle gesprekken moet onthouden." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Lokal", - "state" : "translated" + "state" : "translated", + "value" : "Adicione coisas que pretende que o assistente lembre em todas as conversas." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Τοπικό", - "state" : "translated" + "state" : "translated", + "value" : "Lägg till saker du vill att assistenten ska komma ihåg i alla konversationer." } } } }, - "Photo Library" : { + "Add to Favourites" : { + "comment" : "A label for a button that adds a message to the user's favourites.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Βιβλιοθήκη Φωτογραφιών" + "value" : "Zu Favoriten hinzufügen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Biblioteca de fotos", - "state" : "translated" + "state" : "translated", + "value" : "Προσθήκη στα Αγαπημένα" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Fotobibliothek" + "value" : "Add to Favorites" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Libreria foto", - "state" : "translated" + "state" : "translated", + "value" : "Añadir a Favoritos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Biblioteca de Fotos" + "value" : "Ajouter aux favoris" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Photo Library", - "state" : "translated" + "state" : "translated", + "value" : "Aggiungi ai Preferiti" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Bibliothèque de photos", - "state" : "translated" + "state" : "translated", + "value" : "お気に入りに追加" } }, "nl" : { "stringUnit" : { - "value" : "Fotobibliotheek", - "state" : "translated" + "state" : "translated", + "value" : "Toevoegen aan favorieten" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "写真ライブラリ", - "state" : "translated" + "state" : "translated", + "value" : "Adicionar aos Favoritos" } }, "sv" : { "stringUnit" : { - "value" : "Fotobibliotek", - "state" : "translated" + "state" : "translated", + "value" : "Lägg till i favoriter" } } } }, - "Use these sources to answer the user's question. Cite sources using [Source Title](URL) format." : { + "All" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Verwenden Sie diese Quellen, um die Frage des Benutzers zu beantworten. Zitieren Sie Quellen im Format [Quellentitel](URL)." + "value" : "Alle" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Utilice estas fuentes para responder a la pregunta del usuario. Cite las fuentes usando el formato [Título de la fuente](URL).", - "state" : "translated" + "state" : "translated", + "value" : "Όλα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Använd dessa källor för att besvara användarens fråga. Ange källor med formatet [Källtitel](URL)." + "value" : "All" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Usa queste fonti per rispondere alla domanda dell'utente. Cita le fonti utilizzando il formato [Titolo della fonte](URL)." + "value" : "Todos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Utilize estas fontes para responder à pergunta do utilizador. Cite as fontes usando o formato [Título da Fonte](URL).", - "state" : "translated" + "state" : "translated", + "value" : "Tout" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Use these sources to answer the user's question. Cite sources using [Source Title](URL) format.", - "state" : "translated" + "state" : "translated", + "value" : "Tutti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gebruik deze bronnen om de vraag van de gebruiker te beantwoorden. Verwijs naar bronnen met de notatie [Bron Titel](URL).", - "state" : "translated" + "state" : "translated", + "value" : "すべて" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Utilisez ces sources pour répondre à la question de l'utilisateur. Citez les sources en utilisant le format [Titre de la source](URL).", - "state" : "translated" + "state" : "translated", + "value" : "Alles" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "これらの情報源を使用してユーザーの質問に回答してください。情報源は[情報源タイトル](URL)形式で引用してください。", - "state" : "translated" + "state" : "translated", + "value" : "Tudo" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Χρησιμοποιήστε αυτές τις πηγές για να απαντήσετε στην ερώτηση του χρήστη. Αναφέρετε τις πηγές χρησιμοποιώντας τη μορφή [Τίτλος Πηγής](URL).", - "state" : "translated" + "state" : "translated", + "value" : "Alla" } } - }, - "comment" : "Citation guide for web search results." + } }, - "About" : { + "All local settings and credentials will be deleted. iCloud data will not be affected." : { + "comment" : "A confirmation alert message.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Info", - "state" : "translated" + "state" : "translated", + "value" : "Alle lokalen Einstellungen und Anmeldedaten werden gelöscht. iCloud-Daten bleiben unberührt." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Acerca de", - "state" : "translated" + "state" : "translated", + "value" : "Όλες οι τοπικές ρυθμίσεις και τα διαπιστευτήρια θα διαγραφούν. Τα δεδομένα iCloud δεν θα επηρεαστούν." } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Σχετικά", - "state" : "translated" + "state" : "translated", + "value" : "All local settings and credentials will be deleted. iCloud data will not be affected." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Informazioni" + "value" : "Se eliminarán todas las configuraciones y credenciales locales. Los datos de iCloud no se verán afectados." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Acerca" + "value" : "Tous les paramètres locaux et identifiants seront supprimés. Les données iCloud ne seront pas affectées." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "About" + "value" : "Tutte le impostazioni locali e le credenziali verranno eliminate. I dati di iCloud non saranno interessati." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "À propos", - "state" : "translated" + "state" : "translated", + "value" : "すべてのローカル設定と認証情報が削除されます。iCloudのデータには影響しません。" } }, "nl" : { "stringUnit" : { - "value" : "Over", - "state" : "translated" + "state" : "translated", + "value" : "Alle lokale instellingen en inloggegevens worden verwijderd. iCloud-gegevens blijven ongewijzigd." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "情報", - "state" : "translated" + "state" : "translated", + "value" : "Todas as definições locais e credenciais serão eliminadas. Os dados do iCloud não serão afetados." } }, "sv" : { "stringUnit" : { - "value" : "Om", - "state" : "translated" + "state" : "translated", + "value" : "Alla lokala inställningar och inloggningsuppgifter kommer att raderas. iCloud-data påverkas inte." } } } }, - "Models" : { + "All Tags" : { + "comment" : "The default tag to be selected when the widget is configured.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Μοντέλα", - "state" : "translated" + "state" : "translated", + "value" : "Alle Tags" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Modelos", - "state" : "translated" + "state" : "translated", + "value" : "Όλες οι ετικέτες" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "モデル" + "value" : "All Tags" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Modelli", - "state" : "translated" + "state" : "translated", + "value" : "Todas las etiquetas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Modelos" + "value" : "Tous les tags" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Models", - "state" : "translated" + "state" : "translated", + "value" : "Tutti i tag" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Modèles", - "state" : "translated" + "state" : "translated", + "value" : "すべてのタグ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Modellen" + "value" : "Alle tags" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Modelle", - "state" : "translated" + "state" : "translated", + "value" : "Todas as Etiquetas" } }, "sv" : { "stringUnit" : { - "value" : "Modeller", - "state" : "translated" + "state" : "translated", + "value" : "Alla taggar" } } } }, - "The model returned an invalid agent response." : { + "Anonymous" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το μοντέλο επέστρεψε μη έγκυρη απάντηση πράκτορα.", - "state" : "translated" + "state" : "translated", + "value" : "Anonym" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "El modelo devolvió una respuesta de agente no válida." + "value" : "Ανώνυμος" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Das Modell hat eine ungültige Agentenantwort zurückgegeben." + "value" : "Anonymous" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il modello ha restituito una risposta agente non valida.", - "state" : "translated" + "state" : "translated", + "value" : "Anónimo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O modelo devolveu uma resposta de agente inválida.", - "state" : "translated" + "state" : "translated", + "value" : "Anonyme" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The model returned an invalid agent response.", - "state" : "translated" + "state" : "translated", + "value" : "Anonimo" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Le modèle a renvoyé une réponse d’agent invalide.", - "state" : "translated" + "state" : "translated", + "value" : "匿名" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Het model gaf een ongeldige agentrespons terug." + "value" : "Anoniem" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "モデルが無効なエージェント応答を返しました。", - "state" : "translated" + "state" : "translated", + "value" : "Anónimo" } }, "sv" : { "stringUnit" : { - "value" : "Modellen returnerade ett ogiltigt agent-svar.", - "state" : "translated" + "state" : "translated", + "value" : "Anonym" } } - }, - "comment" : "Error message displayed when the model returns an invalid agent response." + } }, - "Enter your name" : { + "Answer a tricky question" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Gib deinen Namen ein", - "state" : "translated" + "state" : "translated", + "value" : "Beantworte eine knifflige Frage" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Introduce tu nombre", - "state" : "translated" + "state" : "translated", + "value" : "Απάντησε σε μια δύσκολη ερώτηση" } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "Ange ditt namn", - "state" : "translated" + "state" : "translated", + "value" : "Answer a tricky question" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Inserisci il tuo nome" + "value" : "Responder una pregunta difícil" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Introduza o seu nome", - "state" : "translated" + "state" : "translated", + "value" : "Répondre à une question délicate" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Enter your name" + "value" : "Rispondi a una domanda difficile" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Entrez votre nom", - "state" : "translated" + "state" : "translated", + "value" : "難しい質問に答える" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voer uw naam in" + "value" : "Beantwoord een lastige vraag" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "名前を入力してください", - "state" : "translated" + "state" : "translated", + "value" : "Responder a uma pergunta difícil" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Εισάγετε το όνομά σας", - "state" : "translated" + "state" : "translated", + "value" : "Svara på en klurig fråga" } } } }, - "Brainstorm ideas for a project" : { + "Any additional context for the assistant" : { + "comment" : "A label for a text field where the user can add additional context for the assistant.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Ideen für ein Projekt sammeln", - "state" : "translated" + "state" : "translated", + "value" : "Zusätzlicher Kontext für den Assistenten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Generar ideas para un proyecto", - "state" : "translated" + "state" : "translated", + "value" : "Πρόσθετο πλαίσιο για τον βοηθό" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Brainstorma idéer för ett projekt" + "value" : "Additional context for the assistant" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Genera idee per un progetto" + "value" : "Contexto adicional para el asistente" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Gerar ideias para um projeto", - "state" : "translated" + "state" : "translated", + "value" : "Contexte supplémentaire pour l’assistant" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Brainstorm ideas for a project" + "value" : "Contesto aggiuntivo per l’assistente" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Trouver des idées pour un projet", - "state" : "translated" + "state" : "translated", + "value" : "アシスタントへの追加情報" } }, "nl" : { "stringUnit" : { - "value" : "Bedenk ideeën voor een project", - "state" : "translated" + "state" : "translated", + "value" : "Aanvullende context voor de assistent" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "プロジェクトのアイデアをブレインストーミングする", - "state" : "translated" + "state" : "translated", + "value" : "Contexto adicional para o assistente" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Καταιγισμός ιδεών για ένα έργο", - "state" : "translated" + "state" : "translated", + "value" : "Ytterligare information för assistenten" } } } }, - "See conversations for a selected tag." : { + "Any additional context you want the assistant to know. Max 500 characters." : { + "comment" : "A description of the extra information section.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Siehe Unterhaltungen für ein ausgewähltes Tag.", - "state" : "translated" + "state" : "translated", + "value" : "Zusätzliche Informationen, die Sie dem Assistenten mitteilen möchten. Maximal 500 Zeichen." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Ver conversaciones para una etiqueta seleccionada" + "value" : "Περιγραφή της ενότητας με τις επιπλέον πληροφορίες." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "選択したタグの会話を表示します" + "value" : "Any additional context you want the assistant to know. Max 500 characters." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Visualizza le conversazioni per un tag selezionato", - "state" : "translated" + "state" : "translated", + "value" : "Cualquier información adicional que desees que el asistente conozca. Máximo 500 caracteres." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ver conversas para uma etiqueta selecionada" + "value" : "Toute information supplémentaire que vous souhaitez que l’assistant connaisse. Maximum 500 caractères." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "See conversations for the selected tag", - "state" : "translated" + "state" : "translated", + "value" : "Qualsiasi informazione aggiuntiva che desideri comunicare all’assistente. Massimo 500 caratteri." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bekijk gesprekken voor een geselecteerd label.", - "state" : "translated" + "state" : "translated", + "value" : "追加情報セクションの説明です。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Voir les conversations pour un tag sélectionné", - "state" : "translated" + "state" : "translated", + "value" : "Eventuele aanvullende context die u wilt dat de assistent weet. Maximaal 500 tekens." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δείτε συνομιλίες για μια επιλεγμένη ετικέτα.", - "state" : "translated" + "state" : "translated", + "value" : "Qualquer informação adicional que queira que o assistente saiba. Máx. 500 caracteres." } }, "sv" : { "stringUnit" : { - "value" : "Se konversationer för en vald tagg.", - "state" : "translated" + "state" : "translated", + "value" : "Eventuell ytterligare information du vill att assistenten ska känna till. Max 500 tecken." } } - }, - "comment" : "Description of the widget that shows conversations assigned to a tag selected in the widget configuration." + } }, - "Assistant" : { + "Any Model" : { + "comment" : "A description of an app feature that allows users to interact with any large language model.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Assistent" + "value" : "Beliebiges Modell" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Asistente", - "state" : "translated" + "state" : "translated", + "value" : "Οποιοδήποτε Μοντέλο" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Βοηθός" + "value" : "Any Model" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Assistente", - "state" : "translated" + "state" : "translated", + "value" : "Cualquier modelo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Assistente", - "state" : "translated" + "state" : "translated", + "value" : "N’importe quel modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Assistant", - "state" : "translated" + "state" : "translated", + "value" : "Qualsiasi modello" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Assistent", - "state" : "translated" + "state" : "translated", + "value" : "任意のモデル" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Assistant" + "value" : "Elk model" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アシスタント", - "state" : "translated" + "state" : "translated", + "value" : "Qualquer Modelo" } }, "sv" : { "stringUnit" : { - "value" : "Assistent", - "state" : "translated" + "state" : "translated", + "value" : "Vilken modell som helst" } } - }, - "comment" : "A name for the assistant." + } }, - "Touch and hold a message to edit, regenerate, branch, or save it as a favourite." : { + "API Key (Optional)" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Tippen und halten Sie eine Nachricht, um sie zu bearbeiten, neu zu generieren, zu verzweigen oder als Favorit zu speichern." + "value" : "API-Schlüssel (optional)" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mantén pulsado un mensaje para editarlo, regenerarlo, ramificarlo o guardarlo como favorito.", - "state" : "translated" + "state" : "translated", + "value" : "Κλειδί API (Προαιρετικό)" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Πατήστε παρατεταμένα ένα μήνυμα για να το επεξεργαστείτε, αναγεννήσετε, διακλαδώσετε ή αποθηκεύσετε στα αγαπημένα." + "value" : "API Key (Optional)" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tocca e tieni premuto un messaggio per modificarlo, rigenerarlo, creare un ramo o salvarlo tra i preferiti.", - "state" : "translated" + "state" : "translated", + "value" : "Clave API (Opcional)" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Toque e mantenha uma mensagem para editar, regenerar, ramificar ou guardar como favorita." + "value" : "Clé API (facultatif)" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Touch and hold a message to edit, regenerate, branch, or save it as a favorite.", - "state" : "translated" + "state" : "translated", + "value" : "Chiave API (Opzionale)" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Raak een bericht aan en houd vast om het te bewerken, opnieuw te genereren, vertakken of als favoriet op te slaan.", - "state" : "translated" + "state" : "translated", + "value" : "APIキー(任意)" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Touchez et maintenez un message pour le modifier, régénérer, créer une branche ou l’enregistrer en favori.", - "state" : "translated" + "state" : "translated", + "value" : "API-sleutel (optioneel)" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メッセージを長押しして編集、再生成、分岐、またはお気に入りに保存します。", - "state" : "translated" + "state" : "translated", + "value" : "Chave API (Opcional)" } }, "sv" : { "stringUnit" : { - "value" : "Tryck och håll på ett meddelande för att redigera, generera om, förgrena eller spara det som favorit.", - "state" : "translated" + "state" : "translated", + "value" : "API-nyckel (valfritt)" } } - }, - "comment" : "A description of the action to edit, regenerate, branch, or save a message." + } }, - "Each conversation can use a different model. Features depend on its capabilities." : { + "App Data" : { + "comment" : "A section in the settings view that allows the user to reset all local data.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κάθε συνομιλία μπορεί να χρησιμοποιεί διαφορετικό μοντέλο. Οι λειτουργίες εξαρτώνται από τις δυνατότητές του.", - "state" : "translated" + "state" : "translated", + "value" : "App-Daten" } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Jede Unterhaltung kann ein anderes Modell verwenden. Die Funktionen hängen von dessen Fähigkeiten ab." + "value" : "Δεδομένα εφαρμογής" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cada conversación puede usar un modelo diferente. Las funciones dependen de sus capacidades." + "value" : "App Data" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Ogni conversazione può utilizzare un modello diverso. Le funzionalità dipendono dalle sue capacità.", - "state" : "translated" + "state" : "translated", + "value" : "Datos de la app" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Cada conversa pode usar um modelo diferente. As funcionalidades dependem das suas capacidades.", - "state" : "translated" + "state" : "translated", + "value" : "Données de l’application" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Each conversation can use a different model. Features depend on its capabilities." + "value" : "Dati app" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Elke conversatie kan een ander model gebruiken. Functies zijn afhankelijk van de mogelijkheden ervan.", - "state" : "translated" + "state" : "translated", + "value" : "アプリデータ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Chaque conversation peut utiliser un modèle différent. Les fonctionnalités dépendent de ses capacités.", - "state" : "translated" + "state" : "translated", + "value" : "App-gegevens" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "各会話は異なるモデルを使用できます。機能はその能力に依存します。", - "state" : "translated" + "state" : "translated", + "value" : "Dados da App" } }, "sv" : { "stringUnit" : { - "value" : "Varje konversation kan använda en annan modell. Funktionerna beror på dess kapacitet.", - "state" : "translated" + "state" : "translated", + "value" : "Appdata" } } - }, - "comment" : "A description of the features available for each model." + } }, - "Search Tool" : { + "App data could not be completely reset. Your remaining data was not discarded." : { + "comment" : "Error message displayed when app data reset fails.", + "isCommentAutoGenerated" : true + }, + "App Data Reset Failed" : { + "comment" : "A title for a view that indicates that app data reset failed.", + "isCommentAutoGenerated" : true + }, + "Apple Shortcuts" : { + "comment" : "A heading for the Apple Shortcuts section.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Suchwerkzeug", - "state" : "translated" + "state" : "translated", + "value" : "Apple Kurzbefehle" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "検索ツール" + "value" : "Συντομεύσεις Apple" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Herramienta de búsqueda" + "value" : "Apple Shortcuts" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Strumento di ricerca", - "state" : "translated" + "state" : "translated", + "value" : "Atajos de Apple" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ferramenta de Pesquisa" + "value" : "Raccourcis Apple" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Search Tool", - "state" : "translated" + "state" : "translated", + "value" : "Scorciatoie Apple" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Zoekhulpmiddel", - "state" : "translated" + "state" : "translated", + "value" : "Appleショートカット" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Outil de recherche", - "state" : "translated" + "state" : "translated", + "value" : "Apple-snelkoppelingen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εργαλείο αναζήτησης", - "state" : "translated" + "state" : "translated", + "value" : "Atalhos Apple" } }, "sv" : { "stringUnit" : { - "value" : "Sökverktyg", - "state" : "translated" + "state" : "translated", + "value" : "Apple-genvägar" } } - }, - "comment" : "A label for the search tool picker." + } }, - "Creative" : { + "Approximate cost of this conversation based on token usage and model pricing." : { + "comment" : "A description of the cost of a conversation.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δημιουργικό", - "state" : "translated" + "state" : "translated", + "value" : "Ungefähre Kosten dieses Gesprächs basierend auf Tokenverbrauch und Modellpreisen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Creativo", - "state" : "translated" + "state" : "translated", + "value" : "Προσεγγιστικό κόστος αυτής της συνομιλίας βάσει χρήσης tokens και τιμολόγησης μοντέλου." } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "Kreativ", - "state" : "translated" + "state" : "translated", + "value" : "Approximate cost of this conversation based on token usage and model pricing." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Creativo" + "value" : "Costo aproximado de esta conversación basado en el uso de tokens y la tarifa del modelo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Criativo", - "state" : "translated" + "state" : "translated", + "value" : "Coût approximatif de cette conversation basé sur l’utilisation des tokens et la tarification du modèle." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Creative" + "value" : "Costo approssimativo di questa conversazione basato sull’uso dei token e sul prezzo del modello." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Creatief", - "state" : "translated" + "state" : "translated", + "value" : "この会話の概算コスト(トークン使用量とモデル料金に基づく)" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Créatif" + "value" : "Geschatte kosten van dit gesprek op basis van tokengebruik en modelprijzen." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Kreativ", - "state" : "translated" + "state" : "translated", + "value" : "Custo aproximado desta conversa com base no uso de tokens e preços do modelo." } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "クリエイティブ", - "state" : "translated" + "state" : "translated", + "value" : "Ungefärlig kostnad för denna konversation baserat på tokenanvändning och modellpriser." } } } }, - "The selected image could not be prepared. Please choose another image." : { + "Are you sure you want to delete this comment?" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "選択した画像を準備できませんでした。別の画像を選択してください。", - "state" : "translated" + "state" : "translated", + "value" : "Möchten Sie diesen Kommentar wirklich löschen?" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudo preparar la imagen seleccionada. Elige otra imagen.", - "state" : "translated" + "state" : "translated", + "value" : "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το σχόλιο;" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Den valda bilden kunde inte förberedas. Välj en annan bild." + "value" : "Are you sure you want to delete this comment?" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Non è stato possibile preparare l’immagine selezionata. Scegli un’altra immagine.", - "state" : "translated" + "state" : "translated", + "value" : "¿Seguro que quieres eliminar este comentario?" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Não foi possível preparar a imagem selecionada. Escolha outra imagem." + "value" : "Êtes-vous sûr de vouloir supprimer ce commentaire ?" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The selected image could not be prepared. Please choose another image.", - "state" : "translated" + "state" : "translated", + "value" : "Sei sicuro di voler eliminare questo commento?" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De geselecteerde afbeelding kon niet worden voorbereid. Kies een andere afbeelding.", - "state" : "translated" + "state" : "translated", + "value" : "このコメントを削除してもよろしいですか?" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "L’image sélectionnée n’a pas pu être préparée. Veuillez choisir une autre image." + "value" : "Weet je zeker dat je deze opmerking wilt verwijderen?" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δεν ήταν δυνατή η προετοιμασία της επιλεγμένης εικόνας. Επιλέξτε άλλη εικόνα.", - "state" : "translated" + "state" : "translated", + "value" : "Tem a certeza de que pretende eliminar este comentário?" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Das ausgewählte Bild konnte nicht vorbereitet werden. Bitte wählen Sie ein anderes Bild aus.", - "state" : "translated" + "state" : "translated", + "value" : "Är du säker på att du vill ta bort den här kommentaren?" } } - }, - "comment" : "Error message displayed when an error occurs during the preparation of an image." + } }, - "Are you sure you want to delete this suggestion?" : { + "Are you sure you want to delete this conversation? This action cannot be undone." : { + "comment" : "A confirmation dialog message for deleting a conversation.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Möchten Sie diesen Vorschlag wirklich löschen?", - "state" : "translated" + "state" : "translated", + "value" : "Möchten Sie diese Unterhaltung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "¿Seguro que quieres eliminar esta sugerencia?" + "value" : "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή τη συνομιλία; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Är du säker på att du vill ta bort detta förslag?" + "value" : "Are you sure you want to delete this conversation? This action cannot be undone." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sei sicuro di voler eliminare questo suggerimento?", - "state" : "translated" + "state" : "translated", + "value" : "¿Seguro que quieres eliminar esta conversación? Esta acción no se puede deshacer." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tem a certeza de que pretende eliminar esta sugestão?", - "state" : "translated" + "state" : "translated", + "value" : "Voulez-vous vraiment supprimer cette conversation ? Cette action est irréversible." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Are you sure you want to delete this suggestion?" + "value" : "Sei sicuro di voler eliminare questa conversazione? Questa azione non può essere annullata." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Weet je zeker dat je deze suggestie wilt verwijderen?", - "state" : "translated" + "state" : "translated", + "value" : "この会話を削除してもよろしいですか?この操作は元に戻せません。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Êtes-vous sûr de vouloir supprimer cette suggestion ?", - "state" : "translated" + "state" : "translated", + "value" : "Weet u zeker dat u dit gesprek wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "この提案を削除してもよろしいですか?", - "state" : "translated" + "state" : "translated", + "value" : "Tem a certeza de que pretende eliminar esta conversa? Esta ação não pode ser desfeita." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή την πρόταση;", - "state" : "translated" + "state" : "translated", + "value" : "Är du säker på att du vill radera den här konversationen? Denna åtgärd kan inte ångras." } } } }, - "Fully open source on GitHub — inspect or contribute" : { + "Are you sure you want to delete this suggestion?" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Vollständig Open Source auf GitHub — ansehen oder mitwirken", - "state" : "translated" + "state" : "translated", + "value" : "Möchten Sie diesen Vorschlag wirklich löschen?" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Totalmente de código abierto en GitHub: revisa o contribuye", - "state" : "translated" + "state" : "translated", + "value" : "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή την πρόταση;" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "GitHubで完全にオープンソース — 調査や貢献が可能" + "value" : "Are you sure you want to delete this suggestion?" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Completamente open source su GitHub — ispeziona o contribuisci", - "state" : "translated" + "state" : "translated", + "value" : "¿Seguro que quieres eliminar esta sugerencia?" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Totalmente open source no GitHub — inspecione ou contribua", - "state" : "translated" + "state" : "translated", + "value" : "Êtes-vous sûr de vouloir supprimer cette suggestion ?" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Fully open source on GitHub — inspect or contribute" + "value" : "Sei sicuro di voler eliminare questo suggerimento?" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Entièrement open source sur GitHub — inspectez ou contribuez", - "state" : "translated" + "state" : "translated", + "value" : "この提案を削除してもよろしいですか?" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Volledig open source op GitHub — bekijken of bijdragen" + "value" : "Weet je zeker dat je deze suggestie wilt verwijderen?" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πλήρως ανοιχτού κώδικα στο GitHub — επιθεωρήστε ή συνεισφέρετε", - "state" : "translated" + "state" : "translated", + "value" : "Tem a certeza de que pretende eliminar esta sugestão?" } }, "sv" : { "stringUnit" : { - "value" : "Helt öppen källkod på GitHub — granska eller bidra", - "state" : "translated" + "state" : "translated", + "value" : "Är du säker på att du vill ta bort detta förslag?" } } - }, - "comment" : "A description of the Open Source aspect of OpenClient." + } }, - "Voice ID" : { + "Assistant" : { + "comment" : "A name for the assistant.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ταυτότητα φωνής" + "value" : "Assistent" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "ID de voz", - "state" : "translated" + "state" : "translated", + "value" : "Βοηθός" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sprach-ID" + "value" : "Assistant" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "ID voce", - "state" : "translated" + "state" : "translated", + "value" : "Asistente" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "ID de voz", - "state" : "translated" + "state" : "translated", + "value" : "Assistant" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Voice ID" + "value" : "Assistente" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Stem-ID", - "state" : "translated" + "state" : "translated", + "value" : "アシスタント" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "ID vocal", - "state" : "translated" + "state" : "translated", + "value" : "Assistent" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "音声ID", - "state" : "translated" + "state" : "translated", + "value" : "Assistente" } }, "sv" : { "stringUnit" : { - "value" : "Röst-ID", - "state" : "translated" + "state" : "translated", + "value" : "Assistent" } } - }, - "comment" : "A label for the voice ID field." + } }, - "Pricing" : { + "Attach a photo or PDF so the model can analyse its content." : { + "comment" : "A description of how to attach images or PDFs to a message.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Τιμολόγηση", - "state" : "translated" + "state" : "translated", + "value" : "Fügen Sie ein Foto oder eine PDF-Datei an, damit das Modell den Inhalt analysieren kann." } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "価格情報" + "value" : "Επισυνάψτε μια φωτογραφία ή PDF ώστε το μοντέλο να αναλύσει το περιεχόμενό του." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Precios" + "value" : "Attach a photo or PDF so the model can analyze its content." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Prezzi" + "value" : "Adjunta una foto o PDF para que el modelo pueda analizar su contenido." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Preços", - "state" : "translated" + "state" : "translated", + "value" : "Joignez une photo ou un PDF pour que le modèle puisse analyser son contenu." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Pricing", - "state" : "translated" + "state" : "translated", + "value" : "Allega una foto o un PDF in modo che il modello possa analizzarne il contenuto." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Prijzen", - "state" : "translated" + "state" : "translated", + "value" : "写真またはPDFを添付して、モデルが内容を分析できるようにしてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Tarification", - "state" : "translated" + "state" : "translated", + "value" : "Voeg een foto of PDF toe zodat het model de inhoud kan analyseren." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Preise", - "state" : "translated" + "state" : "translated", + "value" : "Anexe uma foto ou PDF para que o modelo possa analisar o seu conteúdo." } }, "sv" : { "stringUnit" : { - "value" : "Prissättning", - "state" : "translated" + "state" : "translated", + "value" : "Bifoga ett foto eller en PDF så att modellen kan analysera dess innehåll." } } - }, - "comment" : "A section that displays the pricing information for a model." + } }, - "Server" : { + "Attach an image or PDF, or drag files into the chat for the model to analyse." : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Server", - "state" : "translated" + "state" : "translated", + "value" : "Fügen Sie ein Bild oder PDF an oder ziehen Sie Dateien in den Chat, damit das Modell sie analysieren kann." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Servidor" + "value" : "Επισυνάψτε μια εικόνα ή PDF, ή σύρετε αρχεία στη συνομιλία για ανάλυση από το μοντέλο." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Διακομιστής" + "value" : "Attach an image or PDF, or drag files into the chat for the model to analyze." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Server", - "state" : "translated" + "state" : "translated", + "value" : "Adjunta una imagen o PDF, o arrastra archivos al chat para que el modelo los analice." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Servidor", - "state" : "translated" + "state" : "translated", + "value" : "Joignez une image ou un PDF, ou glissez des fichiers dans la conversation pour que le modèle les analyse." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Server", - "state" : "translated" + "state" : "translated", + "value" : "Allega un'immagine o un PDF, oppure trascina i file nella chat per farli analizzare dal modello." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Server", - "state" : "translated" + "state" : "translated", + "value" : "画像またはPDFを添付するか、ファイルをチャットにドラッグしてモデルに解析させてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Serveur" + "value" : "Voeg een afbeelding of PDF toe, of sleep bestanden in de chat voor analyse door het model." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバー", - "state" : "translated" + "state" : "translated", + "value" : "Anexe uma imagem ou PDF, ou arraste ficheiros para o chat para o modelo analisar." } }, "sv" : { "stringUnit" : { - "value" : "Server", - "state" : "translated" + "state" : "translated", + "value" : "Bifoga en bild eller PDF, eller dra filer till chatten för modellen att analysera." } } } }, - "Something went wrong. Please try again." : { + "Attach Image" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "問題が発生しました。もう一度お試しください。" + "value" : "Bild anhängen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Algo salió mal. Por favor, inténtalo de nuevo.", - "state" : "translated" + "state" : "translated", + "value" : "Επισύναψη εικόνας" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Κάτι πήγε στραβά. Παρακαλώ δοκιμάστε ξανά." + "value" : "Attach Image" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Qualcosa è andato storto. Riprova.", - "state" : "translated" + "state" : "translated", + "value" : "Adjuntar imagen" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Algo correu mal. Por favor, tente novamente.", - "state" : "translated" + "state" : "translated", + "value" : "Joindre une image" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Something went wrong. Please try again.", - "state" : "translated" + "state" : "translated", + "value" : "Allega immagine" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Er is iets misgegaan. Probeer het opnieuw.", - "state" : "translated" + "state" : "translated", + "value" : "画像を添付" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Une erreur est survenue. Veuillez réessayer." + "value" : "Afbeelding toevoegen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", - "state" : "translated" + "state" : "translated", + "value" : "Anexar imagem" } }, "sv" : { "stringUnit" : { - "value" : "Något gick fel. Försök igen.", - "state" : "translated" + "state" : "translated", + "value" : "Bifoga bild" } } } }, - "Existing tags keep their assigned color." : { + "Author" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Vorhandene Tags behalten ihre zugewiesene Farbe.", - "state" : "translated" + "state" : "translated", + "value" : "Autor" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Las etiquetas existentes mantienen su color asignado.", - "state" : "translated" + "state" : "translated", + "value" : "Συγγραφέας" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Οι υπάρχες ετικέτες διατηρούν το εκχωρημένο τους χρώμα." + "value" : "Author" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "I tag esistenti mantengono il colore assegnato.", - "state" : "translated" + "state" : "translated", + "value" : "Autor" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "As etiquetas existentes mantêm a sua cor atribuída.", - "state" : "translated" + "state" : "translated", + "value" : "Auteur" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Existing tags keep their assigned color" + "value" : "Autore" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bestaande tags behouden hun toegewezen kleur.", - "state" : "translated" + "state" : "translated", + "value" : "作成者" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Les tags existants conservent leur couleur attribuée." + "value" : "Auteur" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "既存のタグは割り当てられた色を保持します。", - "state" : "translated" + "state" : "translated", + "value" : "Autor" } }, "sv" : { "stringUnit" : { - "value" : "Befintliga taggar behåller sin tilldelade färg.", - "state" : "translated" + "state" : "translated", + "value" : "Författare" } } - }, - "comment" : "A description of the behavior of existing tags." + } }, - "Pinned" : { + "Automate OpenClient with the Shortcuts app using the URL scheme actions above." : { + "comment" : "A description of how to use the Shortcuts app to open OpenClient.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ピン留め済み", - "state" : "translated" + "state" : "translated", + "value" : "Automatisieren Sie OpenClient mit der Kurzbefehle-App unter Verwendung der oben genannten URL-Schema-Aktionen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Fijado", - "state" : "translated" + "state" : "translated", + "value" : "Αυτοματοποιήστε το OpenClient με την εφαρμογή Συντομεύσεις χρησιμοποιώντας τις παραπάνω ενέργειες σχήματος URL." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Καρφιτσωμένα" + "value" : "Automate OpenClient with the Shortcuts app using the URL scheme actions above." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Fissate", - "state" : "translated" + "state" : "translated", + "value" : "Automatiza OpenClient con la app Atajos usando las acciones del esquema de URL mencionadas arriba." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Fixadas", - "state" : "translated" + "state" : "translated", + "value" : "Automatisez OpenClient avec l’app Raccourcis en utilisant les actions du schéma d’URL ci-dessus." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Pinned" + "value" : "Automatizza OpenClient con l’app Comandi usando le azioni dello schema URL sopra." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vastgezet", - "state" : "translated" + "state" : "translated", + "value" : "上記のURLスキームアクションを使って、ショートカットアプリでOpenClientを自動化します。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Épinglé" + "value" : "Automatiseer OpenClient met de Opdrachten-app via de bovenstaande URL-scheme-acties." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Angeheftet", - "state" : "translated" + "state" : "translated", + "value" : "Automatize o OpenClient com a app Atalhos usando as ações do esquema URL acima." } }, "sv" : { "stringUnit" : { - "value" : "Fastnålad", - "state" : "translated" + "state" : "translated", + "value" : "Automatisera OpenClient med appen Genvägar med hjälp av URL-schemakommandona ovan." } } - }, - "comment" : "Title for the section of conversations that are pinned." + } }, - "Edit" : { + "Available Servers" : { + "comment" : "A section title for the list of MCP servers available to the user.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Επεξεργασία" + "value" : "Verfügbare Server" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Editar", - "state" : "translated" + "state" : "translated", + "value" : "Διαθέσιμοι Διακομιστές" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Redigera" + "value" : "Available Servers" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Modifica", - "state" : "translated" + "state" : "translated", + "value" : "Servidores disponibles" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Editar", - "state" : "translated" + "state" : "translated", + "value" : "Serveurs disponibles" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Edit" + "value" : "Server disponibili" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bewerken", - "state" : "translated" + "state" : "translated", + "value" : "利用可能なサーバー" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Modifier", - "state" : "translated" + "state" : "translated", + "value" : "Beschikbare servers" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Bearbeiten", - "state" : "translated" + "state" : "translated", + "value" : "Servidores Disponíveis" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "編集", - "state" : "translated" + "state" : "translated", + "value" : "Tillgängliga servrar" } } - }, - "comment" : "A button that opens a sheet for editing a template." + } }, - "Start a new conversation to begin chatting" : { + "Back" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Starta en ny konversation för att börja chatta", - "state" : "translated" + "state" : "translated", + "value" : "Zurück" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "新しい会話を始めてチャットを開始してください" + "value" : "Πίσω" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Inicia una nueva conversación para comenzar a chatear" + "value" : "Back" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inizia una nuova conversazione per iniziare a chattare", - "state" : "translated" + "state" : "translated", + "value" : "Atrás" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Inicie uma nova conversa para começar a conversar", - "state" : "translated" + "state" : "translated", + "value" : "Retour" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Start a new conversation to begin chatting", - "state" : "translated" + "state" : "translated", + "value" : "Indietro" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Begin een nieuw gesprek om te chatten", - "state" : "translated" + "state" : "translated", + "value" : "戻る" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Commencez une nouvelle conversation pour commencer à discuter" + "value" : "Terug" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Beginnen Sie eine neue Unterhaltung, um zu chatten", - "state" : "translated" + "state" : "translated", + "value" : "Voltar" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Ξεκινήστε μια νέα συνομιλία για να αρχίσετε να συνομιλείτε", - "state" : "translated" + "state" : "translated", + "value" : "Tillbaka" } } } }, - "No pinned conversations" : { + "Backup Error" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Inga fastnålda konversationer", - "state" : "translated" + "state" : "translated", + "value" : "Sicherungsfehler" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No hay conversaciones fijadas", - "state" : "translated" + "state" : "translated", + "value" : "Σφάλμα αντιγράφου ασφαλείας" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν υπάρχουν καρφιτσωμένες συνομιλίες" + "value" : "Backup Error" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessuna conversazione fissata", - "state" : "translated" + "state" : "translated", + "value" : "Error de copia de seguridad" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sem conversas fixadas" + "value" : "Erreur de sauvegarde" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "No pinned conversations" + "value" : "Errore di backup" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen vastgezette gesprekken", - "state" : "translated" + "state" : "translated", + "value" : "バックアップエラー" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucune conversation épinglée", - "state" : "translated" + "state" : "translated", + "value" : "Back-upfout" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ピン留めされた会話はありません", - "state" : "translated" + "state" : "translated", + "value" : "Erro de Cópia de Segurança" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Keine angehefteten Unterhaltungen", - "state" : "translated" + "state" : "translated", + "value" : "Säkerhetskopieringsfel" } } - }, - "comment" : "A message displayed when the user has no pinned conversations." + } }, - "Your server is ready. Let's start a conversation." : { + "Balanced" : { + "comment" : "A description of a temperature value.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Ihr Server ist bereit. Beginnen wir ein Gespräch.", - "state" : "translated" + "state" : "translated", + "value" : "Ausgeglichen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Tu servidor está listo. Comencemos una conversación.", - "state" : "translated" + "state" : "translated", + "value" : "Ισορροπημένη" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ο διακομιστής σας είναι έτοιμος. Ας ξεκινήσουμε μια συνομιλία." + "value" : "Balanced" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il tuo server è pronto. Iniziamo una conversazione." + "value" : "Equilibrado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O seu servidor está pronto. Vamos começar uma conversa.", - "state" : "translated" + "state" : "translated", + "value" : "Équilibré" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Your server is ready. Let's start a conversation.", - "state" : "translated" + "state" : "translated", + "value" : "Bilanciato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je server is klaar. Laten we een gesprek beginnen.", - "state" : "translated" + "state" : "translated", + "value" : "バランス型" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Votre serveur est prêt. Commençons une conversation." + "value" : "Gebalanceerd" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバーの準備ができました。会話を始めましょう。", - "state" : "translated" + "state" : "translated", + "value" : "Equilibrada" } }, "sv" : { "stringUnit" : { - "value" : "Din server är redo. Låt oss börja en konversation.", - "state" : "translated" + "state" : "translated", + "value" : "Balanserad" } } - }, - "comment" : "A description of the onboarding screen when the server is ready." - }, - "" : { - "shouldTranslate" : false + } }, - "The response was cut short. Open the app to see what was received." : { + "Be the first to suggest something!" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Die Antwort wurde abgeschnitten. Öffnen Sie die App, um zu sehen, was empfangen wurde.", - "state" : "translated" + "state" : "translated", + "value" : "Sei der Erste, der etwas vorschlägt!" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La respuesta se cortó. Abre la app para ver lo recibido.", - "state" : "translated" + "state" : "translated", + "value" : "Να είστε ο πρώτος που θα προτείνει κάτι!" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Svaret avbröts. Öppna appen för att se vad som mottogs." + "value" : "Be the first to suggest something!" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "La risposta è stata interrotta. Apri l’app per vedere cosa è stato ricevuto." + "value" : "¡Sé el primero en sugerir algo!" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A resposta foi interrompida. Abra a app para ver o que foi recebido.", - "state" : "translated" + "state" : "translated", + "value" : "Soyez le premier à suggérer quelque chose !" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The response was cut short. Open the app to see what was received." + "value" : "Sii il primo a suggerire qualcosa!" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "La réponse a été interrompue. Ouvrez l’application pour voir ce qui a été reçu.", - "state" : "translated" + "state" : "translated", + "value" : "最初に提案しましょう!" } }, "nl" : { "stringUnit" : { - "value" : "Het antwoord is afgebroken. Open de app om te zien wat er is ontvangen.", - "state" : "translated" + "state" : "translated", + "value" : "Wees de eerste om iets voor te stellen!" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "応答が途中で切れました。受信内容を確認するにはアプリを開いてください。", - "state" : "translated" + "state" : "translated", + "value" : "Seja o primeiro a sugerir algo!" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Η απάντηση διακόπηκε. Άνοιξε την εφαρμογή για να δεις τι λήφθηκε.", - "state" : "translated" + "state" : "translated", + "value" : "Var den första att föreslå något!" } } - }, - "comment" : "Text displayed in a notification when the response to a prompt was cut short." + } }, - "Opens OpenClient and starts a new conversation." : { + "Blocked" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ανοίγει το OpenClient και ξεκινά μια νέα συνομιλία.", - "state" : "translated" + "state" : "translated", + "value" : "Blockiert" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Abre OpenClient y comienza una nueva conversación.", - "state" : "translated" + "state" : "translated", + "value" : "Αποκλεισμένο" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClientを開き、新しい会話を開始します。" + "value" : "Blocked" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apre OpenClient e avvia una nuova conversazione.", - "state" : "translated" + "state" : "translated", + "value" : "Bloqueado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Abre o OpenClient e inicia uma nova conversa." + "value" : "Bloqué" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Opens OpenClient and starts a new conversation.", - "state" : "translated" + "state" : "translated", + "value" : "Bloccato" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Ouvre OpenClient et démarre une nouvelle conversation.", - "state" : "translated" + "state" : "translated", + "value" : "ブロック済み" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Opent OpenClient en start een nieuw gesprek." + "value" : "Geblokkeerd" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Öffnet OpenClient und startet eine neue Unterhaltung.", - "state" : "translated" + "state" : "translated", + "value" : "Bloqueado" } }, "sv" : { "stringUnit" : { - "value" : "Öppnar OpenClient och startar en ny konversation.", - "state" : "translated" + "state" : "translated", + "value" : "Blockerad" } } } }, - "No conversations for this tag" : { + "Blue" : { + "comment" : "Name of the color blue.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Keine Unterhaltungen für dieses Schlagwort", - "state" : "translated" + "state" : "translated", + "value" : "Blau" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No hay conversaciones para esta etiqueta", - "state" : "translated" + "state" : "translated", + "value" : "Μπλε" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν υπάρχουν συνομιλίες για αυτή την ετικέτα" + "value" : "Blue" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nessuna conversazione per questo tag" + "value" : "Azul" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sem conversas para esta etiqueta", - "state" : "translated" + "state" : "translated", + "value" : "Bleu" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No conversations for this tag", - "state" : "translated" + "state" : "translated", + "value" : "Blu" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Aucune conversation pour cette étiquette", - "state" : "translated" + "state" : "translated", + "value" : "青" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geen gesprekken voor deze tag" + "value" : "Blauw" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "このタグの会話はありません", - "state" : "translated" + "state" : "translated", + "value" : "Azul" } }, "sv" : { "stringUnit" : { - "value" : "Inga konversationer för denna tagg", - "state" : "translated" + "state" : "translated", + "value" : "Blå" } } - }, - "comment" : "A message displayed when a tag has no conversations." + } }, - "Describe the issue in detail..." : { + "Brainstorm ideas for a project" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Beskriv problemet i detalj..." + "value" : "Ideen für ein Projekt sammeln" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Describe el problema en detalle..." + "value" : "Καταιγισμός ιδεών για ένα έργο" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "問題を詳しく説明してください..." + "value" : "Brainstorm ideas for a project" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Descrivi il problema in dettaglio...", - "state" : "translated" + "state" : "translated", + "value" : "Generar ideas para un proyecto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Descreva o problema em detalhe...", - "state" : "translated" + "state" : "translated", + "value" : "Trouver des idées pour un projet" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Describe the issue in detail...", - "state" : "translated" - } + "state" : "translated", + "value" : "Genera idee per un progetto" + } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Beschrijf het probleem in detail...", - "state" : "translated" + "state" : "translated", + "value" : "プロジェクトのアイデアをブレインストーミングする" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Décrivez le problème en détail...", - "state" : "translated" + "state" : "translated", + "value" : "Bedenk ideeën voor een project" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Περιγράψτε το πρόβλημα με λεπτομέρεια...", - "state" : "translated" + "state" : "translated", + "value" : "Gerar ideias para um projeto" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Beschreiben Sie das Problem ausführlich...", - "state" : "translated" + "state" : "translated", + "value" : "Brainstorma idéer för ett projekt" } } } }, - "You are a concise summarizer. Extract the key points from any text the user provides. Present summaries in clear bullet points. Focus on the most important information and omit redundant details." : { + "Browse Library" : { + "comment" : "A button that opens a library of pre-made system prompts.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Du bist ein prägnanter Zusammenfasser. Extrahiere die wichtigsten Punkte aus jedem vom Nutzer bereitgestellten Text. Präsentiere Zusammenfassungen in klaren Aufzählungspunkten. Konzentriere dich auf die wichtigsten Informationen und lasse redundante Details weg.", - "state" : "translated" + "state" : "translated", + "value" : "Bibliothek durchsuchen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eres un resumidor conciso. Extrae los puntos clave de cualquier texto que el usuario proporcione. Presenta resúmenes en viñetas claras. Enfócate en la información más importante y omite detalles redundantes.", - "state" : "translated" + "state" : "translated", + "value" : "Περιήγηση στη Βιβλιοθήκη" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Du är en kortfattad sammanfattare. Extrahera nyckelpunkterna från all text användaren tillhandahåller. Presentera sammanfattningar i tydliga punktlistor. Fokusera på den viktigaste informationen och utelämna överflödiga detaljer." + "value" : "Browse Library" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Sei un riassuntore conciso. Estrai i punti chiave da qualsiasi testo fornito dall’utente. Presenta i riassunti in elenchi puntati chiari. Concentrati sulle informazioni più importanti ed elimina i dettagli ridondanti." + "value" : "Explorar biblioteca" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "És um resumidor conciso. Extrai os pontos-chave de qualquer texto fornecido pelo utilizador. Apresenta os resumos em tópicos claros. Foca-te na informação mais importante e omite detalhes redundantes.", - "state" : "translated" + "state" : "translated", + "value" : "Parcourir la bibliothèque" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "- Concise summarizer \n- Extracts key points from user-provided text \n- Presents summaries in clear bullet points \n- Focuses on most important information \n- Omits redundant details" + "value" : "Sfoglia Libreria" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je bent een beknopte samenvatter. Haal de belangrijkste punten uit elke tekst die de gebruiker aanlevert. Presenteer samenvattingen in duidelijke opsommingstekens. Richt je op de belangrijkste informatie en laat overbodige details weg.", - "state" : "translated" + "state" : "translated", + "value" : "ライブラリを参照" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Vous êtes un résumé concis. Extrait les points clés de tout texte fourni par l’utilisateur. Présente les résumés sous forme de puces claires. Concentre-toi sur l’information la plus importante et omets les détails redondants.", - "state" : "translated" + "state" : "translated", + "value" : "Bibliotheek bladeren" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Είστε συνοπτικός περιληπτής. Εξάγετε τα βασικά σημεία από οποιοδήποτε κείμενο παρέχει ο χρήστης. Παρουσιάζετε τις περιλήψεις με σαφή κουκκίδες. Επικεντρωθείτε στις πιο σημαντικές πληροφορίες και παραλείψτε τις επαναλαμβανόμενες λεπτομέρειες.", - "state" : "translated" + "state" : "translated", + "value" : "Explorar Biblioteca" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "簡潔な要約者です。 \nユーザーが提供するテキストから重要なポイントを抽出します。 \n要約は明確な箇条書きで提示します。 \n最も重要な情報に焦点を当て、冗長な詳細は省きます。", - "state" : "translated" + "state" : "translated", + "value" : "Bläddra i biblioteket" } } - }, - "comment" : "Description of the summarizer assistant." + } }, - "OpenClient version %@ is available. Would you like to update now?" : { + "Built-in" : { + "comment" : "A section title for built-in templates.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "OpenClient-version %@ är tillgänglig. Vill du uppdatera nu?", - "state" : "translated" + "state" : "translated", + "value" : "Eingebaut" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "La versión %@ de OpenClient está disponible. ¿Quieres actualizar ahora?" + "value" : "Ενσωματωμένα" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient-Version %@ ist verfügbar. Möchten Sie jetzt aktualisieren?" + "value" : "Built-in" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "È disponibile la versione %@ di OpenClient. Vuoi aggiornarla ora?", - "state" : "translated" + "state" : "translated", + "value" : "Incorporado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A versão %@ do OpenClient está disponível. Pretende atualizar agora?", - "state" : "translated" + "state" : "translated", + "value" : "Intégré" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "OpenClient version %@ is available. Would you like to update now?", - "state" : "translated" + "state" : "translated", + "value" : "Integrato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "OpenClient-versie %@ is beschikbaar. Wil je nu bijwerken?", - "state" : "translated" + "state" : "translated", + "value" : "組み込み" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "La version %@ d’OpenClient est disponible. Voulez-vous effectuer la mise à jour maintenant ?" + "value" : "Ingebouwd" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenClientバージョン %@ が利用可能です。今すぐアップデートしますか?", - "state" : "translated" + "state" : "translated", + "value" : "Integrado" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Η έκδοση %@ του OpenClient είναι διαθέσιμη. Θέλετε να κάνετε ενημέρωση τώρα;", - "state" : "translated" + "state" : "translated", + "value" : "Inbyggd" } } - }, - "comment" : "A message that is displayed in a notification when an update is available. The argument is the version number of the update." + } }, - "You're welcome!" : { + "Buy Me a Coffee" : { + "comment" : "A button that opens a payment interface to support the app's development.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Gern geschehen!", - "state" : "translated" + "state" : "translated", + "value" : "Kauf mir einen Kaffee" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¡De nada!", - "state" : "translated" + "state" : "translated", + "value" : "Κάνε μου μια δωρεά καφέ" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Παρακαλώ!", - "state" : "translated" + "state" : "translated", + "value" : "Buy Me a Coffee" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Prego!" + "value" : "Invítame a un café" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "De nada!" + "value" : "Offrez-moi un café" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "You're welcome!", - "state" : "translated" + "state" : "translated", + "value" : "Offrimi un caffè" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Graag gedaan!", - "state" : "translated" + "state" : "translated", + "value" : "コーヒーをおごる" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "De rien !" + "value" : "Trakteer me op een koffie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "どういたしまして!", - "state" : "translated" + "state" : "translated", + "value" : "Oferecer um Café" } }, "sv" : { "stringUnit" : { - "value" : "Varsågod!", - "state" : "translated" + "state" : "translated", + "value" : "Bjud mig på en kaffe" } } - }, - "comment" : "A button that dismisses a thank you alert." + } }, - "Controls randomness. Higher values make output more creative." : { + "Buying me a coffee keeps development going!" : { + "comment" : "A body text for the tip jar view.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ランダム性を制御します。値が高いほど出力がより創造的になります。", - "state" : "translated" + "state" : "translated", + "value" : "Mir einen Kaffee zu spendieren hält die Entwicklung am Laufen!" } }, - "sv" : { + "el" : { "stringUnit" : { - "value" : "Styr slumpmässigheten. Högre värden gör resultatet mer kreativt.", - "state" : "translated" + "state" : "translated", + "value" : "Η αγορά ενός καφέ για μένα διατηρεί την ανάπτυξη σε εξέλιξη!" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Controla la aleatoriedad. Valores más altos hacen que la salida sea más creativa." + "value" : "Buying me a coffee keeps development going!" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Controlla la casualità. Valori più alti rendono l'output più creativo." + "value" : "¡Invitarme un café mantiene el desarrollo en marcha!" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Controla a aleatoriedade. Valores mais altos tornam a saída mais criativa.", - "state" : "translated" + "state" : "translated", + "value" : "Offrez-moi un café pour soutenir le développement !" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Controls randomness. Higher values make output more creative.", - "state" : "translated" + "state" : "translated", + "value" : "Offrirmi un caffè aiuta a sostenere lo sviluppo!" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Contrôle l'aléatoire. Des valeurs plus élevées rendent la sortie plus créative.", - "state" : "translated" + "state" : "translated", + "value" : "コーヒーを買っていただくと開発が続けられます!" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Beheert willekeurigheid. Hogere waarden maken de output creatiever." + "value" : "Een kopje koffie voor mij helpt de ontwikkeling voort te zetten!" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Steuert die Zufälligkeit. Höhere Werte machen die Ausgabe kreativer.", - "state" : "translated" + "state" : "translated", + "value" : "Oferecer-me um café mantém o desenvolvimento em andamento!" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Ελέγχει την τυχαιότητα. Μεγαλύτερες τιμές κάνουν το αποτέλεσμα πιο δημιουργικό.", - "state" : "translated" + "state" : "translated", + "value" : "Att köpa mig en kaffe håller utvecklingen igång!" } } } }, - "The conversation context window must be greater than zero." : { + "Camera" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Το παράθυρο συμφραζομένων συνομιλίας πρέπει να είναι μεγαλύτερο του μηδενός." + "value" : "Kamera" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "La ventana de contexto de la conversación debe ser mayor que cero." + "value" : "Κάμερα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Samtalskontextfönstret måste vara större än noll." + "value" : "Camera" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "La finestra del contesto della conversazione deve essere maggiore di zero.", - "state" : "translated" + "state" : "translated", + "value" : "Cámara" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A janela de contexto da conversa deve ser maior que zero.", - "state" : "translated" + "state" : "translated", + "value" : "Appareil photo" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The conversation context window must be greater than zero.", - "state" : "translated" + "state" : "translated", + "value" : "Fotocamera" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Het contextvenster van het gesprek moet groter zijn dan nul.", - "state" : "translated" + "state" : "translated", + "value" : "カメラ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "La fenêtre de contexte de la conversation doit être supérieure à zéro.", - "state" : "translated" + "state" : "translated", + "value" : "Camera" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話コンテキストウィンドウはゼロより大きくする必要があります。", - "state" : "translated" + "state" : "translated", + "value" : "Câmara" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Das Kontextfenster der Unterhaltung muss größer als null sein.", - "state" : "translated" + "state" : "translated", + "value" : "Kamera" } } } }, - "A brief description about yourself. Max 500 characters." : { + "Cancel" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Μια σύντομη περιγραφή για εσάς. Μέγιστο 500 χαρακτήρες.", - "state" : "translated" + "state" : "translated", + "value" : "Abbrechen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Una breve descripción sobre ti. Máximo 500 caracteres.", - "state" : "translated" + "state" : "translated", + "value" : "Ακύρωση" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "自分についての簡単な説明。最大500文字まで。" + "value" : "Cancel" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Una breve descrizione di te stesso. Max 500 caratteri." + "value" : "Cancelar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Uma breve descrição sobre si. Máx. 500 caracteres.", - "state" : "translated" + "state" : "translated", + "value" : "Annuler" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "A brief description about yourself. Max 500 characters." + "value" : "Annulla" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Een korte beschrijving van jezelf. Maximaal 500 tekens.", - "state" : "translated" + "state" : "translated", + "value" : "キャンセル" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Une brève description de vous-même. Max 500 caractères.", - "state" : "translated" + "state" : "translated", + "value" : "Annuleren" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Eine kurze Beschreibung von dir. Maximal 500 Zeichen.", - "state" : "translated" + "state" : "translated", + "value" : "Cancelar" } }, "sv" : { "stringUnit" : { - "value" : "En kort beskrivning om dig själv. Max 500 tecken.", - "state" : "translated" + "state" : "translated", + "value" : "Avbryt" } } - }, - "comment" : "A description of the field that allows the user to add a" + } }, - "Load Available Tools" : { + "Cancel Recording" : { + "comment" : "A button that cancels the current recording.", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme abbrechen" + } + }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Φόρτωση Διαθέσιμων Εργαλείων" + "value" : "Ακύρωση εγγραφής" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Cargar herramientas disponibles", - "state" : "translated" + "state" : "translated", + "value" : "Cancel Recording" } }, - "ja" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "利用可能なツールを読み込む" + "value" : "Cancelar grabación" } }, - "it" : { + "fr" : { "stringUnit" : { - "value" : "Carica strumenti disponibili", - "state" : "translated" + "state" : "translated", + "value" : "Annuler l’enregistrement" } }, - "pt-PT" : { + "it" : { "stringUnit" : { - "value" : "Carregar Ferramentas Disponíveis", - "state" : "translated" + "state" : "translated", + "value" : "Annulla registrazione" } }, - "en" : { + "ja" : { "stringUnit" : { - "value" : "Load Available Tools", - "state" : "translated" + "state" : "translated", + "value" : "録音をキャンセル" } }, "nl" : { "stringUnit" : { - "value" : "Beschikbare tools laden", - "state" : "translated" + "state" : "translated", + "value" : "Opname annuleren" } }, - "fr" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Charger les outils disponibles" - } - }, - "de" : { - "stringUnit" : { - "value" : "Verfügbare Werkzeuge laden", - "state" : "translated" + "value" : "Cancelar Gravação" } }, "sv" : { "stringUnit" : { - "value" : "Ladda tillgängliga verktyg", - "state" : "translated" + "state" : "translated", + "value" : "Avbryt inspelning" } } - }, - "comment" : "A button that fetches the list of search tools configured in the user's LiteLLM server." + } }, - "No MCP servers configured. Add them in your LiteLLM server's config.yaml." : { + "Capabilities" : { + "comment" : "A section that lists the capabilities of a model.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Keine MCP-Server konfiguriert. Fügen Sie sie in der config.yaml Ihres LiteLLM-Servers hinzu." + "value" : "Fähigkeiten" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν έχουν ρυθμιστεί MCP διακομιστές. Προσθέστε τους στο config.yaml του διακομιστή LiteLLM σας." + "value" : "Δυνατότητες" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "No hay servidores MCP configurados. Agréguelos en el config.yaml de su servidor LiteLLM." + "value" : "Capabilities" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessun server MCP configurato. Aggiungili nel file config.yaml del tuo server LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Capacidades" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nenhum servidor MCP configurado. Adicione-os no config.yaml do seu servidor LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Capacités" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No MCP servers configured. Add them in your LiteLLM server's config.yaml.", - "state" : "translated" + "state" : "translated", + "value" : "Capacità" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen MCP-servers geconfigureerd. Voeg ze toe in de config.yaml van je LiteLLM-server.", - "state" : "translated" + "state" : "translated", + "value" : "機能" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucun serveur MCP configuré. Ajoutez-les dans le config.yaml de votre serveur LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Mogelijkheden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "MCPサーバーが設定されていません。LiteLLMサーバーのconfig.yamlに追加してください。", - "state" : "translated" + "state" : "translated", + "value" : "Capacidades" } }, "sv" : { "stringUnit" : { - "value" : "Inga MCP-servrar konfigurerade. Lägg till dem i din LiteLLM-servers config.yaml.", - "state" : "translated" + "state" : "translated", + "value" : "Funktioner" } } - }, - "comment" : "A message that appears when there are no MCP servers configured." + } }, - "Here is a concise summary of the meeting." : { + "Chat" : { + "comment" : "A section of the settings view that deals with chat-related settings.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "会議の簡潔な要約です", - "state" : "translated" + "state" : "translated", + "value" : "Chat" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Aquí un resumen conciso de la reunión.", - "state" : "translated" + "state" : "translated", + "value" : "Συνομιλία" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εδώ είναι μια σύντομη περίληψη της συνάντησης." + "value" : "Chat" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Ecco un riassunto conciso della riunione", - "state" : "translated" + "state" : "translated", + "value" : "Chat" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Aqui está um resumo conciso da reunião." + "value" : "Discussion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Here is a concise summary of the meeting", - "state" : "translated" + "state" : "translated", + "value" : "Chat" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Hier is een beknopte samenvatting van de vergadering.", - "state" : "translated" + "state" : "translated", + "value" : "チャット" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voici un résumé concis de la réunion" + "value" : "Chatten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Hier ist eine kurze Zusammenfassung des Treffens.", - "state" : "translated" + "state" : "translated", + "value" : "Chat" } }, "sv" : { "stringUnit" : { - "value" : "Här är en kort sammanfattning av mötet.", - "state" : "translated" + "state" : "translated", + "value" : "Chatt" } } - }, - "comment" : "Last message preview text for a conversation." + } }, - "Swipe left to remove a tag." : { + "Chat Message" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Σύρετε αριστερά για να αφαιρέσετε μια ετικέτα.", - "state" : "translated" + "state" : "translated", + "value" : "Chatnachricht" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Desliza a la izquierda para eliminar una etiqueta" + "value" : "Μήνυμα συνομιλίας" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "タグを削除するには左にスワイプしてください。" + "value" : "Chat Message" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Scorri a sinistra per rimuovere un tag.", - "state" : "translated" + "state" : "translated", + "value" : "Mensaje de chat" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Deslize para a esquerda para remover uma etiqueta.", - "state" : "translated" + "state" : "translated", + "value" : "Message de chat" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Swipe left to remove a tag", - "state" : "translated" + "state" : "translated", + "value" : "Messaggio chat" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Veeg naar links om een tag te verwijderen", - "state" : "translated" + "state" : "translated", + "value" : "チャットメッセージ" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Faites glisser vers la gauche pour supprimer une étiquette." + "value" : "Chatbericht" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Nach links wischen, um ein Tag zu entfernen.", - "state" : "translated" + "state" : "translated", + "value" : "Mensagem de Chat" } }, "sv" : { "stringUnit" : { - "value" : "Svep åt vänster för att ta bort en tagg.", - "state" : "translated" + "state" : "translated", + "value" : "Chattmeddelande" } } - }, - "comment" : "A footer displayed under the list of tags." + } }, - "New comment" : { + "Chat without saving history" : { + "comment" : "Localized title for a shortcut action that opens a private chat.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ny kommentar" + "value" : "Chat ohne Verlauf speichern" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nuevo comentario", - "state" : "translated" + "state" : "translated", + "value" : "Συνομιλία χωρίς αποθήκευση ιστορικού" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Neuer Kommentar" + "value" : "Chat without saving history" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nuovo commento" + "value" : "Chat sin guardar historial" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Novo comentário", - "state" : "translated" + "state" : "translated", + "value" : "Discussion sans enregistrer l’historique" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New comment", - "state" : "translated" + "state" : "translated", + "value" : "Chat senza salvare la cronologia" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nieuwe opmerking", - "state" : "translated" + "state" : "translated", + "value" : "履歴を保存しないチャット" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Nouveau commentaire", - "state" : "translated" + "state" : "translated", + "value" : "Chatten zonder geschiedenis op te slaan" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "新しいコメント", - "state" : "translated" + "state" : "translated", + "value" : "Chat sem guardar histórico" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Νέα σχόλια", - "state" : "translated" + "state" : "translated", + "value" : "Chatt utan att spara historik" } } } }, - "Input" : { - "shouldTranslate" : false, - "comment" : "A label for the cost of input tokens." - }, - "Coding Assistant" : { + "Chats" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Coding-Assistent", - "state" : "translated" + "state" : "translated", + "value" : "Chats" } }, "el" : { "stringUnit" : { - "value" : "Βοηθός Κωδικοποίησης", - "state" : "translated" + "state" : "translated", + "value" : "Συζητήσεις" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Asistente de codificación" + "value" : "Chats" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Assistente di Codifica", - "state" : "translated" + "state" : "translated", + "value" : "Chats" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Assistente de Programação", - "state" : "translated" + "state" : "translated", + "value" : "Discussions" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Coding Assistant" + "value" : "Chat" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Programmeerassistent", - "state" : "translated" + "state" : "translated", + "value" : "チャット" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Assistant de codage" + "value" : "Chats" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "コーディングアシスタント", - "state" : "translated" + "state" : "translated", + "value" : "Conversas" } }, "sv" : { "stringUnit" : { - "value" : "Kodassistent", - "state" : "translated" + "state" : "translated", + "value" : "Chattar" } } - }, - "comment" : "Name of the prompt template for coding-related tasks." + } }, - "Privacy Policy" : { + "Choose the right model" : { + "comment" : "A title for a tip that explains how to select a model for a conversation.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Πολιτική Απορρήτου" + "value" : "Wähle das richtige Modell" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Política de privacidad", - "state" : "translated" + "state" : "translated", + "value" : "Επιλέξτε το σωστό μοντέλο" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Datenschutzerklärung" + "value" : "Choose the right model" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Informativa sulla privacy" + "value" : "Elige el modelo correcto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Política de Privacidade", - "state" : "translated" + "state" : "translated", + "value" : "Choisissez le bon modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Privacy Policy", - "state" : "translated" + "state" : "translated", + "value" : "Scegli il modello giusto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Privacybeleid", - "state" : "translated" + "state" : "translated", + "value" : "適切なモデルを選択する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Politique de confidentialité", - "state" : "translated" + "state" : "translated", + "value" : "Kies het juiste model" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "プライバシーポリシー", - "state" : "translated" + "state" : "translated", + "value" : "Escolha o modelo correto" } }, "sv" : { "stringUnit" : { - "value" : "Integritetspolicy", - "state" : "translated" + "state" : "translated", + "value" : "Välj rätt modell" } } } }, - "The MCP tool arguments are not valid JSON." : { + "Choose the tag shown by the conversations widget." : { + "comment" : "Title of the widget configuration intent.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Die Argumente des MCP-Tools sind kein gültiges JSON.", - "state" : "translated" + "state" : "translated", + "value" : "Wähle das vom Konversations-Widget angezeigte Tag." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Los argumentos de la herramienta MCP no son un JSON válido.", - "state" : "translated" + "state" : "translated", + "value" : "Επιλέξτε την ετικέτα που εμφανίζεται στο widget συνομιλιών" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Argumenten för MCP-verktyget är inte giltig JSON." + "value" : "Choose the tag displayed by the conversations widget" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Gli argomenti dello strumento MCP non sono un JSON valido.", - "state" : "translated" + "state" : "translated", + "value" : "Elige la etiqueta que muestra el widget de conversaciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Os argumentos da ferramenta MCP não são JSON válido." + "value" : "Choisissez l’étiquette affichée par le widget de conversations" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The MCP tool arguments are not valid JSON." + "value" : "Scegli il tag mostrato dal widget delle conversazioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De argumenten van de MCP-tool zijn geen geldige JSON.", - "state" : "translated" + "state" : "translated", + "value" : "会話ウィジェットで表示するタグを選択してください" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Les arguments de l’outil MCP ne sont pas un JSON valide.", - "state" : "translated" + "state" : "translated", + "value" : "Kies de tag die door de gesprekken-widget wordt weergegeven" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "MCPツールの引数が有効なJSONではありません。", - "state" : "translated" + "state" : "translated", + "value" : "Escolha a etiqueta mostrada pelo widget de conversas" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Τα επιχειρήματα του εργαλείου MCP δεν είναι έγκυρο JSON.", - "state" : "translated" + "state" : "translated", + "value" : "Välj taggen som visas i konversationswidgeten" } } - }, - "comment" : "Error message when the MCP tool arguments are not valid JSON." + } }, - "Personalization" : { + "Close" : { + "comment" : "A button that dismisses the current view.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εξατομίκευση", - "state" : "translated" + "state" : "translated", + "value" : "Schließen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Personalización", - "state" : "translated" - } + "state" : "translated", + "value" : "Κλείσιμο" + } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Personalisering" + "value" : "Close" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Personalizzazione" + "value" : "Cerrar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Personalização", - "state" : "translated" + "state" : "translated", + "value" : "Fermer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Personalization", - "state" : "translated" + "state" : "translated", + "value" : "Chiudi" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Personnalisation", - "state" : "translated" + "state" : "translated", + "value" : "閉じる" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Personalisatie" + "value" : "Sluiten" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "パーソナライズ", - "state" : "translated" + "state" : "translated", + "value" : "Fechar" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Personalisierung", - "state" : "translated" + "state" : "translated", + "value" : "Stäng" } } - }, - "comment" : "A heading for the personalization settings." + } }, - "Share" : { + "Cloud" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "共有" + "value" : "Cloud" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Compartir", - "state" : "translated" + "state" : "translated", + "value" : "Νέφος" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Teilen" + "value" : "Cloud" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Condividi", - "state" : "translated" + "state" : "translated", + "value" : "Cloud" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Partilhar" + "value" : "Cloud" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Share", - "state" : "translated" + "state" : "translated", + "value" : "Cloud" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Delen", - "state" : "translated" + "state" : "translated", + "value" : "クラウド" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Partager", - "state" : "translated" + "state" : "translated", + "value" : "Cloud" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Κοινή χρήση", - "state" : "translated" + "state" : "translated", + "value" : "Nuvem" } }, "sv" : { "stringUnit" : { - "value" : "Dela", - "state" : "translated" + "state" : "translated", + "value" : "Moln" } } } }, - "This Week" : { + "Code" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αυτή την εβδομάδα", - "state" : "translated" + "state" : "translated", + "value" : "Code" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Esta semana", - "state" : "translated" + "state" : "translated", + "value" : "Κωδικός" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Diese Woche" + "value" : "Code" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Questa settimana", - "state" : "translated" + "state" : "translated", + "value" : "Código" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Esta Semana" + "value" : "Code" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "This Week" + "value" : "Codice" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Deze week", - "state" : "translated" + "state" : "translated", + "value" : "コード" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Cette semaine", - "state" : "translated" + "state" : "translated", + "value" : "Code" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "今週", - "state" : "translated" + "state" : "translated", + "value" : "Código" } }, "sv" : { "stringUnit" : { - "value" : "Den här veckan", - "state" : "translated" + "state" : "translated", + "value" : "Kod" } } - }, - "comment" : "Title of a conversation section for conversations from the current week." + } }, - "OpenClient may summarise or exclude older messages without removing them from your history." : { + "Coding Assistant" : { + "comment" : "Name of the prompt template for coding-related tasks.", "localizations" : { "de" : { "stringUnit" : { - "value" : "OpenClient kann ältere Nachrichten zusammenfassen oder ausblenden, ohne sie aus Ihrem Verlauf zu entfernen.", - "state" : "translated" + "state" : "translated", + "value" : "Coding-Assistent" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "OpenClient puede resumir o excluir mensajes antiguos sin eliminarlos de tu historial.", - "state" : "translated" + "state" : "translated", + "value" : "Βοηθός Κωδικοποίησης" } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "OpenClient kan sammanfatta eller utesluta äldre meddelanden utan att ta bort dem från din historik.", - "state" : "translated" + "state" : "translated", + "value" : "Coding Assistant" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "OpenClient può riassumere o escludere i messaggi più vecchi senza rimuoverli dalla tua cronologia.", - "state" : "translated" + "state" : "translated", + "value" : "Asistente de codificación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O OpenClient pode resumir ou excluir mensagens antigas sem as remover do seu histórico." + "value" : "Assistant de codage" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient may summarize or exclude older messages without removing them from your history." + "value" : "Assistente di Codifica" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "OpenClient peut résumer ou exclure les anciens messages sans les supprimer de votre historique.", - "state" : "translated" + "state" : "translated", + "value" : "コーディングアシスタント" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient kan oudere berichten samenvatten of uitsluiten zonder ze uit je geschiedenis te verwijderen." + "value" : "Programmeerassistent" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenClientは古いメッセージを履歴から削除せずに要約または除外することがあります。", - "state" : "translated" + "state" : "translated", + "value" : "Assistente de Programação" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Το OpenClient μπορεί να συνοψίζει ή να εξαιρεί παλαιότερα μηνύματα χωρίς να τα αφαιρεί από το ιστορικό σας.", - "state" : "translated" + "state" : "translated", + "value" : "Kodassistent" } } - }, - "comment" : "A description of how OpenClient can remove older messages from the user's history." + } }, - "This chat is not saved or added to memory." : { + "Color" : { + "comment" : "A label for the color of a tag.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "このチャットは保存されず、記憶にも追加されません。" + "value" : "Farbe" } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Dieser Chat wird nicht gespeichert oder im Speicher abgelegt." + "value" : "Χρώμα" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Este chat no se guarda ni se añade a la memoria." + "value" : "Color" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Questa chat non viene salvata né aggiunta alla memoria.", - "state" : "translated" + "state" : "translated", + "value" : "Color" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Esta conversa não é guardada nem adicionada à memória.", - "state" : "translated" + "state" : "translated", + "value" : "Couleur" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "This chat is not saved or stored in memory.", - "state" : "translated" + "state" : "translated", + "value" : "Colore" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Cette conversation n’est pas enregistrée ni ajoutée à la mémoire.", - "state" : "translated" + "state" : "translated", + "value" : "色" } }, "nl" : { "stringUnit" : { - "value" : "Deze chat wordt niet opgeslagen of toegevoegd aan het geheugen.", - "state" : "translated" + "state" : "translated", + "value" : "Kleur" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αυτή η συνομιλία δεν αποθηκεύεται ούτε προστίθεται στη μνήμη.", - "state" : "translated" + "state" : "translated", + "value" : "Cor" } }, "sv" : { "stringUnit" : { - "value" : "Den här chatten sparas inte eller läggs till i minnet.", - "state" : "translated" + "state" : "translated", + "value" : "Färg" } } - }, - "comment" : "A description of a private chat." + } }, - "Enter a brief title for your suggestion" : { + "comments" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ange en kort titel för ditt förslag" + "value" : "Kommentare" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Introduce un título breve para tu sugerencia", - "state" : "translated" + "state" : "translated", + "value" : "σχόλια" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εισαγάγετε έναν σύντομο τίτλο για την πρότασή σας" + "value" : "comments" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inserisci un titolo breve per il tuo suggerimento", - "state" : "translated" + "state" : "translated", + "value" : "comentarios" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Introduza um título breve para a sua sugestão", - "state" : "translated" + "state" : "translated", + "value" : "commentaires" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Enter a brief title for your suggestion", - "state" : "translated" + "state" : "translated", + "value" : "commenti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voer een korte titel voor uw suggestie in", - "state" : "translated" + "state" : "translated", + "value" : "コメント" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Entrez un titre bref pour votre suggestion" + "value" : "reacties" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "提案の簡単なタイトルを入力してください", - "state" : "translated" + "state" : "translated", + "value" : "comentários" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Geben Sie einen kurzen Titel für Ihren Vorschlag ein", - "state" : "translated" + "state" : "translated", + "value" : "kommentarer" } } } }, - "Share text, links, images, or PDFs from any app into OpenClient." : { + "Comments" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Teile Text, Links, Bilder oder PDFs aus jeder App mit OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Kommentare" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "任意のアプリからテキスト、リンク、画像、PDFをOpenClientに共有する" + "value" : "Σχόλια" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Comparte texto, enlaces, imágenes o PDFs desde cualquier aplicación en OpenClient." + "value" : "Comments" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Condividi testo, link, immagini o PDF da qualsiasi app in OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Comentarios" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Partilhe texto, links, imagens ou PDFs de qualquer aplicação para o OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Commentaires" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Share text, links, images, or PDFs from any app to OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Commenti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Deel tekst, links, afbeeldingen of PDF's vanuit elke app met OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "コメント" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Partagez du texte, des liens, des images ou des PDF depuis n’importe quelle application vers OpenClient." + "value" : "Reacties" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Μοιραστείτε κείμενο, συνδέσμους, εικόνες ή αρχεία PDF από οποιαδήποτε εφαρμογή στο OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Comentários" } }, "sv" : { "stringUnit" : { - "value" : "Dela text, länkar, bilder eller PDF-filer från vilken app som helst till OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Kommentarer" } } - }, - "comment" : "A description of how to use the share extension." + } }, - "Version %@ (%@)" : { + "Completed" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Έκδοση %1$@ (%2$@)", - "state" : "translated" + "state" : "translated", + "value" : "Abgeschlossen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Versión %1$@ (%2$@)", - "state" : "translated" + "state" : "translated", + "value" : "Ολοκληρώθηκε" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Version %1$@ (%2$@)" + "value" : "Completed" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Versione %1$@ (%2$@)", - "state" : "translated" + "state" : "translated", + "value" : "Completado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Versão %1$@ (%2$@)" + "value" : "Terminé" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Version %1$@ (%2$@)", - "state" : "new" + "state" : "translated", + "value" : "Completato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Versie %1$@ (%2$@)", - "state" : "translated" + "state" : "translated", + "value" : "完了" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Version %1$@ (%2$@)" + "value" : "Voltooid" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "バージョン %1$@(%2$@)", - "state" : "translated" + "state" : "translated", + "value" : "Concluído" } }, "sv" : { "stringUnit" : { - "value" : "Version %1$@ (%2$@)", - "state" : "translated" + "state" : "translated", + "value" : "Slutförd" } } - }, - "comment" : "A label displaying the current version of the app and its build number. The first argument is the string “CFBundleShortVersionString” or the string “—”. The second argument is the string “CFBundleVersion” or the string “—”." + } }, - "Voice" : { + "Completion" : { + "comment" : "A description of a completion LLM model.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Φωνή", - "state" : "translated" + "state" : "translated", + "value" : "Abschluss" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Voz", - "state" : "translated" + "state" : "translated", + "value" : "Ολοκλήρωση" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Stimme" + "value" : "Completion" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Voce", - "state" : "translated" + "state" : "translated", + "value" : "Finalización" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Vozes" + "value" : "Achèvement" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Voice", - "state" : "translated" + "state" : "translated", + "value" : "Completamento" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Voix", - "state" : "translated" + "state" : "translated", + "value" : "完了" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Stem" + "value" : "Voltooiing" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "音声", - "state" : "translated" + "state" : "translated", + "value" : "Conclusão" } }, "sv" : { "stringUnit" : { - "value" : "Röst", - "state" : "translated" + "state" : "translated", + "value" : "Slutförande" } } - }, - "comment" : "A label displayed above a list of available voices." + } }, - "How can I help you?" : { + "Configure your personal context and memory items to personalise model responses." : { + "comment" : "A description of the personalization section.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Wie kann ich Ihnen helfen?", - "state" : "translated" + "state" : "translated", + "value" : "Konfigurieren Sie Ihre persönlichen Kontext- und Speicherobjekte, um die Modellantworten zu personalisieren." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "¿Cómo puedo ayudarte?" + "value" : "Διαμορφώστε το προσωπικό σας πλαίσιο και τα στοιχεία μνήμης για να εξατομικεύσετε τις απαντήσεις του μοντέλου." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "どうされましたか?" + "value" : "Configure your personal context and memory items to personalize model responses." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Come posso aiutarti?", - "state" : "translated" + "state" : "translated", + "value" : "Configura tu contexto personal y elementos de memoria para personalizar las respuestas del modelo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Como posso ajudar?", - "state" : "translated" + "state" : "translated", + "value" : "Configurez votre contexte personnel et vos éléments de mémoire pour personnaliser les réponses du modèle." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "How can I help you?" + "value" : "Configura il tuo contesto personale e gli elementi di memoria per personalizzare le risposte del modello." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Hoe kan ik u helpen?", - "state" : "translated" + "state" : "translated", + "value" : "モデルの応答をパーソナライズするために、個人のコンテキストとメモリ項目を設定してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Comment puis-je vous aider ?", - "state" : "translated" + "state" : "translated", + "value" : "Configureer je persoonlijke context- en geheugenitems om modelantwoorden te personaliseren." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πώς μπορώ να σας βοηθήσω;", - "state" : "translated" + "state" : "translated", + "value" : "Configure o seu contexto pessoal e itens de memória para personalizar as respostas do modelo." } }, "sv" : { "stringUnit" : { - "value" : "Hur kan jag hjälpa dig?", - "state" : "translated" + "state" : "translated", + "value" : "Konfigurera din personliga kontext och minnesobjekt för att anpassa modellens svar." } } } }, - "In Progress" : { + "Connect external tools" : { + "comment" : "A tip that explains how to connect external tools to the model.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "In Bearbeitung" + "value" : "Externe Werkzeuge verbinden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "En progreso", - "state" : "translated" + "state" : "translated", + "value" : "Σύνδεση εξωτερικών εργαλείων" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Σε εξέλιξη" + "value" : "Connect external tools" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "In corso", - "state" : "translated" + "state" : "translated", + "value" : "Conectar herramientas externas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Em progresso", - "state" : "translated" + "state" : "translated", + "value" : "Connecter des outils externes" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "In Progress", - "state" : "translated" + "state" : "translated", + "value" : "Collega strumenti esterni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bezig", - "state" : "translated" + "state" : "translated", + "value" : "外部ツールを接続する" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "En cours" + "value" : "Externe tools verbinden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "進行中", - "state" : "translated" + "state" : "translated", + "value" : "Ligar ferramentas externas" } }, "sv" : { "stringUnit" : { - "value" : "Pågår", - "state" : "translated" + "state" : "translated", + "value" : "Anslut externa verktyg" } } } }, - "Tap to return to your conversation." : { + "Connect Your Server" : { + "comment" : "A heading for the server configuration step of the onboarding flow.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Tippen, um zu Ihrer Unterhaltung zurückzukehren.", - "state" : "translated" + "state" : "translated", + "value" : "Verbinden Sie Ihren Server" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Toca para volver a tu conversación.", - "state" : "translated" + "state" : "translated", + "value" : "Συνδέστε τον διακομιστή σας" } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "会話に戻るにはタップしてください", - "state" : "translated" + "state" : "translated", + "value" : "Connect Your Server" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tocca per tornare alla tua conversazione.", - "state" : "translated" + "state" : "translated", + "value" : "Conecta tu servidor" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Toque para voltar à sua conversa." + "value" : "Connectez votre serveur" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Tap to return to your conversation" + "value" : "Connetti il tuo server" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Tik om terug te keren naar je gesprek.", - "state" : "translated" + "state" : "translated", + "value" : "サーバーを接続する" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Touchez pour revenir à votre conversation." + "value" : "Verbind uw server" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πατήστε για να επιστρέψετε στη συνομιλία σας.", - "state" : "translated" + "state" : "translated", + "value" : "Ligue o Seu Servidor" } }, "sv" : { "stringUnit" : { - "value" : "Tryck för att återvända till din konversation.", - "state" : "translated" + "state" : "translated", + "value" : "Anslut din server" } } - }, - "comment" : "Text displayed in a conversation card when there is no conversation to show." + } }, - "Dismiss banner" : { + "Connection successful" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Banner schließen", - "state" : "translated" + "state" : "translated", + "value" : "Verbindung erfolgreich" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Descartar banner", - "state" : "translated" + "state" : "translated", + "value" : "Σύνδεση επιτυχής" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Απόρριψη banner" + "value" : "Connection successful" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Chiudi il banner", - "state" : "translated" + "state" : "translated", + "value" : "Conexión exitosa" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Fechar faixa de aviso", - "state" : "translated" + "state" : "translated", + "value" : "Connexion réussie" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Dismiss banner", - "state" : "translated" + "state" : "translated", + "value" : "Connessione riuscita" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Banner sluiten" + "value" : "接続に成功しました" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fermer la bannière" + "value" : "Verbinding geslaagd" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "バナーを閉じる", - "state" : "translated" + "state" : "translated", + "value" : "Ligação bem-sucedida" } }, "sv" : { "stringUnit" : { - "value" : "Stäng bannern", - "state" : "translated" + "state" : "translated", + "value" : "Anslutning lyckades" } } - }, - "comment" : "A label for dismissing a banner." + } }, - "Buying me a coffee keeps development going!" : { + "Connection successful — ready to continue" : { + "comment" : "A message displayed when the connection to the server is successful.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η αγορά ενός καφέ για μένα διατηρεί την ανάπτυξη σε εξέλιξη!", - "state" : "translated" + "state" : "translated", + "value" : "Verbindung erfolgreich — bereit zum Fortfahren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¡Invitarme un café mantiene el desarrollo en marcha!", - "state" : "translated" + "state" : "translated", + "value" : "Η σύνδεση ήταν επιτυχής — έτοιμοι για συνέχεια" } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Mir einen Kaffee zu spendieren hält die Entwicklung am Laufen!", - "state" : "translated" + "state" : "translated", + "value" : "Connection successful — ready to continue" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Offrirmi un caffè aiuta a sostenere lo sviluppo!", - "state" : "translated" + "state" : "translated", + "value" : "Conexión exitosa — listo para continuar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Oferecer-me um café mantém o desenvolvimento em andamento!", - "state" : "translated" + "state" : "translated", + "value" : "Connexion réussie — prêt à continuer" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Buying me a coffee keeps development going!" + "value" : "Connessione riuscita — pronto per continuare" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Offrez-moi un café pour soutenir le développement !" + "value" : "接続に成功しました — 続行の準備ができました" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Een kopje koffie voor mij helpt de ontwikkeling voort te zetten!" + "value" : "Verbinding geslaagd — klaar om door te gaan" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "コーヒーを買っていただくと開発が続けられます!", - "state" : "translated" + "state" : "translated", + "value" : "Ligação bem-sucedida — pronto para continuar" } }, "sv" : { "stringUnit" : { - "value" : "Att köpa mig en kaffe håller utvecklingen igång!", - "state" : "translated" + "state" : "translated", + "value" : "Anslutning lyckades — redo att fortsätta" } } - }, - "comment" : "A body text for the tip jar view." + } }, - "Green" : { + "Context Window" : { + "comment" : "A section that displays the maximum number of tokens that can be processed in a single request.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Grün", - "state" : "translated" + "state" : "translated", + "value" : "Kontextfenster" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Verde" + "value" : "Παράθυρο Συμφραζομένων" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Grön" + "value" : "Context Window" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Verde", - "state" : "translated" + "state" : "translated", + "value" : "Ventana de contexto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Verde", - "state" : "translated" + "state" : "translated", + "value" : "Fenêtre de contexte" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Green", - "state" : "translated" + "state" : "translated", + "value" : "Finestra di contesto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Groen", - "state" : "translated" + "state" : "translated", + "value" : "コンテキストウィンドウ" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vert" + "value" : "Contextvenster" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "緑", - "state" : "translated" + "state" : "translated", + "value" : "Janela de Contexto" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Πράσινο", - "state" : "translated" + "state" : "translated", + "value" : "Kontextfönster" } } - }, - "comment" : "Name of the color green." + } }, - "Connect Your Server" : { + "Continue" : { + "comment" : "A button that allows the user to continue the onboarding process.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "サーバーを接続する", - "state" : "translated" + "state" : "translated", + "value" : "Weiter" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Conecta tu servidor", - "state" : "translated" + "state" : "translated", + "value" : "Συνέχεια" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Συνδέστε τον διακομιστή σας" + "value" : "Continue" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Connetti il tuo server" + "value" : "Continuar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ligue o Seu Servidor", - "state" : "translated" + "state" : "translated", + "value" : "Continuer" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Connect Your Server" + "value" : "Continua" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Verbind uw server", - "state" : "translated" + "state" : "translated", + "value" : "続行" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Connectez votre serveur", - "state" : "translated" + "state" : "translated", + "value" : "Doorgaan" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Verbinden Sie Ihren Server", - "state" : "translated" + "state" : "translated", + "value" : "Continuar" } }, "sv" : { "stringUnit" : { - "value" : "Anslut din server", - "state" : "translated" + "state" : "translated", + "value" : "Fortsätt" } } - }, - "comment" : "A heading for the server configuration step of the onboarding flow." + } }, - "Documents" : { + "Continue Chat" : { + "comment" : "Widget title.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Έγγραφα" + "value" : "Chat fortsetzen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Documentos", - "state" : "translated" + "state" : "translated", + "value" : "Συνέχεια συνομιλίας" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ドキュメント" + "value" : "Continue Chat" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Documenti" + "value" : "Continuar chat" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Documentos", - "state" : "translated" + "state" : "translated", + "value" : "Continuer la discussion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Documents", - "state" : "translated" + "state" : "translated", + "value" : "Continua chat" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Documenten", - "state" : "translated" + "state" : "translated", + "value" : "チャットを続ける" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Documents", - "state" : "translated" + "state" : "translated", + "value" : "Chat voortzetten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Dokumente", - "state" : "translated" + "state" : "translated", + "value" : "Continuar Conversa" } }, "sv" : { "stringUnit" : { - "value" : "Dokument", - "state" : "translated" + "state" : "translated", + "value" : "Fortsätt chatt" } } - }, - "comment" : "A section header for a list of documents." + } }, - "How the assistant will address you. Max 50 characters." : { + "Continue your latest chat" : { + "comment" : "Title of a placeholder conversation.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Wie der Assistent Sie ansprechen wird. Maximal 50 Zeichen", - "state" : "translated" + "state" : "translated", + "value" : "Führe deinen letzten Chat fort" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Cómo se dirigirá a ti el asistente. Máx 50 caracteres" + "value" : "Συνέχισε την τελευταία σου συνομιλία" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hur assistenten kommer att tilltala dig. Max 50 tecken." + "value" : "Continue your latest chat" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Come l’assistente si rivolgerà a te. Max 50 caratteri", - "state" : "translated" + "state" : "translated", + "value" : "Continúa tu último chat" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Como o assistente se dirigirá a si. Máx. 50 caracteres.", - "state" : "translated" + "state" : "translated", + "value" : "Poursuivre votre dernière conversation" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "How the assistant will address you. Max 50 characters" + "value" : "Continua la tua ultima chat" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Hoe de assistent u zal aanspreken. Maximaal 50 tekens", - "state" : "translated" + "state" : "translated", + "value" : "最新のチャットを続ける" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Comment l’assistant s’adressera à vous. 50 caractères max.", - "state" : "translated" + "state" : "translated", + "value" : "Ga door met je laatste gesprek" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πώς θα σας απευθύνεται ο βοηθός. Μέγιστο 50 χαρακτήρες.", - "state" : "translated" + "state" : "translated", + "value" : "Continue a sua última conversa" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "アシスタントがあなたを呼ぶ名前。最大50文字。", - "state" : "translated" + "state" : "translated", + "value" : "Fortsätt din senaste chatt" } } - }, - "comment" : "A description of how the assistant will address the user." + } }, - "Loading more..." : { + "Control what the model remembers" : { + "comment" : "A tip that explains how to control the user's memory.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Φόρτωση περισσότερων...", - "state" : "translated" + "state" : "translated", + "value" : "Steuern, was das Modell sich merkt" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cargando más...", - "state" : "translated" + "state" : "translated", + "value" : "Έλεγχος του τι θυμάται το μοντέλο" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "さらに読み込み中..." + "value" : "Control what the model remembers" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Caricamento in corso..." + "value" : "Controla lo que el modelo recuerda" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A carregar mais...", - "state" : "translated" + "state" : "translated", + "value" : "Contrôlez ce que le modèle retient" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Loading more...", - "state" : "translated" + "state" : "translated", + "value" : "Controlla ciò che il modello ricorda" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Chargement de plus...", - "state" : "translated" + "state" : "translated", + "value" : "モデルの記憶を制御する" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Meer laden..." + "value" : "Beheer wat het model onthoudt" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Mehr laden...", - "state" : "translated" + "state" : "translated", + "value" : "Controle o que o modelo recorda" } }, "sv" : { "stringUnit" : { - "value" : "Laddar mer...", - "state" : "translated" + "state" : "translated", + "value" : "Styr vad modellen kommer ihåg" } } } }, - "Control what the model remembers" : { + "Controls randomness. Higher values make output more creative." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "モデルの記憶を制御する" + "value" : "Steuert die Zufälligkeit. Höhere Werte machen die Ausgabe kreativer." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Controla lo que el modelo recuerda", - "state" : "translated" + "state" : "translated", + "value" : "Ελέγχει την τυχαιότητα. Μεγαλύτερες τιμές κάνουν το αποτέλεσμα πιο δημιουργικό." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Styr vad modellen kommer ihåg" + "value" : "Controls randomness. Higher values make output more creative." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Controlla ciò che il modello ricorda", - "state" : "translated" + "state" : "translated", + "value" : "Controla la aleatoriedad. Valores más altos hacen que la salida sea más creativa." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Controle o que o modelo recorda", - "state" : "translated" + "state" : "translated", + "value" : "Contrôle l'aléatoire. Des valeurs plus élevées rendent la sortie plus créative." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Control what the model remembers", - "state" : "translated" + "state" : "translated", + "value" : "Controlla la casualità. Valori più alti rendono l'output più creativo." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Beheer wat het model onthoudt", - "state" : "translated" + "state" : "translated", + "value" : "ランダム性を制御します。値が高いほど出力がより創造的になります。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Contrôlez ce que le modèle retient" + "value" : "Beheert willekeurigheid. Hogere waarden maken de output creatiever." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Steuern, was das Modell sich merkt", - "state" : "translated" + "state" : "translated", + "value" : "Controla a aleatoriedade. Valores mais altos tornam a saída mais criativa." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Έλεγχος του τι θυμάται το μοντέλο", - "state" : "translated" + "state" : "translated", + "value" : "Styr slumpmässigheten. Högre värden gör resultatet mer kreativt." } } - }, - "comment" : "A tip that explains how to control the user's memory." + } }, - "Continue Chat" : { + "Conversation name" : { + "comment" : "A label for the name of a conversation.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Fortsätt chatt", - "state" : "translated" + "state" : "translated", + "value" : "Konversationsname" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Continuar chat", - "state" : "translated" + "state" : "translated", + "value" : "Όνομα συνομιλίας" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "チャットを続ける" + "value" : "Conversation name" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Continua chat", - "state" : "translated" + "state" : "translated", + "value" : "Nombre de la conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Continuar Conversa" + "value" : "Nom de la conversation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Continue Chat", - "state" : "translated" + "state" : "translated", + "value" : "Nome conversazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Continuer la discussion", - "state" : "translated" + "state" : "translated", + "value" : "会話名" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Chat voortzetten" + "value" : "Gespreksnaam" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Συνέχεια συνομιλίας", - "state" : "translated" + "state" : "translated", + "value" : "Nome da conversa" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Chat fortsetzen", - "state" : "translated" + "state" : "translated", + "value" : "Konversationsnamn" } } - }, - "comment" : "Widget title." + } }, - "Sends an image or PDF to a new OpenClient conversation." : { + "Conversations and attachments" : { + "comment" : "A list of the names of the categories that have been synchronized.", + "isCommentAutoGenerated" : true + }, + "Conversations are synchronized across your devices via iCloud." : { + "comment" : "A description of how conversations are synchronized across devices.", + "extractionState" : "stale", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Στέλνει μια εικόνα ή PDF σε μια νέα συνομιλία OpenClient." + "value" : "Konversationen werden über iCloud auf all Ihren Geräten synchronisiert." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Envía una imagen o PDF a una nueva conversación de OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Οι συνομιλίες συγχρονίζονται σε όλες τις συσκευές σας μέσω iCloud." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "画像またはPDFを新しいOpenClientの会話に送信します。" + "value" : "Conversations are synchronized across your devices via iCloud." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Invia un'immagine o un PDF a una nuova conversazione OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Las conversaciones se sincronizan entre tus dispositivos mediante iCloud." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Envia uma imagem ou PDF para uma nova conversa OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Les conversations sont synchronisées entre vos appareils via iCloud." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Sends an image or PDF to a new OpenClient conversation", - "state" : "translated" + "state" : "translated", + "value" : "Le conversazioni sono sincronizzate tra i tuoi dispositivi tramite iCloud." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Verzendt een afbeelding of PDF naar een nieuw OpenClient-gesprek.", - "state" : "translated" + "state" : "translated", + "value" : "会話はiCloudを通じてデバイス間で同期されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Envoie une image ou un PDF dans une nouvelle conversation OpenClient." + "value" : "Gesprekken worden via iCloud gesynchroniseerd op al je apparaten." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Sendet ein Bild oder PDF an eine neue OpenClient-Konversation.", - "state" : "translated" + "state" : "translated", + "value" : "As conversas são sincronizadas entre os seus dispositivos através do iCloud." } }, "sv" : { "stringUnit" : { - "value" : "Skickar en bild eller PDF till en ny OpenClient-konversation.", - "state" : "translated" + "state" : "translated", + "value" : "Samtal synkroniseras mellan dina enheter via iCloud." } } - }, - "comment" : "Description of the intent that sends an image or PDF to a new OpenClient conversation." + } }, - "Unable to Load Models" : { + "Conversations, personal context, memory, and prompt templates are synchronized via iCloud." : { + + }, + "Copied" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αδυναμία φόρτωσης μοντέλων", - "state" : "translated" + "state" : "translated", + "value" : "Kopiert" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Kan inte ladda modeller" + "value" : "Αντιγράφηκε" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "No se pueden cargar los modelos" + "value" : "Copied" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile caricare i modelli", - "state" : "translated" + "state" : "translated", + "value" : "Copiado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Incapaz de carregar modelos", - "state" : "translated" + "state" : "translated", + "value" : "Copié" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Unable to Load Models", - "state" : "translated" + "state" : "translated", + "value" : "Copiato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Kan modellen niet laden", - "state" : "translated" + "state" : "translated", + "value" : "コピー済み" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Impossible de charger les modèles" + "value" : "Gekopieerd" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "モデルを読み込めません", - "state" : "translated" + "state" : "translated", + "value" : "Copiado" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Modelle können nicht geladen werden", - "state" : "translated" + "state" : "translated", + "value" : "Kopierad" } } } }, - "Suggested by" : { + "Copy" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Vorgeschlagen von" + "value" : "Kopieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sugerido por", - "state" : "translated" + "state" : "translated", + "value" : "Αντιγραφή" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "からの提案" + "value" : "Copy" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Suggerito da" + "value" : "Copiar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sugerido por", - "state" : "translated" + "state" : "translated", + "value" : "Copier" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Suggested by", - "state" : "translated" + "state" : "translated", + "value" : "Copia" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voorgesteld door", - "state" : "translated" + "state" : "translated", + "value" : "コピー" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Suggéré par", - "state" : "translated" + "state" : "translated", + "value" : "Kopiëren" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προτεινόμενο από", - "state" : "translated" + "state" : "translated", + "value" : "Copiar" } }, "sv" : { "stringUnit" : { - "value" : "Föreslagen av", - "state" : "translated" + "state" : "translated", + "value" : "Kopiera" } } } }, - "A network error occurred. Please try again." : { + "Copy Image" : { + "comment" : "A label for copying an image to the clipboard.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.", - "state" : "translated" + "state" : "translated", + "value" : "Bild kopieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Ocurrió un error de red. Por favor, inténtalo de nuevo.", - "state" : "translated" + "state" : "translated", + "value" : "Αντιγραφή εικόνας" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ネットワークエラーが発生しました。もう一度お試しください。" + "value" : "Copy Image" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Si è verificato un errore di rete. Riprova." + "value" : "Copiar imagen" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ocorreu um erro de rede. Por favor, tente novamente.", - "state" : "translated" + "state" : "translated", + "value" : "Copier l’image" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "A network error occurred. Please try again.", - "state" : "translated" + "state" : "translated", + "value" : "Copia immagine" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Une erreur réseau est survenue. Veuillez réessayer.", - "state" : "translated" + "state" : "translated", + "value" : "画像をコピー" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Er is een netwerkfout opgetreden. Probeer het opnieuw." + "value" : "Afbeelding kopiëren" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Παρουσιάστηκε σφάλμα δικτύου. Παρακαλώ δοκιμάστε ξανά.", - "state" : "translated" + "state" : "translated", + "value" : "Copiar imagem" } }, "sv" : { "stringUnit" : { - "value" : "Ett nätverksfel uppstod. Försök igen.", - "state" : "translated" + "state" : "translated", + "value" : "Kopiera bild" } } } }, - "MCP Servers Unavailable" : { + "Copy URL" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Οι διακομιστές MCP δεν είναι διαθέσιμοι", - "state" : "translated" + "state" : "translated", + "value" : "URL kopieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Servidores MCP no disponibles", - "state" : "translated" + "state" : "translated", + "value" : "Αντιγραφή URL" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "MCP-servrar otillgängliga" + "value" : "Copy URL" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Server MCP non disponibili" + "value" : "Copiar URL" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Servidores MCP Indisponíveis", - "state" : "translated" + "state" : "translated", + "value" : "Copier l’URL" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "MCP Servers Unavailable", - "state" : "translated" + "state" : "translated", + "value" : "Copia URL" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "MCP-servers niet beschikbaar", - "state" : "translated" + "state" : "translated", + "value" : "URLをコピー" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Serveurs MCP indisponibles" + "value" : "URL kopiëren" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "MCPサーバー利用不可", - "state" : "translated" + "state" : "translated", + "value" : "Copiar URL" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "MCP-Server nicht verfügbar", - "state" : "translated" + "state" : "translated", + "value" : "Kopiera URL" } } - }, - "comment" : "A label that describes the unavailable state of the MCP servers." + } }, - "Opens OpenClient with the conversation search field active." : { + "Could not connect to the server." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClientを会話検索フィールドがアクティブな状態で開く。" + "value" : "Verbindung zum Server konnte nicht hergestellt werden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Abre OpenClient con el campo de búsqueda de conversación activo.", - "state" : "translated" + "state" : "translated", + "value" : "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ανοίγει το OpenClient με ενεργό το πεδίο αναζήτησης συνομιλίας." + "value" : "Could not connect to the server." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apre OpenClient con il campo di ricerca conversazioni attivo.", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo conectar al servidor." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Abre o OpenClient com o campo de pesquisa da conversa ativo.", - "state" : "translated" + "state" : "translated", + "value" : "Impossible de se connecter au serveur." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Opens OpenClient with the conversation search field active.", - "state" : "translated" + "state" : "translated", + "value" : "Impossibile connettersi al server." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Ouvre OpenClient avec le champ de recherche de conversation actif.", - "state" : "translated" + "state" : "translated", + "value" : "サーバーに接続できませんでした。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Opent OpenClient met het zoekveld voor gesprekken actief." + "value" : "Kan geen verbinding maken met de server." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Öffnet OpenClient mit aktivem Suchfeld für Konversationen.", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível ligar ao servidor." } }, "sv" : { "stringUnit" : { - "value" : "Öppnar OpenClient med sökfältet för konversation aktivt.", - "state" : "translated" + "state" : "translated", + "value" : "Kunde inte ansluta till servern." } } } }, - "Type" : { + "Could not establish a secure connection to the server." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "タイプ", - "state" : "translated" + "state" : "translated", + "value" : "Es konnte keine sichere Verbindung zum Server hergestellt werden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Tipo", - "state" : "translated" + "state" : "translated", + "value" : "Δεν ήταν δυνατή η δημιουργία ασφαλούς σύνδεσης με τον διακομιστή." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Τύπος" + "value" : "Could not establish a secure connection to the server." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tipo" + "value" : "No se pudo establecer una conexión segura con el servidor." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tipo", - "state" : "translated" + "state" : "translated", + "value" : "Impossible d’établir une connexion sécurisée avec le serveur." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Type", - "state" : "translated" + "state" : "translated", + "value" : "Impossibile stabilire una connessione sicura con il server." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Type", - "state" : "translated" + "state" : "translated", + "value" : "サーバーへの安全な接続を確立できませんでした。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Type" + "value" : "Er kon geen beveiligde verbinding met de server worden gemaakt." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Typ", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível estabelecer uma ligação segura ao servidor." } }, "sv" : { "stringUnit" : { - "value" : "Typ", - "state" : "translated" + "state" : "translated", + "value" : "Kunde inte upprätta en säker anslutning till servern." } } - }, - "comment" : "A label that describes the type of a model." + } }, - "Response interrupted" : { + "Could not find the server. Please check the URL." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "応答が中断されました", - "state" : "translated" + "state" : "translated", + "value" : "Server konnte nicht gefunden werden. Bitte überprüfen Sie die URL." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Respuesta interrumpida" + "value" : "Δεν βρέθηκε ο διακομιστής. Ελέγξτε τη διεύθυνση URL." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Svar avbrutet" + "value" : "Could not find the server. Please check the URL." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Risposta interrotta", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo encontrar el servidor. Por favor, verifica la URL." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Resposta interrompida", - "state" : "translated" + "state" : "translated", + "value" : "Serveur introuvable. Veuillez vérifier l’URL." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Response interrupted" + "value" : "Impossibile trovare il server. Controlla l'URL." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Reactie onderbroken", - "state" : "translated" + "state" : "translated", + "value" : "サーバーが見つかりません。URLを確認してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Réponse interrompue", - "state" : "translated" + "state" : "translated", + "value" : "Kan de server niet vinden. Controleer de URL." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Antwort unterbrochen", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível encontrar o servidor. Por favor, verifique o URL." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Η απάντηση διακόπηκε", - "state" : "translated" + "state" : "translated", + "value" : "Kunde inte hitta servern. Kontrollera URL:en." } } - }, - "comment" : "Text displayed in a notification when the response to a prompt was cut short." + } }, - "The project notes are ready to review." : { + "Could not load tip options. Please try again later." : { + "comment" : "Error message displayed when there is an issue loading the tip options.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Οι σημειώσεις του έργου είναι έτοιμες για ανασκόπηση.", - "state" : "translated" + "state" : "translated", + "value" : "Tippoptionen konnten nicht geladen werden. Bitte versuchen Sie es später erneut." } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Die Projektnotizen sind bereit zur Überprüfung.", - "state" : "translated" + "state" : "translated", + "value" : "Δεν ήταν δυνατή η φόρτωση των επιλογών φιλοδωρήματος. Δοκιμάστε ξανά αργότερα." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Las notas del proyecto están listas para revisar." + "value" : "Could not load tip options. Please try again later." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Le note del progetto sono pronte per la revisione.", - "state" : "translated" + "state" : "translated", + "value" : "No se pudieron cargar las opciones de propina. Por favor, inténtelo de nuevo más tarde." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "As notas do projeto estão prontas para revisão." + "value" : "Impossible de charger les options de pourboire. Veuillez réessayer plus tard." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The project notes are ready to review.", - "state" : "translated" + "state" : "translated", + "value" : "Impossibile caricare le opzioni di mancia. Riprova più tardi." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De projectnotities zijn klaar om te bekijken.", - "state" : "translated" + "state" : "translated", + "value" : "チップオプションを読み込めませんでした。後でもう一度お試しください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Les notes du projet sont prêtes à être examinées." + "value" : "Kan de fooiopties niet laden. Probeer het later opnieuw." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "プロジェクトのメモがレビュー可能です。", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível carregar as opções de gorjeta. Por favor, tente novamente mais tarde." } }, "sv" : { "stringUnit" : { - "value" : "Projektanteckningarna är klara för granskning.", - "state" : "translated" + "state" : "translated", + "value" : "Kunde inte ladda dricksalternativ. Försök igen senare." } } - }, - "comment" : "Last message preview text for a conversation." + } }, - "GitHub Profile" : { + "Could not read the server response." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Προφίλ GitHub", - "state" : "translated" + "state" : "translated", + "value" : "Serverantwort konnte nicht gelesen werden." } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "GitHub-Profil", - "state" : "translated" + "state" : "translated", + "value" : "Δεν ήταν δυνατή η ανάγνωση της απάντησης του διακομιστή." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Perfil de GitHub" + "value" : "Could not read the server response." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Profilo GitHub", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo leer la respuesta del servidor." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Perfil do GitHub" + "value" : "Impossible de lire la réponse du serveur." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "GitHub Profile", - "state" : "translated" + "state" : "translated", + "value" : "Impossibile leggere la risposta del server." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "GitHub-profiel", - "state" : "translated" + "state" : "translated", + "value" : "サーバーの応答を読み取れませんでした。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Profil GitHub" + "value" : "Kan de serverreactie niet lezen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "GitHubプロフィール", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível ler a resposta do servidor." } }, "sv" : { "stringUnit" : { - "value" : "GitHub-profil", - "state" : "translated" + "state" : "translated", + "value" : "Kunde inte läsa serverns svar." } } - }, - "comment" : "Title of a web destination that opens the user's GitHub profile." + } }, - "Write a creative story" : { + "Could not write file to the shared container" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Γράψε μια δημιουργική ιστορία", - "state" : "translated" + "state" : "translated", + "value" : "Datei konnte nicht im gemeinsamen Container gespeichert werden" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "創造的な物語を書く" + "value" : "Δεν ήταν δυνατή η εγγραφή του αρχείου στον κοινόχρηστο φάκελο" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Escribe una historia creativa" + "value" : "Could not write file to the shared container" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Scrivi una storia creativa", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo escribir el archivo en el contenedor compartido" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Escreve uma história criativa", - "state" : "translated" + "state" : "translated", + "value" : "Impossible d’écrire le fichier dans le conteneur partagé" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Write a creative story" + "value" : "Impossibile scrivere il file nel contenitore condiviso" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Schrijf een creatief verhaal", - "state" : "translated" + "state" : "translated", + "value" : "共有コンテナにファイルを書き込めませんでした" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Écris une histoire créative", - "state" : "translated" + "state" : "translated", + "value" : "Kon bestand niet naar de gedeelde container schrijven" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Schreibe eine kreative Geschichte", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível gravar o ficheiro no contentor partilhado" } }, "sv" : { "stringUnit" : { - "value" : "Skriv en kreativ berättelse", - "state" : "translated" + "state" : "translated", + "value" : "Kunde inte skriva fil till den delade behållaren" } } } }, - "Image File..." : { + "Creative" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Αρχείο εικόνας..." + "value" : "Kreativ" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Archivo de imagen...", - "state" : "translated" + "state" : "translated", + "value" : "Δημιουργικό" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bilddatei..." + "value" : "Creative" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "File immagine..." + "value" : "Creativo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ficheiro de Imagem...", - "state" : "translated" + "state" : "translated", + "value" : "Créatif" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Image File...", - "state" : "translated" + "state" : "translated", + "value" : "Creativo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeeldingsbestand...", - "state" : "translated" + "state" : "translated", + "value" : "クリエイティブ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Fichier image...", - "state" : "translated" + "state" : "translated", + "value" : "Creatief" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "画像ファイル...", - "state" : "translated" + "state" : "translated", + "value" : "Criativo" } }, "sv" : { "stringUnit" : { - "value" : "Bildfil...", - "state" : "translated" + "state" : "translated", + "value" : "Kreativ" } } - }, - "comment" : "A label for selecting an image file." + } }, - "Your data stays on your own server — no telemetry" : { + "Creative Writer" : { + "comment" : "Name of the creative writing assistant prompt template.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Ihre Daten bleiben auf Ihrem eigenen Server — keine Telemetrie", - "state" : "translated" + "state" : "translated", + "value" : "Kreativautor" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Tus datos permanecen en tu propio servidor sin telemetría" + "value" : "Δημιουργικός Συγγραφέας" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Τα δεδομένα σας παραμένουν στον δικό σας διακομιστή — χωρίς τηλεμετρία" + "value" : "Creative Writer" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "I tuoi dati restano sul tuo server — nessuna telemetria", - "state" : "translated" + "state" : "translated", + "value" : "Escritor Creativo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Os seus dados permanecem no seu próprio servidor — sem telemetria" + "value" : "Écrivain créatif" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Your data stays on your own server — no telemetry", - "state" : "translated" + "state" : "translated", + "value" : "Scrittore Creativo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Uw gegevens blijven op uw eigen server — geen telemetrie", - "state" : "translated" + "state" : "translated", + "value" : "クリエイティブライター" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Vos données restent sur votre propre serveur — pas de télémétrie", - "state" : "translated" + "state" : "translated", + "value" : "Creatief Schrijver" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "データはお客様のサーバーにのみ保存され、テレメトリーはありません", - "state" : "translated" + "state" : "translated", + "value" : "Escritor Criativo" } }, "sv" : { "stringUnit" : { - "value" : "Dina data stannar på din egen server — ingen telemetri", - "state" : "translated" + "state" : "translated", + "value" : "Kreativ författare" } } - }, - "comment" : "A description of the privacy features of OpenClient." + } }, - "Issue Image" : { + "Custom" : { + "comment" : "A section title for the user's custom prompt templates.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "問題の画像" + "value" : "Benutzerdefiniert" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Imagen del problema", - "state" : "translated" + "state" : "translated", + "value" : "Προσαρμοσμένο" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bild på problemet" + "value" : "Custom" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Immagine del problema" + "value" : "Personalizado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Imagem do Problema", - "state" : "translated" + "state" : "translated", + "value" : "Personnalisé" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Issue Image", - "state" : "translated" + "state" : "translated", + "value" : "Personalizzato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeelding van het probleem", - "state" : "translated" + "state" : "translated", + "value" : "カスタム" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Image du problème", - "state" : "translated" + "state" : "translated", + "value" : "Aangepast" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εικόνα προβλήματος", - "state" : "translated" + "state" : "translated", + "value" : "Personalizado" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Problemfoto", - "state" : "translated" + "state" : "translated", + "value" : "Anpassad" } } - }, - "comment" : "Title of the section where the user can attach an image of the issue." + } }, - "votes" : { + "Custom..." : { + "comment" : "A button that opens a sheet for entering a custom voice ID.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Stimmen", - "state" : "translated" + "state" : "translated", + "value" : "Benutzerdefiniert..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "votos", - "state" : "translated" + "state" : "translated", + "value" : "Προσαρμοσμένο..." } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "ψήφοι", - "state" : "translated" + "state" : "translated", + "value" : "Custom..." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "voti" + "value" : "Personalizado..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "votos", - "state" : "translated" + "state" : "translated", + "value" : "Personnalisé..." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "votes" + "value" : "Personalizzato..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "stemmen", - "state" : "translated" + "state" : "translated", + "value" : "カスタム..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "votes" + "value" : "Aangepast..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "投票数", - "state" : "translated" + "state" : "translated", + "value" : "Personalizado..." } }, "sv" : { "stringUnit" : { - "value" : "röster", - "state" : "translated" + "state" : "translated", + "value" : "Anpassad..." } } } }, - "Your support means a lot and helps keep the app free and open source." : { + "Customise this conversation" : { + "comment" : "A label for a menu that allows users to customise their current conversation.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Deine Unterstützung bedeutet viel und hilft, die App kostenlos und Open Source zu halten." + "value" : "Diese Unterhaltung anpassen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Tu apoyo significa mucho y ayuda a mantener la aplicación gratuita y de código abierto.", - "state" : "translated" + "state" : "translated", + "value" : "Προσαρμόστε αυτή τη συνομιλία" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ditt stöd betyder mycket och hjälper till att hålla appen gratis och öppen källkod." + "value" : "Customize this conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il tuo supporto è molto importante e aiuta a mantenere l’app gratuita e open source.", - "state" : "translated" + "state" : "translated", + "value" : "Personalizar esta conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O seu apoio é muito importante e ajuda a manter a aplicação gratuita e de código aberto.", - "state" : "translated" + "state" : "translated", + "value" : "Personnaliser cette conversation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Your support means a lot and helps keep the app free and open source.", - "state" : "translated" + "state" : "translated", + "value" : "Personalizza questa conversazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Votre soutien est précieux et permet de garder l’application gratuite et open source.", - "state" : "translated" + "state" : "translated", + "value" : "この会話をカスタマイズする" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Je steun betekent veel en helpt de app gratis en open source te houden." + "value" : "Pas dit gesprek aan" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Η υποστήριξή σας σημαίνει πολλά και βοηθά να παραμείνει η εφαρμογή δωρεάν και ανοιχτού κώδικα.", - "state" : "translated" + "state" : "translated", + "value" : "Personalizar esta conversa" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "ご支援いただくことで、アプリを無料かつオープンソースのまま維持できます。", - "state" : "translated" + "state" : "translated", + "value" : "Anpassa den här konversationen" } } - }, - "comment" : "A message displayed in a thank you alert." + } }, - "No Model" : { + "Cyan" : { + "comment" : "Name of the color cyan.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Ingen modell", - "state" : "translated" + "state" : "translated", + "value" : "Cyan" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sin modelo", - "state" : "translated" + "state" : "translated", + "value" : "Κυανό" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "モデルなし" + "value" : "Cyan" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessun modello", - "state" : "translated" + "state" : "translated", + "value" : "Cian" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sem modelo" + "value" : "Cyan" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "No Model" + "value" : "Ciano" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen model", - "state" : "translated" + "state" : "translated", + "value" : "シアン" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucun modèle", - "state" : "translated" + "state" : "translated", + "value" : "Cyaan" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Kein Modell", - "state" : "translated" + "state" : "translated", + "value" : "Ciano" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Χωρίς μοντέλο", - "state" : "translated" + "state" : "translated", + "value" : "Cyan" } } } }, - "Show Token Usage" : { + "Data Analyst" : { + "comment" : "Description of a prompt template for a data analyst assistant.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εμφάνιση χρήσης διακριτικού", - "state" : "translated" + "state" : "translated", + "value" : "Datenanalyst" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mostrar uso de tokens", - "state" : "translated" + "state" : "translated", + "value" : "Αναλυτής Δεδομένων" } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Tokenverbrauch anzeigen", - "state" : "translated" + "state" : "translated", + "value" : "Data Analyst" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Mostra utilizzo token" + "value" : "Analista de datos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mostrar Utilização de Token" + "value" : "Analyste de données" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Show Token Usage", - "state" : "translated" + "state" : "translated", + "value" : "Analista Dati" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Afficher l’utilisation des jetons", - "state" : "translated" + "state" : "translated", + "value" : "データアナリスト" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tokengebruik weergeven" + "value" : "Data-analist" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "トークン使用量を表示", - "state" : "translated" + "state" : "translated", + "value" : "Analista de Dados" } }, "sv" : { "stringUnit" : { - "value" : "Visa tokenanvändning", - "state" : "translated" + "state" : "translated", + "value" : "Dataanalytiker" } } - }, - "comment" : "A toggle that shows the number of tokens remaining in the current token." + } }, - "Explain a complex topic simply" : { + "Delete" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "複雑な話題を簡単に説明する", - "state" : "translated" + "state" : "translated", + "value" : "Löschen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Explica un tema complejo de forma sencilla" + "value" : "Διαγραφή" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εξήγησε ένα σύνθετο θέμα απλά" + "value" : "Delete" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Spiega un argomento complesso in modo semplice", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Explique um tema complexo de forma simples", - "state" : "translated" + "state" : "translated", + "value" : "Supprimer" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Explain a complex topic simply" + "value" : "Elimina" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Leg een complex onderwerp eenvoudig uit", - "state" : "translated" + "state" : "translated", + "value" : "削除" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Expliquer un sujet complexe simplement", - "state" : "translated" + "state" : "translated", + "value" : "Verwijderen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Erkläre ein komplexes Thema einfach", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar" } }, "sv" : { "stringUnit" : { - "value" : "Förklara ett komplext ämne enkelt", - "state" : "translated" + "state" : "translated", + "value" : "Radera" } } } }, - "Web Search" : { + "Delete comment" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "ウェブ検索" + "value" : "Kommentar löschen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Búsqueda web", - "state" : "translated" + "state" : "translated", + "value" : "Διαγραφή σχολίου" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Αναζήτηση στο Διαδίκτυο" + "value" : "Delete comment" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Ricerca Web", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar comentario" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Pesquisa Web" + "value" : "Supprimer le commentaire" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Elimina commento" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Webzoekfunctie", - "state" : "translated" + "state" : "translated", + "value" : "コメントを削除" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Recherche Web", - "state" : "translated" + "state" : "translated", + "value" : "Reactie verwijderen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Websuche", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar comentário" } }, "sv" : { "stringUnit" : { - "value" : "Webbsökning", - "state" : "translated" + "state" : "translated", + "value" : "Radera kommentar" } } - }, - "comment" : "A section of the settings view that allows the user to configure the web search tool." + } }, - "Test Connection" : { + "Delete Conversation" : { + "comment" : "A confirmation dialog title for deleting a conversation.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δοκιμή σύνδεσης", - "state" : "translated" + "state" : "translated", + "value" : "Konversation löschen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Probar conexión" + "value" : "Διαγραφή Συνομιλίας" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Verbindung testen" + "value" : "Delete Conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Testa connessione", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Testar ligação", - "state" : "translated" + "state" : "translated", + "value" : "Supprimer la conversation" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Test Connection" + "value" : "Elimina conversazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Tester la connexion", - "state" : "translated" + "state" : "translated", + "value" : "会話を削除" } }, "nl" : { "stringUnit" : { - "value" : "Verbinding testen", - "state" : "translated" + "state" : "translated", + "value" : "Gesprek verwijderen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "接続をテスト", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar Conversa" } }, "sv" : { "stringUnit" : { - "value" : "Testa anslutning", - "state" : "translated" + "state" : "translated", + "value" : "Radera konversation" } } } }, - "Favourites" : { + "Delete suggestion" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Αγαπημένα" + "value" : "Vorschlag löschen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Favoritos", - "state" : "translated" + "state" : "translated", + "value" : "Διαγραφή πρότασης" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Favoriten" + "value" : "Delete suggestion" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Preferiti", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar sugerencia" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Favoritos", - "state" : "translated" + "state" : "translated", + "value" : "Supprimer la suggestion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Favorites", - "state" : "translated" + "state" : "translated", + "value" : "Elimina suggerimento" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Favoris", - "state" : "translated" + "state" : "translated", + "value" : "提案を削除" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Favorieten" + "value" : "Suggestie verwijderen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "お気に入り", - "state" : "translated" + "state" : "translated", + "value" : "Eliminar sugestão" } }, "sv" : { "stringUnit" : { - "value" : "Favoriter", - "state" : "translated" + "state" : "translated", + "value" : "Ta bort förslag" } } - }, - "comment" : "A title for a screen that shows the user's favourite messages." + } }, - "Orange" : { + "Deleted from memory: %@" : { + "comment" : "A notification that a memory item has been deleted. The argument is the content of the memory item.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Orange", - "state" : "translated" + "state" : "translated", + "value" : "Aus dem Speicher gelöscht: %@" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Naranja", - "state" : "translated" + "state" : "translated", + "value" : "Διαγράφηκε από τη μνήμη: %@" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Πορτοκαλί" + "value" : "Deleted from memory: %@" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Arancione" + "value" : "Eliminado de la memoria: %@" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Laranja", - "state" : "translated" + "state" : "translated", + "value" : "Supprimé de la mémoire : %@" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Orange" + "value" : "Eliminato dalla memoria: %@" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Oranje", - "state" : "translated" + "state" : "translated", + "value" : "メモリから削除しました: %@" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Orange", - "state" : "translated" + "state" : "translated", + "value" : "Verwijderd uit geheugen: %@" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "オレンジ", - "state" : "translated" + "state" : "translated", + "value" : "Eliminado da memória: %@" } }, "sv" : { "stringUnit" : { - "value" : "Orange", - "state" : "translated" + "state" : "translated", + "value" : "Borttaget från minnet: %@" } } - }, - "comment" : "Name of the color orange." + } }, - "%lld messages compacted" : { + "Deletes all local settings and credentials. iCloud data will not be affected." : { + "comment" : "A footer for the reset button in the settings.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "%lld meddelanden komprimerade" + "value" : "Löscht alle lokalen Einstellungen und Anmeldedaten. iCloud-Daten bleiben unberührt." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%lld mensajes compactados", - "state" : "translated" + "state" : "translated", + "value" : "Διαγράφει όλες τις τοπικές ρυθμίσεις και τα διαπιστευτήρια. Τα δεδομένα iCloud δεν θα επηρεαστούν." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%lld συμπιεσμένα μηνύματα" + "value" : "Deletes all local settings and credentials. iCloud data will not be affected." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "%lld messaggi compressi" + "value" : "Elimina todas las configuraciones y credenciales locales. Los datos de iCloud no se verán afectados." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "%lld mensagens compactadas", - "state" : "translated" + "state" : "translated", + "value" : "Supprime tous les paramètres et identifiants locaux. Les données iCloud ne seront pas affectées." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%lld messages compacted", - "state" : "translated" + "state" : "translated", + "value" : "Elimina tutte le impostazioni e le credenziali locali. I dati di iCloud non saranno interessati." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%lld berichten samengevoegd", - "state" : "translated" + "state" : "translated", + "value" : "すべてのローカル設定と認証情報を削除します。iCloudのデータには影響しません。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "%lld messages compactés", - "state" : "translated" + "state" : "translated", + "value" : "Verwijdert alle lokale instellingen en inloggegevens. iCloud-gegevens blijven ongewijzigd." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "%lld 件のメッセージを圧縮しました", - "state" : "translated" + "state" : "translated", + "value" : "Apaga todas as definições e credenciais locais. Os dados do iCloud não serão afetados." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "%lld Nachrichten komprimiert", - "state" : "translated" + "state" : "translated", + "value" : "Tar bort alla lokala inställningar och inloggningsuppgifter. iCloud-data påverkas inte." } } } }, - "Find a conversation" : { + "Describe the issue in detail..." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Βρες μια συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Beschreiben Sie das Problem ausführlich..." } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Konversation finden", - "state" : "translated" + "state" : "translated", + "value" : "Περιγράψτε το πρόβλημα με λεπτομέρεια..." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Buscar una conversación" + "value" : "Describe the issue in detail..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Trova una conversazione", - "state" : "translated" + "state" : "translated", + "value" : "Describe el problema en detalle..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Encontrar uma conversa" + "value" : "Décrivez le problème en détail..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Find a conversation", - "state" : "translated" + "state" : "translated", + "value" : "Descrivi il problema in dettaglio..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Zoek een gesprek", - "state" : "translated" + "state" : "translated", + "value" : "問題を詳しく説明してください..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Trouver une conversation" + "value" : "Beschrijf het probleem in detail..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話を検索", - "state" : "translated" + "state" : "translated", + "value" : "Descreva o problema em detalhe..." } }, "sv" : { "stringUnit" : { - "value" : "Hitta en konversation", - "state" : "translated" + "state" : "translated", + "value" : "Beskriv problemet i detalj..." } } - }, - "comment" : "Text displayed in a shortcut item for searching conversations." + } }, - "%lld compacted · %lld excluded" : { + "Describe your suggestion in detail..." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "%1$lld 件を圧縮 · %2$lld 件を除外", - "state" : "translated" + "state" : "translated", + "value" : "Beschreiben Sie Ihren Vorschlag im Detail..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%1$lld compactados · %2$lld excluidos", - "state" : "translated" + "state" : "translated", + "value" : "Περιγράψτε την πρότασή σας λεπτομερώς..." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld komprimiert · %2$lld ausgeschlossen" + "value" : "Describe your suggestion in detail..." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld compattati · %2$lld esclusi" + "value" : "Describe tu sugerencia en detalle..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "%1$lld compactados · %2$lld excluídos", - "state" : "translated" + "state" : "translated", + "value" : "Décrivez votre suggestion en détail..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%1$lld compacted · %2$lld excluded", - "state" : "new" + "state" : "translated", + "value" : "Descrivi la tua proposta in dettaglio..." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "%1$lld compactés · %2$lld exclus", - "state" : "translated" + "state" : "translated", + "value" : "提案の詳細を説明してください..." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld gecomprimeerd · %2$lld uitgesloten" + "value" : "Beschrijf uw suggestie in detail..." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "%1$lld συμπιεσμένα · %2$lld εξαιρέθηκαν", - "state" : "translated" + "state" : "translated", + "value" : "Descreva a sua sugestão em detalhe..." } }, "sv" : { "stringUnit" : { - "value" : "%1$lld komprimerade · %2$lld uteslutna", - "state" : "translated" + "state" : "translated", + "value" : "Beskriv ditt förslag i detalj..." } } - }, - "comment" : "A description of the number of messages that were compacted or excluded from the context. The first argument is the number of compacted messages. The second argument is the number of excluded messages." + } }, - "Tags" : { + "Description" : { + "comment" : "A label displayed above the user's profile description.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "タグ", - "state" : "translated" + "state" : "translated", + "value" : "Beschreibung" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Etiquetas", - "state" : "translated" + "state" : "translated", + "value" : "Περιγραφή" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Ετικέτες", - "state" : "translated" + "state" : "translated", + "value" : "Description" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tag" + "value" : "Descripción" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Etiquetas" + "value" : "Description" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Tags" + "value" : "Descrizione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Tags", - "state" : "translated" + "state" : "translated", + "value" : "説明" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Étiquettes", - "state" : "translated" + "state" : "translated", + "value" : "Beschrijving" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Tags", - "state" : "translated" + "state" : "translated", + "value" : "Descrição" } }, "sv" : { "stringUnit" : { - "value" : "Taggar", - "state" : "translated" + "state" : "translated", + "value" : "Beskrivning" } } - }, - "comment" : "A heading displayed above the user's tags." + } }, - "Keep it short and descriptive" : { + "Description (optional)" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Håll det kort och beskrivande" + "value" : "Beschreibung (optional)" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sé breve y descriptivo", - "state" : "translated" + "state" : "translated", + "value" : "Περιγραφή (προαιρετικό)" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kurz und prägnant" + "value" : "Description (optional)" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Mantienilo breve e descrittivo", - "state" : "translated" + "state" : "translated", + "value" : "Descripción (opcional)" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Seja breve e descritivo" + "value" : "Description (optionnel)" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Keep it short and descriptive", - "state" : "translated" + "state" : "translated", + "value" : "Descrizione (opzionale)" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Houd het kort en duidelijk", - "state" : "translated" + "state" : "translated", + "value" : "説明(任意)" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Soyez bref et descriptif", - "state" : "translated" + "state" : "translated", + "value" : "Beschrijving (optioneel)" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "短く分かりやすく", - "state" : "translated" + "state" : "translated", + "value" : "Descrição (opcional)" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Κρατήστε το σύντομο και περιγραφικό", - "state" : "translated" + "state" : "translated", + "value" : "Beskrivning (valfritt)" } } } }, - "%lld server(s) available" : { + "Details" : { + "comment" : "A section that provides more details about a model.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "%lld 台のサーバーが利用可能" + "value" : "Details" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%lld servidor(es) disponibles", - "state" : "translated" + "state" : "translated", + "value" : "Λεπτομέρειες" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%lld διακομιστής(ες) διαθέσιμος(οι)" + "value" : "Details" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "%lld server disponibili", - "state" : "translated" + "state" : "translated", + "value" : "Detalles" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "%lld servidor(es) disponível(eis)", - "state" : "translated" + "state" : "translated", + "value" : "Détails" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%lld server(s) available", - "state" : "translated" + "state" : "translated", + "value" : "Dettagli" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%lld server(s) beschikbaar", - "state" : "translated" + "state" : "translated", + "value" : "詳細" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "%lld serveur(s) disponible(s)" + "value" : "Details" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "%lld Server verfügbar", - "state" : "translated" + "state" : "translated", + "value" : "Detalhes" } }, "sv" : { "stringUnit" : { - "value" : "%lld server tillgängliga", - "state" : "translated" + "state" : "translated", + "value" : "Detaljer" } } - }, - "comment" : "A label that shows the number of MCP servers available. The argument is the number of servers." + } }, - "Estimated context" : { + "Disable Web Search" : { + "comment" : "A button that disables the web search feature.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εκτιμώμενο πλαίσιο", - "state" : "translated" + "state" : "translated", + "value" : "Websuche deaktivieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Contexto estimado", - "state" : "translated" + "state" : "translated", + "value" : "Απενεργοποίηση Αναζήτησης Ιστού" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "推定コンテキスト" + "value" : "Disable Web Search" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Contesto stimato", - "state" : "translated" + "state" : "translated", + "value" : "Desactivar búsqueda web" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Contexto estimado", - "state" : "translated" + "state" : "translated", + "value" : "Désactiver la recherche Web" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Estimated context" + "value" : "Disattiva ricerca web" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geschatte context", - "state" : "translated" + "state" : "translated", + "value" : "ウェブ検索を無効にする" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Contexte estimé" + "value" : "Webzoekfunctie uitschakelen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Geschätzter Kontext", - "state" : "translated" + "state" : "translated", + "value" : "Desativar Pesquisa Web" } }, "sv" : { "stringUnit" : { - "value" : "Uppskattad kontext", - "state" : "translated" + "state" : "translated", + "value" : "Inaktivera webbsökning" } } - }, - "comment" : "A label that describes the context usage." - }, - "$%.4f \/ 1K tokens" : { - "shouldTranslate" : false, - "comment" : "A label that shows the cost of input in USD per 1K tokens." + } }, - "Pinned Conversations" : { + "Dismiss banner" : { + "comment" : "A label for dismissing a banner.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Καρφιτσωμένες Συνομιλίες", - "state" : "translated" + "state" : "translated", + "value" : "Banner schließen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Conversaciones fijadas", - "state" : "translated" + "state" : "translated", + "value" : "Απόρριψη banner" } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "ピン留めされた会話", - "state" : "translated" + "state" : "translated", + "value" : "Dismiss banner" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Conversazioni fissate", - "state" : "translated" + "state" : "translated", + "value" : "Descartar banner" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Conversas Fixadas" + "value" : "Fermer la bannière" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Pinned Conversations", - "state" : "translated" + "state" : "translated", + "value" : "Chiudi il banner" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Conversations épinglées" + "value" : "バナーを閉じる" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vastgezette gesprekken" + "value" : "Banner sluiten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Angeheftete Unterhaltungen", - "state" : "translated" + "state" : "translated", + "value" : "Fechar faixa de aviso" } }, "sv" : { "stringUnit" : { - "value" : "Fästa konversationer", - "state" : "translated" + "state" : "translated", + "value" : "Stäng bannern" } } - }, - "comment" : "Title of the widget that shows pinned conversations." - }, - "Input tokens" : { - "shouldTranslate" : false, - "comment" : "A label for the maximum number of input tokens for a model." + } }, - "Image" : { + "Document" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "画像" + "value" : "Dokument" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Imagen", - "state" : "translated" + "state" : "translated", + "value" : "Έγγραφο" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bild" + "value" : "Document" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Immagine", - "state" : "translated" + "state" : "translated", + "value" : "Documento" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Imagem" + "value" : "Document" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "Documento" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeelding", - "state" : "translated" + "state" : "translated", + "value" : "ドキュメント" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "Document" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εικόνα", - "state" : "translated" + "state" : "translated", + "value" : "Documento" } }, "sv" : { "stringUnit" : { - "value" : "Bild", - "state" : "translated" + "state" : "translated", + "value" : "Dokument" } } } }, - "Copy" : { + "Documents" : { + "comment" : "A section header for a list of documents.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "コピー" + "value" : "Dokumente" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Copiar", - "state" : "translated" + "state" : "translated", + "value" : "Έγγραφα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kopiera" + "value" : "Documents" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Copia", - "state" : "translated" + "state" : "translated", + "value" : "Documentos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Copiar", - "state" : "translated" + "state" : "translated", + "value" : "Documents" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Copy", - "state" : "translated" + "state" : "translated", + "value" : "Documenti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Kopiëren", - "state" : "translated" + "state" : "translated", + "value" : "ドキュメント" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Copier" + "value" : "Documenten" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αντιγραφή", - "state" : "translated" + "state" : "translated", + "value" : "Documentos" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Kopieren", - "state" : "translated" + "state" : "translated", + "value" : "Dokument" } } } }, - "Retry" : { + "Done" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Επανάληψη προσπάθειας", - "state" : "translated" + "state" : "translated", + "value" : "Fertig" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Reintentar", - "state" : "translated" + "state" : "translated", + "value" : "ΤΕΛΕΙΩΣΕ" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Erneut versuchen" + "value" : "Done" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Riprova" + "value" : "Hecho" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tentar novamente", - "state" : "translated" + "state" : "translated", + "value" : "Terminé" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Retry" + "value" : "Fatto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Opnieuw proberen", - "state" : "translated" + "state" : "translated", + "value" : "完了" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Réessayer", - "state" : "translated" + "state" : "translated", + "value" : "Gereed" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "再試行", - "state" : "translated" + "state" : "translated", + "value" : "Concluído" } }, "sv" : { "stringUnit" : { - "value" : "Försök igen", - "state" : "translated" + "state" : "translated", + "value" : "Klart" } } } }, - "Model Parameters" : { + "Drag image here" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Παράμετροι Μοντέλου" + "value" : "Bild hierher ziehen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Parámetros del modelo", - "state" : "translated" + "state" : "translated", + "value" : "Σύρετε την εικόνα εδώ" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Modellparameter" + "value" : "Drag image here" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Parametri del modello", - "state" : "translated" + "state" : "translated", + "value" : "Arrastra la imagen aquí" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Parâmetros do Modelo", - "state" : "translated" + "state" : "translated", + "value" : "Glissez l’image ici" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Model Parameters", - "state" : "translated" + "state" : "translated", + "value" : "Trascina l'immagine qui" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Modelparameters", - "state" : "translated" + "state" : "translated", + "value" : "ここに画像をドラッグしてください" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Paramètres du modèle" + "value" : "Sleep afbeelding hierheen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "モデルパラメータ", - "state" : "translated" + "state" : "translated", + "value" : "Arraste a imagem aqui" } }, "sv" : { "stringUnit" : { - "value" : "Modellparametrar", - "state" : "translated" + "state" : "translated", + "value" : "Dra bilden hit" } } - }, - "comment" : "A title for a view that allows the user to configure the parameters of a chat model." + } }, - "You" : { + "e.g. Coding Assistant" : { + "comment" : "A placeholder text for the title of a prompt template.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εσύ", - "state" : "translated" + "state" : "translated", + "value" : "z. B. Coding Assistant" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Tú", - "state" : "translated" + "state" : "translated", + "value" : "π.χ. Βοηθός Κωδικοποίησης" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Du" + "value" : "e.g. Coding Assistant" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tu" + "value" : "p. ej. Asistente de codificación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tu", - "state" : "translated" + "state" : "translated", + "value" : "ex. Assistant de codage" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "You" + "value" : "es. Assistente di Codifica" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Jij", - "state" : "translated" + "state" : "translated", + "value" : "例:コーディングアシスタント" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Vous", - "state" : "translated" + "state" : "translated", + "value" : "bijv. Coding Assistant" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "あなた", - "state" : "translated" + "state" : "translated", + "value" : "ex. Assistente de Programação" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Du", - "state" : "translated" + "state" : "translated", + "value" : "t.ex. Kodningsassistent" } } - }, - "comment" : "A name for the user." + } }, - "Text to Speech" : { + "e.g. User prefers concise answers" : { + "comment" : "A placeholder text for a memory item's content.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κείμενο σε Ομιλία", - "state" : "translated" + "state" : "translated", + "value" : "z. B. Nutzer bevorzugt kurze Antworten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Texto a voz", - "state" : "translated" + "state" : "translated", + "value" : "π.χ. Ο χρήστης προτιμά σύντομες απαντήσεις" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Text-zu-Sprache" + "value" : "e.g. User prefers concise answers" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sintesi vocale", - "state" : "translated" + "state" : "translated", + "value" : "p. ej. El usuario prefiere respuestas concisas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Texto para Fala", - "state" : "translated" + "state" : "translated", + "value" : "ex. L’utilisateur préfère des réponses concises" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Text to Speech" + "value" : "es. L’utente preferisce risposte concise" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Tekst-naar-spraak", - "state" : "translated" + "state" : "translated", + "value" : "例:ユーザーは簡潔な回答を好む" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Synthèse vocale" + "value" : "Bijv. gebruiker geeft de voorkeur aan beknopte antwoorden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "テキスト読み上げ", - "state" : "translated" + "state" : "translated", + "value" : "ex. O utilizador prefere respostas concisas" } }, "sv" : { "stringUnit" : { - "value" : "Text-till-tal", - "state" : "translated" + "state" : "translated", + "value" : "t.ex. Användaren föredrar korta svar" } } - }, - "comment" : "A section title for a list of text-to-speech models." + } }, - "No internet connection. Please check your network." : { + "Each conversation can use a different model. Features depend on its capabilities." : { + "comment" : "A description of the features available for each model.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Keine Internetverbindung. Bitte überprüfen Sie Ihr Netzwerk.", - "state" : "translated" + "state" : "translated", + "value" : "Jede Unterhaltung kann ein anderes Modell verwenden. Die Funktionen hängen von dessen Fähigkeiten ab." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sin conexión a internet. Por favor, verifica tu red.", - "state" : "translated" + "state" : "translated", + "value" : "Κάθε συνομιλία μπορεί να χρησιμοποιεί διαφορετικό μοντέλο. Οι λειτουργίες εξαρτώνται από τις δυνατότητές του." } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "インターネットに接続されていません。ネットワークを確認してください。", - "state" : "translated" + "state" : "translated", + "value" : "Each conversation can use a different model. Features depend on its capabilities." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nessuna connessione a Internet. Controlla la tua rete." + "value" : "Cada conversación puede usar un modelo diferente. Las funciones dependen de sus capacidades." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sem ligação à internet. Verifique a sua rede." + "value" : "Chaque conversation peut utiliser un modèle différent. Les fonctionnalités dépendent de ses capacités." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "No internet connection. Please check your network." + "value" : "Ogni conversazione può utilizzare un modello diverso. Le funzionalità dipendono dalle sue capacità." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen internetverbinding. Controleer uw netwerk.", - "state" : "translated" + "state" : "translated", + "value" : "各会話は異なるモデルを使用できます。機能はその能力に依存します。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Pas de connexion Internet. Veuillez vérifier votre réseau.", - "state" : "translated" + "state" : "translated", + "value" : "Elke conversatie kan een ander model gebruiken. Functies zijn afhankelijk van de mogelijkheden ervan." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δεν υπάρχει σύνδεση στο διαδίκτυο. Ελέγξτε το δίκτυό σας.", - "state" : "translated" + "state" : "translated", + "value" : "Cada conversa pode usar um modelo diferente. As funcionalidades dependem das suas capacidades." } }, "sv" : { "stringUnit" : { - "value" : "Ingen internetanslutning. Kontrollera ditt nätverk.", - "state" : "translated" + "state" : "translated", + "value" : "Varje konversation kan använda en annan modell. Funktionerna beror på dess kapacitet." } } } }, - "MCP Servers" : { + "Earlier" : { + "comment" : "Title for a section of conversation data that includes conversations older than a week.", "localizations" : { "de" : { - "stringUnit" : { - "value" : "MCP-Server", - "state" : "translated" - } - }, - "es" : { "stringUnit" : { "state" : "translated", - "value" : "Servidores MCP" + "value" : "Früher" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Διακομιστές MCP" + "value" : "Προηγούμενα" } }, - "it" : { + "en" : { "stringUnit" : { - "value" : "Server MCP", - "state" : "translated" + "state" : "translated", + "value" : "Earlier" } }, - "pt-PT" : { + "es" : { "stringUnit" : { - "value" : "Servidores MCP", - "state" : "translated" + "state" : "translated", + "value" : "Anteriormente" } }, - "en" : { + "fr" : { "stringUnit" : { - "value" : "MCP Servers", - "state" : "translated" + "state" : "translated", + "value" : "Plus tôt" } }, - "nl" : { + "it" : { "stringUnit" : { - "value" : "MCP-servers", - "state" : "translated" + "state" : "translated", + "value" : "Più vecchio" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Serveurs MCP" + "value" : "以前" } }, - "ja" : { + "nl" : { "stringUnit" : { - "value" : "MCPサーバー", - "state" : "translated" + "state" : "translated", + "value" : "Eerder" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mais antigo" } }, "sv" : { "stringUnit" : { - "value" : "MCP-servrar", - "state" : "translated" + "state" : "translated", + "value" : "Tidigare" } } - }, - "comment" : "A button that dismisses the MCP Tools sheet." + } }, - "Review and improve my writing" : { + "Edit" : { + "comment" : "A button that opens a sheet for editing a template.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αναθεώρηση και βελτίωση της γραφής μου", - "state" : "translated" + "state" : "translated", + "value" : "Bearbeiten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Revisa y mejora mi redacción", - "state" : "translated" + "state" : "translated", + "value" : "Επεξεργασία" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Überprüfen und verbessern Sie meinen Text" + "value" : "Edit" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Rivedi e migliora il mio testo" + "value" : "Editar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Rever e melhorar a minha escrita" + "value" : "Modifier" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Review and improve my writing", - "state" : "translated" + "state" : "translated", + "value" : "Modifica" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Relisez et améliorez mon texte", - "state" : "translated" + "state" : "translated", + "value" : "編集" } }, "nl" : { "stringUnit" : { - "value" : "Beoordeel en verbeter mijn tekst", - "state" : "translated" + "state" : "translated", + "value" : "Bewerken" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "私の文章を見直して改善する", - "state" : "translated" + "state" : "translated", + "value" : "Editar" } }, "sv" : { "stringUnit" : { - "value" : "Granska och förbättra min text", - "state" : "translated" + "state" : "translated", + "value" : "Redigera" } } } }, - "Use this when an OpenAI-compatible server does not provide context metadata." : { + "Edit & Resend" : { + "comment" : "A label for editing and resending a chat message.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Verwenden Sie dies, wenn ein OpenAI-kompatibler Server keine Kontextmetadaten bereitstellt." + "value" : "Bearbeiten & erneut senden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Usa esto cuando un servidor compatible con OpenAI no proporcione metadatos de contexto.", - "state" : "translated" + "state" : "translated", + "value" : "Επεξεργασία & Αποστολή ξανά" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Χρησιμοποιήστε το όταν ένας διακομιστής συμβατός με OpenAI δεν παρέχει μεταδεδομένα συμφραζομένων." + "value" : "Edit & Resend" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Usa questo quando un server compatibile con OpenAI non fornisce metadati di contesto." + "value" : "Editar y reenviar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Utilize isto quando um servidor compatível com OpenAI não fornecer metadados de contexto.", - "state" : "translated" + "state" : "translated", + "value" : "Modifier et renvoyer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Use this when an OpenAI-compatible server does not provide context metadata", - "state" : "translated" + "state" : "translated", + "value" : "Modifica e rinvia" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gebruik dit wanneer een OpenAI-compatibele server geen contextmetadata levert.", - "state" : "translated" + "state" : "translated", + "value" : "編集して再送信" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Utilisez ceci lorsqu’un serveur compatible OpenAI ne fournit pas de métadonnées contextuelles.", - "state" : "translated" + "state" : "translated", + "value" : "Bewerken & Opnieuw verzenden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenAI互換サーバーがコンテキストメタデータを提供しない場合に使用してください。", - "state" : "translated" + "state" : "translated", + "value" : "Editar e Reenviar" } }, "sv" : { "stringUnit" : { - "value" : "Använd detta när en OpenAI-kompatibel server inte tillhandahåller kontextmetadata.", - "state" : "translated" + "state" : "translated", + "value" : "Redigera och skicka igen" } } } }, - "Settings" : { + "Edit Memory" : { + "comment" : "A title for a view that edits a memory item.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "設定", - "state" : "translated" + "state" : "translated", + "value" : "Erinnerung bearbeiten" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Configuración" + "value" : "Επεξεργασία Μνήμης" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Einstellungen" + "value" : "Edit Memory" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Impostazioni" + "value" : "Editar memoria" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Definições", - "state" : "translated" + "state" : "translated", + "value" : "Modifier la mémoire" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Settings", - "state" : "translated" + "state" : "translated", + "value" : "Modifica memoria" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Instellingen", - "state" : "translated" + "state" : "translated", + "value" : "メモリを編集" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Paramètres", - "state" : "translated" + "state" : "translated", + "value" : "Geheugen bewerken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ρυθμίσεις", - "state" : "translated" + "state" : "translated", + "value" : "Editar Memória" } }, "sv" : { "stringUnit" : { - "value" : "Inställningar", - "state" : "translated" + "state" : "translated", + "value" : "Redigera minne" } } } }, - "Any Model" : { + "Edit Message" : { + "comment" : "A label for the view that appears when editing a message.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Vilken modell som helst" + "value" : "Nachricht bearbeiten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cualquier modelo", - "state" : "translated" + "state" : "translated", + "value" : "Επεξεργασία μηνύματος" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Beliebiges Modell" + "value" : "Edit Message" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Qualsiasi modello", - "state" : "translated" + "state" : "translated", + "value" : "Editar mensaje" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Qualquer Modelo", - "state" : "translated" + "state" : "translated", + "value" : "Modifier le message" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Any Model" + "value" : "Modifica messaggio" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Elk model", - "state" : "translated" + "state" : "translated", + "value" : "メッセージを編集" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "N’importe quel modèle", - "state" : "translated" + "state" : "translated", + "value" : "Bericht bewerken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Οποιοδήποτε Μοντέλο", - "state" : "translated" + "state" : "translated", + "value" : "Editar Mensagem" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "任意のモデル", - "state" : "translated" + "state" : "translated", + "value" : "Redigera meddelande" } } - }, - "comment" : "A description of an app feature that allows users to interact with any large language model." + } }, - "Recipe for pasta carbonara" : { + "Edit Tags" : { + "comment" : "A button that opens a sheet for editing a conversation's tags.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Rezept für Pasta Carbonara", - "state" : "translated" + "state" : "translated", + "value" : "Tags bearbeiten" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Receta de pasta carbonara" + "value" : "Επεξεργασία ετικετών" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "パスタカルボナーラのレシピ" + "value" : "Edit Tags" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Ricetta per pasta alla carbonara", - "state" : "translated" + "state" : "translated", + "value" : "Editar etiquetas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Receita de massa carbonara", - "state" : "translated" + "state" : "translated", + "value" : "Modifier les tags" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Recipe for pasta carbonara" + "value" : "Modifica tag" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Recept voor pasta carbonara", - "state" : "translated" + "state" : "translated", + "value" : "タグを編集" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Recette de pâtes à la carbonara", - "state" : "translated" + "state" : "translated", + "value" : "Tags bewerken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Συνταγή για καρμπονάρα ζυμαρικών", - "state" : "translated" + "state" : "translated", + "value" : "Editar Etiquetas" } }, "sv" : { "stringUnit" : { - "value" : "Recept på pasta carbonara", - "state" : "translated" + "state" : "translated", + "value" : "Redigera taggar" } } - }, - "comment" : "Title of a recipe for pasta carbonara." + } }, - "Feature tips can appear again when their conditions are met." : { + "Edit Template" : { + "comment" : "A title for a view that allows the user to edit a prompt template.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Οι συμβουλές λειτουργιών μπορούν να εμφανιστούν ξανά όταν πληρούνται οι προϋποθέσεις τους.", - "state" : "translated" + "state" : "translated", + "value" : "Vorlage bearbeiten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Los consejos de funciones pueden aparecer de nuevo cuando se cumplan sus condiciones.", - "state" : "translated" + "state" : "translated", + "value" : "Επεξεργασία Προτύπου" } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Feature-Tipps können erneut angezeigt werden, wenn ihre Bedingungen erfüllt sind.", - "state" : "translated" + "state" : "translated", + "value" : "Edit Template" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "I suggerimenti delle funzionalità possono riapparire quando si verificano le condizioni.", - "state" : "translated" + "state" : "translated", + "value" : "Editar plantilla" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "As dicas de funcionalidades podem voltar a aparecer quando as suas condições forem cumpridas." + "value" : "Modifier le modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Feature tips can reappear when their conditions are met.", - "state" : "translated" + "state" : "translated", + "value" : "Modifica modello" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Functietips kunnen opnieuw verschijnen wanneer aan de voorwaarden wordt voldaan." + "value" : "テンプレート編集" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Les astuces de fonctionnalité peuvent réapparaître lorsque leurs conditions sont remplies." + "value" : "Sjabloon bewerken" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "条件が満たされると、機能のヒントが再度表示されます。", - "state" : "translated" + "state" : "translated", + "value" : "Editar Modelo" } }, "sv" : { "stringUnit" : { - "value" : "Tips om funktioner kan visas igen när deras villkor uppfylls.", - "state" : "translated" + "state" : "translated", + "value" : "Redigera mall" } } - }, - "comment" : "A message displayed in an alert when the user resets feature tips." + } }, - "tag.JSON.mode" : { - "comment" : "Label for a capability that uses JSON schemas.", + "Email Composer" : { + "comment" : "Name of a prompt template for composing emails.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "JSON Mode" + "value" : "E-Mail-Verfasser" } }, - "en" : { + "el" : { "stringUnit" : { - "value" : "JSON Mode", - "state" : "translated" + "state" : "translated", + "value" : "Σύνθετης Email" } }, - "pt-PT" : { + "en" : { "stringUnit" : { - "value" : "JSON Mode", - "state" : "translated" + "state" : "translated", + "value" : "Email Composer" } }, - "nl" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "JSON Mode" + "value" : "Compositor de correo electrónico" } }, - "ja" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "JSON Mode" + "value" : "Compositeur d’e-mails" } }, - "fr" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "JSON Mode" + "value" : "Compositore Email" } }, - "it" : { + "ja" : { "stringUnit" : { - "value" : "JSON Mode", - "state" : "translated" + "state" : "translated", + "value" : "メール作成ツール" } }, - "sv" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "JSON Mode" + "value" : "E-mailcomposer" } }, - "el" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "JSON Mode" + "value" : "Compositor de Email" } }, - "es" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "JSON Mode" + "value" : "E-postkompositör" } } } }, - "Fetch the list of search tools configured in your LiteLLM server." : { + "Embedding" : { + "comment" : "A label for an LLM model.", + "shouldTranslate" : false + }, + "Enable All Tools" : { + "comment" : "A toggle that enables or disables all tools.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Rufe die Liste der in deinem LiteLLM-Server konfigurierten Suchwerkzeuge ab." + "value" : "Alle Werkzeuge aktivieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Obtener la lista de herramientas de búsqueda configuradas en su servidor LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Ενεργοποίηση όλων των εργαλείων" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ανάκτηση της λίστας εργαλείων αναζήτησης που έχουν ρυθμιστεί στον διακομιστή LiteLLM σας." + "value" : "Enable All Tools" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Recupera l'elenco degli strumenti di ricerca configurati nel tuo server LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Activar todas las herramientas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Obter a lista de ferramentas de pesquisa configuradas no seu servidor LiteLLM." + "value" : "Activer tous les outils" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Fetch the list of search tools configured on your LiteLLM server.", - "state" : "translated" + "state" : "translated", + "value" : "Abilita tutti gli strumenti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Haal de lijst met zoekhulpmiddelen op die zijn geconfigureerd in uw LiteLLM-server.", - "state" : "translated" + "state" : "translated", + "value" : "すべてのツールを有効にする" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Récupérer la liste des outils de recherche configurés sur votre serveur LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Alle tools inschakelen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "LiteLLMサーバーに設定されている検索ツールの一覧を取得します。", - "state" : "translated" + "state" : "translated", + "value" : "Ativar Todas as Ferramentas" } }, "sv" : { "stringUnit" : { - "value" : "Hämta listan över sökverktyg som är konfigurerade i din LiteLLM-server.", - "state" : "translated" + "state" : "translated", + "value" : "Aktivera alla verktyg" } } - }, - "comment" : "A description of the action to fetch the list of search tools." + } }, - "Stop Recording" : { + "Enable Notifications" : { + "comment" : "A button that enables notifications.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Aufnahme stoppen" + "value" : "Benachrichtigungen aktivieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Detener grabación", - "state" : "translated" + "state" : "translated", + "value" : "Ενεργοποίηση ειδοποιήσεων" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Stoppa inspelning" + "value" : "Enable Notifications" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Interrompi registrazione", - "state" : "translated" + "state" : "translated", + "value" : "Activar notificaciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Parar Gravação", - "state" : "translated" + "state" : "translated", + "value" : "Activer les notifications" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Stop Recording" + "value" : "Abilita notifiche" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Opname stoppen", - "state" : "translated" + "state" : "translated", + "value" : "通知を有効にする" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Arrêter l’enregistrement", - "state" : "translated" + "state" : "translated", + "value" : "Meldingen inschakelen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "録音停止", - "state" : "translated" + "state" : "translated", + "value" : "Ativar notificações" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Διακοπή εγγραφής", - "state" : "translated" + "state" : "translated", + "value" : "Aktivera aviseringar" } } } }, - "Open the app from Shortcuts, other apps, or a browser using `openclient:\/\/`." : { + "Enable tools from MCP servers like GitHub, databases, and more to let the model work with external services." : { + "comment" : "A description of a feature that allows the model to connect to external tools.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Άνοιξε την εφαρμογή από Συντομεύσεις, άλλες εφαρμογές ή πρόγραμμα περιήγησης χρησιμοποιώντας `openclient:\/\/`." + "value" : "Aktivieren Sie Werkzeuge von MCP-Servern wie GitHub, Datenbanken und mehr, damit das Modell mit externen Diensten arbeiten kann." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Abre la app desde Atajos, otras apps o un navegador usando `openclient:\/\/`.", - "state" : "translated" + "state" : "translated", + "value" : "Ενεργοποιήστε εργαλεία από διακομιστές MCP όπως το GitHub, βάσεις δεδομένων και άλλα για να επιτρέψετε στο μοντέλο να συνεργάζεται με εξωτερικές υπηρεσίες." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Öppna appen från Genvägar, andra appar eller en webbläsare med `openclient:\/\/`." + "value" : "Enable tools from MCP servers like GitHub, databases, and more to allow the model to work with external services." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apri l’app da Comandi, altre app o un browser usando `openclient:\/\/`.", - "state" : "translated" + "state" : "translated", + "value" : "Habilita herramientas de servidores MCP como GitHub, bases de datos y más para que el modelo trabaje con servicios externos." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Abra a app a partir de Atalhos, outras apps ou um navegador usando `openclient:\/\/`.", - "state" : "translated" + "state" : "translated", + "value" : "Activez les outils des serveurs MCP comme GitHub, les bases de données et plus encore pour permettre au modèle de travailler avec des services externes." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Open the app from Shortcuts, other apps, or a browser using `openclient:\/\/`." + "value" : "Abilita strumenti dai server MCP come GitHub, database e altro per permettere al modello di lavorare con servizi esterni." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Open de app via Opdrachten, andere apps of een browser met `openclient:\/\/`.", - "state" : "translated" + "state" : "translated", + "value" : "GitHubやデータベースなどのMCPサーバーのツールを有効にして、モデルが外部サービスと連携できるようにします。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ouvrez l’application depuis Raccourcis, d’autres applications ou un navigateur en utilisant `openclient:\/\/`.", - "state" : "translated" + "state" : "translated", + "value" : "Schakel tools van MCP-servers in zoals GitHub, databases en meer om het model met externe diensten te laten werken." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ショートカット、他のアプリ、またはブラウザから `openclient:\/\/` を使ってアプリを開く", - "state" : "translated" + "state" : "translated", + "value" : "Ative ferramentas dos servidores MCP como GitHub, bases de dados e mais para permitir que o modelo trabalhe com serviços externos." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Öffnen Sie die App über Kurzbefehle, andere Apps oder einen Browser mit `openclient:\/\/`.", - "state" : "translated" + "state" : "translated", + "value" : "Aktivera verktyg från MCP-servrar som GitHub, databaser med mera för att låta modellen arbeta med externa tjänster." } } } }, - "Color" : { + "Enable Web Search" : { + "comment" : "A label for a button that enables web search.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Χρώμα" + "value" : "Websuche aktivieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Color", - "state" : "translated" + "state" : "translated", + "value" : "Ενεργοποίηση Αναζήτησης Ιστού" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Farbe" + "value" : "Enable Web Search" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Colore", - "state" : "translated" + "state" : "translated", + "value" : "Activar búsqueda web" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Cor", - "state" : "translated" + "state" : "translated", + "value" : "Activer la recherche Web" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Color", - "state" : "translated" + "state" : "translated", + "value" : "Abilita ricerca web" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Kleur", - "state" : "translated" + "state" : "translated", + "value" : "ウェブ検索を有効にする" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Couleur", - "state" : "translated" + "state" : "translated", + "value" : "Webzoekfunctie inschakelen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "色" + "value" : "Ativar Pesquisa Web" } }, "sv" : { "stringUnit" : { - "value" : "Färg", - "state" : "translated" + "state" : "translated", + "value" : "Aktivera webbsökning" } } - }, - "comment" : "A label for the color of a tag." + } }, - "The app opens with a new conversation pre-filled with your content." : { + "Enter a brief title for the issue" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η εφαρμογή ανοίγει με μια νέα συνομιλία προγεμισμένη με το περιεχόμενό σας.", - "state" : "translated" + "state" : "translated", + "value" : "Geben Sie einen kurzen Titel für das Problem ein" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La app se abre con una nueva conversación prellenada con tu contenido.", - "state" : "translated" + "state" : "translated", + "value" : "Εισαγάγετε έναν σύντομο τίτλο για το ζήτημα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Appen öppnas med en ny konversation förifylld med ditt innehåll." + "value" : "Enter a brief title for the issue" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "L’app si apre con una nuova conversazione precompilata con i tuoi contenuti.", - "state" : "translated" + "state" : "translated", + "value" : "Introduce un título breve para el problema" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A app abre com uma nova conversa preenchida com o seu conteúdo.", - "state" : "translated" + "state" : "translated", + "value" : "Entrez un titre bref pour le problème" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The app opens with a new conversation pre-filled with your content.", - "state" : "translated" + "state" : "translated", + "value" : "Inserisci un titolo breve per il problema" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "De app opent met een nieuw gesprek vooraf ingevuld met jouw inhoud." + "value" : "問題の簡単なタイトルを入力してください" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "L’application s’ouvre avec une nouvelle conversation préremplie avec votre contenu." + "value" : "Voer een korte titel voor het probleem in" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アプリはあなたの内容が事前入力された新しい会話で開きます。", - "state" : "translated" + "state" : "translated", + "value" : "Introduza um título breve para o problema" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Die App öffnet sich mit einer neuen Unterhaltung, die mit Ihrem Inhalt vorausgefüllt ist.", - "state" : "translated" + "state" : "translated", + "value" : "Ange en kort titel för problemet" } } } }, - "Use iCloud Data" : { + "Enter a brief title for your suggestion" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Χρήση δεδομένων iCloud" + "value" : "Geben Sie einen kurzen Titel für Ihren Vorschlag ein" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Usar datos de iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Εισαγάγετε έναν σύντομο τίτλο για την πρότασή σας" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud-Daten verwenden" + "value" : "Enter a brief title for your suggestion" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Usa dati iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Introduce un título breve para tu sugerencia" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Usar dados do iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Entrez un titre bref pour votre suggestion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Use iCloud Data", - "state" : "translated" + "state" : "translated", + "value" : "Inserisci un titolo breve per il tuo suggerimento" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Utiliser les données iCloud", - "state" : "translated" + "state" : "translated", + "value" : "提案の簡単なタイトルを入力してください" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruik iCloud-gegevens" + "value" : "Voer een korte titel voor uw suggestie in" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "iCloudデータを使用", - "state" : "translated" + "state" : "translated", + "value" : "Introduza um título breve para a sua sugestão" } }, "sv" : { "stringUnit" : { - "value" : "Använd iCloud-data", - "state" : "translated" + "state" : "translated", + "value" : "Ange en kort titel för ditt förslag" } } - }, - "comment" : "A button that selects iCloud data as the preferred data source." + } }, - "Keep track of context" : { + "Enter a new name for this conversation." : { + "comment" : "A message displayed in an alert when renaming a conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "コンテキストを追跡する", - "state" : "translated" + "state" : "translated", + "value" : "Geben Sie einen neuen Namen für diese Unterhaltung ein." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mantén el seguimiento del contexto", - "state" : "translated" + "state" : "translated", + "value" : "Εισαγάγετε ένα νέο όνομα για αυτή τη συνομιλία." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kontext im Blick behalten" + "value" : "Enter a new name for this conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tieni traccia del contesto", - "state" : "translated" + "state" : "translated", + "value" : "Introduce un nuevo nombre para esta conversación." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Acompanhe o contexto" + "value" : "Entrez un nouveau nom pour cette conversation." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Keep track of context" + "value" : "Inserisci un nuovo nome per questa conversazione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Houd de context bij", - "state" : "translated" + "state" : "translated", + "value" : "この会話の新しい名前を入力してください" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Suivez le contexte", - "state" : "translated" + "state" : "translated", + "value" : "Voer een nieuwe naam in voor dit gesprek." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Παρακολουθήστε το πλαίσιο", - "state" : "translated" + "state" : "translated", + "value" : "Introduza um novo nome para esta conversa." } }, "sv" : { "stringUnit" : { - "value" : "Håll koll på sammanhanget", - "state" : "translated" + "state" : "translated", + "value" : "Ange ett nytt namn för den här konversationen." } } - }, - "comment" : "A tip that explains how OpenClient may summarise or exclude older messages without removing them from your history." + } }, - "The server returned an invalid response." : { + "Enter a positive whole number of input tokens." : { + "comment" : "A description of the input tokens field.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Servern returnerade ett ogiltigt svar." + "value" : "Geben Sie eine positive ganze Zahl der Eingabetoken ein." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El servidor devolvió una respuesta no válida.", - "state" : "translated" + "state" : "translated", + "value" : "Εισάγετε έναν θετικό ακέραιο αριθμό εισόδων." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーが無効な応答を返しました。" + "value" : "Enter a positive integer number of input tokens" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il server ha restituito una risposta non valida." + "value" : "Introduce un número entero positivo de tokens de entrada." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O servidor devolveu uma resposta inválida.", - "state" : "translated" + "state" : "translated", + "value" : "Entrez un nombre entier positif de jetons d’entrée." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The server returned an invalid response.", - "state" : "translated" + "state" : "translated", + "value" : "Inserisci un numero intero positivo di token di input." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De server gaf een ongeldige reactie terug.", - "state" : "translated" + "state" : "translated", + "value" : "正の整数の入力トークン数を入力してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Le serveur a renvoyé une réponse invalide.", - "state" : "translated" + "state" : "translated", + "value" : "Voer een positief geheel aantal invoertokens in." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ο διακομιστής επέστρεψε μη έγκυρη απάντηση.", - "state" : "translated" + "state" : "translated", + "value" : "Introduza um número inteiro positivo de tokens de entrada." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Der Server hat eine ungültige Antwort zurückgegeben.", - "state" : "translated" + "state" : "translated", + "value" : "Ange ett positivt heltal för inmatningstoken." } } } }, - "Add things you want the assistant to remember across all conversations." : { + "Enter a URL such as `openclient://chat?text=Summarise this`." : { + "comment" : "Step 3 in the process of creating a shortcut to open the OpenClient app with a specific URL.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "アシスタントにすべての会話で記憶してほしい内容を追加してください", - "state" : "translated" + "state" : "translated", + "value" : "Geben Sie eine URL ein, z. B. `openclient://chat?text=Summarise this`." } }, - "sv" : { + "el" : { "stringUnit" : { - "value" : "Lägg till saker du vill att assistenten ska komma ihåg i alla konversationer.", - "state" : "translated" + "state" : "translated", + "value" : "Εισαγάγετε μια διεύθυνση URL όπως `openclient://chat?text=Summarise this`" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Agrega cosas que quieres que el asistente recuerde en todas las conversaciones." + "value" : "Enter a URL such as `openclient://chat?text=Summarise this`" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiungi elementi che vuoi che l’assistente ricordi in tutte le conversazioni.", - "state" : "translated" + "state" : "translated", + "value" : "Introduce una URL como `openclient://chat?text=Summarise this`." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Adicione coisas que pretende que o assistente lembre em todas as conversas.", - "state" : "translated" + "state" : "translated", + "value" : "Entrez une URL telle que `openclient://chat?text=Summarise this`." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Add items you want the assistant to remember across all conversations" + "value" : "Inserisci un URL come `openclient://chat?text=Summarise this`" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Ajoutez des éléments que vous souhaitez que l’assistant retienne dans toutes les conversations.", - "state" : "translated" + "state" : "translated", + "value" : "`openclient://chat?text=Summarise this` のようなURLを入力してください" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voeg dingen toe die de assistent in alle gesprekken moet onthouden." + "value" : "Voer een URL in zoals `openclient://chat?text=Summarise this`." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Fügen Sie Dinge hinzu, an die sich der Assistent in allen Gesprächen erinnern soll.", - "state" : "translated" + "state" : "translated", + "value" : "Introduza um URL como `openclient://chat?text=Summarise this`" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Προσθέστε πράγματα που θέλετε ο βοηθός να θυμάται σε όλες τις συνομιλίες.", - "state" : "translated" + "state" : "translated", + "value" : "Ange en URL som `openclient://chat?text=Summarise this`" } } - }, - "comment" : "A description of the feature that allows the user to add items to their memory." + } }, - "The backup contains an invalid attachment reference." : { + "Enter your LiteLLM proxy URL, the gateway to any AI model." : { + "comment" : "A description of the purpose of the server URL field.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "バックアップに無効な添付ファイル参照が含まれています。", - "state" : "translated" + "state" : "translated", + "value" : "Geben Sie Ihre LiteLLM-Proxy-URL ein, das Tor zu jedem KI-Modell." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La copia de seguridad contiene una referencia de archivo adjunto no válida.", - "state" : "translated" + "state" : "translated", + "value" : "Εισαγάγετε το URL διακομιστή μεσολάβησης LiteLLM, την πύλη σε οποιοδήποτε μοντέλο AI." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Die Sicherung enthält eine ungültige Anlagenreferenz." + "value" : "Enter your LiteLLM proxy URL, the gateway to any AI model." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il backup contiene un riferimento a un allegato non valido.", - "state" : "translated" + "state" : "translated", + "value" : "Introduce la URL de tu proxy LiteLLM, la puerta de acceso a cualquier modelo de IA." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A cópia de segurança contém uma referência de anexo inválida." + "value" : "Entrez l’URL de votre proxy LiteLLM, la passerelle vers n’importe quel modèle d’IA." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The backup contains an invalid attachment reference." + "value" : "Inserisci l’URL del proxy LiteLLM, il gateway per qualsiasi modello AI." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De back-up bevat een ongeldige bijlageverwijzing.", - "state" : "translated" + "state" : "translated", + "value" : "LiteLLMプロキシURLを入力してください。これはあらゆるAIモデルへのゲートウェイです。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "La sauvegarde contient une référence de pièce jointe invalide.", - "state" : "translated" + "state" : "translated", + "value" : "Voer uw LiteLLM-proxy-URL in, de toegangspoort tot elk AI-model." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Η δημιουργία αντιγράφου περιέχει μη έγκυρη αναφορά συνημμένου.", - "state" : "translated" + "state" : "translated", + "value" : "Introduza a URL do seu proxy LiteLLM, a porta de entrada para qualquer modelo de IA." } }, "sv" : { "stringUnit" : { - "value" : "Säkerhetskopian innehåller en ogiltig bilagereferens.", - "state" : "translated" + "state" : "translated", + "value" : "Ange din LiteLLM-proxy-URL, porten till vilken AI-modell som helst." } } } }, - "Tagged Conversations" : { + "Enter your name" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "タグ付き会話", - "state" : "translated" + "state" : "translated", + "value" : "Gib deinen Namen ein" } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Markierte Unterhaltungen" + "value" : "Εισάγετε το όνομά σας" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Conversaciones Etiquetadas" + "value" : "Enter your name" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Conversazioni taggate", - "state" : "translated" + "state" : "translated", + "value" : "Introduce tu nombre" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Conversas Marcadas", - "state" : "translated" + "state" : "translated", + "value" : "Entrez votre nom" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Tagged Conversations", - "state" : "translated" + "state" : "translated", + "value" : "Inserisci il tuo nome" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gemerkt Gesprekken", - "state" : "translated" + "state" : "translated", + "value" : "名前を入力してください" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Conversations étiquetées" + "value" : "Voer uw naam in" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επισημασμένες Συνομιλίες", - "state" : "translated" + "state" : "translated", + "value" : "Introduza o seu nome" } }, "sv" : { "stringUnit" : { - "value" : "Taggade konversationer", - "state" : "translated" + "state" : "translated", + "value" : "Ange ditt namn" } } - }, - "comment" : "Title of the widget configuration intent." + } }, - "The agent timed out before completing the response." : { + "Error" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ο πράκτορας διέκοψε τη σύνδεση πριν ολοκληρώσει την απάντηση.", - "state" : "translated" + "state" : "translated", + "value" : "Fehler" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Der Agent hat die Antwort nicht rechtzeitig abgeschlossen.", - "state" : "translated" + "state" : "translated", + "value" : "Σφάλμα" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "El agente agotó el tiempo antes de completar la respuesta.", - "state" : "translated" + "state" : "translated", + "value" : "Error" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "L'agente ha superato il tempo limite prima di completare la risposta.", - "state" : "translated" + "state" : "translated", + "value" : "Error" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O agente expirou antes de concluir a resposta." + "value" : "Erreur" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The agent timed out before completing the response." + "value" : "Errore" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De agent heeft te lang gewacht om de reactie te voltooien.", - "state" : "translated" + "state" : "translated", + "value" : "エラー" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Le délai de réponse de l’agent a expiré avant la fin." + "value" : "Fout" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "エージェントが応答を完了する前にタイムアウトしました。", - "state" : "translated" + "state" : "translated", + "value" : "Erro" } }, "sv" : { "stringUnit" : { - "value" : "Agenten tog för lång tid på sig att slutföra svaret.", - "state" : "translated" + "state" : "translated", + "value" : "Fel" } } } }, - "You are a professional translator. Translate the user's text accurately while preserving the original meaning, tone, and nuance. Identify the source language automatically and ask for the target language if not specified." : { + "Estimated context" : { + "comment" : "A label that describes the context usage.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Είστε επαγγελματίας μεταφραστής. Μεταφράστε το κείμενο του χρήστη με ακρίβεια διατηρώντας το αρχικό νόημα, τόνο και αποχρώσεις. Αναγνωρίστε αυτόματα τη γλώσσα προέλευσης και ζητήστε τη γλώσσα στόχο αν δεν έχει καθοριστεί.", - "state" : "translated" + "state" : "translated", + "value" : "Geschätzter Kontext" } }, - "ja" : { + "el" : { "stringUnit" : { - "value" : "あなたはプロの翻訳者です。元の意味、トーン、ニュアンスを保ちながら、ユーザーのテキストを正確に翻訳してください。ソース言語を自動的に識別し、ターゲット言語が指定されていない場合は尋ねてください。", - "state" : "translated" + "state" : "translated", + "value" : "Εκτιμώμενο πλαίσιο" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Eres un traductor profesional. Traduce el texto del usuario con precisión, preservando el significado, tono y matiz originales. Identifica automáticamente el idioma de origen y solicita el idioma de destino si no está especificado." + "value" : "Estimated context" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sei un traduttore professionista. Traduci accuratamente il testo dell'utente preservando il significato, il tono e le sfumature originali. Identifica automaticamente la lingua di origine e chiedi la lingua di destinazione se non specificata.", - "state" : "translated" + "state" : "translated", + "value" : "Contexto estimado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "És um tradutor profissional. Traduz o texto do utilizador com precisão, preservando o significado, tom e nuances originais. Identifica automaticamente a língua de origem e pergunta pela língua de destino se não estiver especificada.", - "state" : "translated" + "state" : "translated", + "value" : "Contexte estimé" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "You are a professional translator. Translate the user's text accurately while preserving the original meaning, tone, and nuance. Identify the source language automatically and ask for the target language if not specified.", - "state" : "translated" + "state" : "translated", + "value" : "Contesto stimato" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Vous êtes un traducteur professionnel. Traduisez le texte de l'utilisateur avec précision tout en préservant le sens, le ton et la nuance originaux. Identifiez automatiquement la langue source et demandez la langue cible si elle n'est pas spécifiée." + "value" : "推定コンテキスト" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Je bent een professionele vertaler. Vertaal de tekst van de gebruiker nauwkeurig en behoud de oorspronkelijke betekenis, toon en nuance. Identificeer automatisch de brontaal en vraag om de doeltaal als deze niet is opgegeven." + "value" : "Geschatte context" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Sie sind ein professioneller Übersetzer. Übersetzen Sie den Text des Benutzers genau und bewahren Sie dabei die ursprüngliche Bedeutung, den Ton und die Nuancen. Erkennen Sie die Ausgangssprache automatisch und fragen Sie nach der Zielsprache, falls diese nicht angegeben ist.", - "state" : "translated" + "state" : "translated", + "value" : "Contexto estimado" } }, "sv" : { "stringUnit" : { - "value" : "Du är en professionell översättare. Översätt användarens text noggrant samtidigt som du bevarar den ursprungliga betydelsen, tonen och nyansen. Identifiera källspråket automatiskt och fråga efter målspråket om det inte är angivet.", - "state" : "translated" + "state" : "translated", + "value" : "Uppskattad kontext" } } - }, - "comment" : "Content of the \"Translator\" built-in template." + } }, - "%lld attachment(s)" : { + "Estimated cost" : { + "comment" : "A label for the estimated cost of a conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "%lld 件の添付ファイル" + "value" : "Geschätzte Kosten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%lld archivo(s) adjunto(s)", - "state" : "translated" + "state" : "translated", + "value" : "Εκτιμώμενο κόστος" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%lld συνημμένο(α)" + "value" : "Estimated cost" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "%lld allegato(i)", - "state" : "translated" + "state" : "translated", + "value" : "Costo estimado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%lld anexo(s)" + "value" : "Coût estimé" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%lld attachment(s)", - "state" : "translated" + "state" : "translated", + "value" : "Costo stimato" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "%lld pièce(s) jointe(s)", - "state" : "translated" + "state" : "translated", + "value" : "推定費用" } }, "nl" : { "stringUnit" : { - "value" : "%lld bijlage(n)", - "state" : "translated" + "state" : "translated", + "value" : "Geschatte kosten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "%lld Anhang\/Anhänge", - "state" : "translated" + "state" : "translated", + "value" : "Custo estimado" } }, "sv" : { "stringUnit" : { - "value" : "%lld bilaga(or)", - "state" : "translated" + "state" : "translated", + "value" : "Beräknad kostnad" } } - }, - "comment" : "A label that shows the number of attachments and a paperclip icon." + } }, - "Yellow" : { + "Existing tags keep their assigned color." : { + "comment" : "A description of the behavior of existing tags.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Gul" + "value" : "Vorhandene Tags behalten ihre zugewiesene Farbe." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Amarillo", - "state" : "translated" + "state" : "translated", + "value" : "Οι υπάρχες ετικέτες διατηρούν το εκχωρημένο τους χρώμα." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Gelb" + "value" : "Existing tags keep their assigned color" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Giallo", - "state" : "translated" + "state" : "translated", + "value" : "Las etiquetas existentes mantienen su color asignado." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Amarelo" + "value" : "Les tags existants conservent leur couleur attribuée." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Yellow", - "state" : "translated" + "state" : "translated", + "value" : "I tag esistenti mantengono il colore assegnato." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geel", - "state" : "translated" + "state" : "translated", + "value" : "既存のタグは割り当てられた色を保持します。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Jaune", - "state" : "translated" + "state" : "translated", + "value" : "Bestaande tags behouden hun toegewezen kleur." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Κίτρινο", - "state" : "translated" + "state" : "translated", + "value" : "As etiquetas existentes mantêm a sua cor atribuída." } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "黄色", - "state" : "translated" + "state" : "translated", + "value" : "Befintliga taggar behåller sin tilldelade färg." } } - }, - "comment" : "Name of the color yellow." + } }, - "Send File to Chat" : { + "Explain a complex topic simply" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Datei an Chat senden", - "state" : "translated" + "state" : "translated", + "value" : "Erkläre ein komplexes Thema einfach" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Enviar archivo al chat", - "state" : "translated" + "state" : "translated", + "value" : "Εξήγησε ένα σύνθετο θέμα απλά" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ファイルをチャットに送信" + "value" : "Explain a complex topic simply" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Invia file alla chat" + "value" : "Explica un tema complejo de forma sencilla" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Enviar ficheiro para o chat" + "value" : "Expliquer un sujet complexe simplement" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Send File to Chat", - "state" : "translated" + "state" : "translated", + "value" : "Spiega un argomento complesso in modo semplice" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bestand naar chat verzenden", - "state" : "translated" + "state" : "translated", + "value" : "複雑な話題を簡単に説明する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Envoyer le fichier au chat", - "state" : "translated" + "state" : "translated", + "value" : "Leg een complex onderwerp eenvoudig uit" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αποστολή αρχείου στη συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Explique um tema complexo de forma simples" } }, "sv" : { "stringUnit" : { - "value" : "Skicka fil till chatt", - "state" : "translated" + "state" : "translated", + "value" : "Förklara ett komplext ämne enkelt" } } } }, - "Update" : { + "Explain quantum entanglement" : { + "comment" : "Title of a conversation.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ενημέρωση" + "value" : "Quantenverschränkung erklären" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Actualizar", - "state" : "translated" + "state" : "translated", + "value" : "Εξήγηση της κβαντικής εμπλοκής" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Aktualisieren" + "value" : "Explain quantum entanglement" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiorna", - "state" : "translated" + "state" : "translated", + "value" : "Explicar el entrelazamiento cuántico" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Atualizar", - "state" : "translated" + "state" : "translated", + "value" : "Expliquer l’intrication quantique" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Update", - "state" : "translated" + "state" : "translated", + "value" : "Spiegare l’entanglement quantistico" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Mettre à jour", - "state" : "translated" + "state" : "translated", + "value" : "量子もつれについて説明する" } }, "nl" : { "stringUnit" : { - "value" : "Bijwerken", - "state" : "translated" + "state" : "translated", + "value" : "Leg kwantumverstrengeling uit" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "アップデート" + "value" : "Explicar o entrelaçamento quântico" } }, "sv" : { "stringUnit" : { - "value" : "Uppdatera", - "state" : "translated" + "state" : "translated", + "value" : "Förklara kvantintrassling" } } - }, - "comment" : "A button that updates the app." + } }, - "Optional" : { + "Explain why this feature would be useful" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "任意", - "state" : "translated" + "state" : "translated", + "value" : "Erklären Sie, warum diese Funktion nützlich wäre" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Opcional", - "state" : "translated" + "state" : "translated", + "value" : "Εξηγήστε γιατί αυτή η λειτουργία θα ήταν χρήσιμη" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Προαιρετικό" + "value" : "Explain why this feature would be useful" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Opzionale" + "value" : "Explica por qué esta función sería útil" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Opcional", - "state" : "translated" + "state" : "translated", + "value" : "Expliquez pourquoi cette fonctionnalité serait utile" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Optional" + "value" : "Spiega perché questa funzione sarebbe utile" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Optionnel", - "state" : "translated" + "state" : "translated", + "value" : "この機能が役立つ理由を説明してください" } }, "nl" : { "stringUnit" : { - "value" : "Optioneel", - "state" : "translated" + "state" : "translated", + "value" : "Leg uit waarom deze functie nuttig zou zijn" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Optional", - "state" : "translated" + "state" : "translated", + "value" : "Explique por que esta funcionalidade seria útil" } }, "sv" : { "stringUnit" : { - "value" : "Valfri", - "state" : "translated" + "state" : "translated", + "value" : "Förklara varför denna funktion skulle vara användbar" } } } }, - "The server URL is not valid." : { + "Export" : { + "comment" : "A label for exporting a conversation.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Η διεύθυνση URL του διακομιστή δεν είναι έγκυρη." + "value" : "Exportieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La URL del servidor no es válida.", - "state" : "translated" + "state" : "translated", + "value" : "Εξαγωγή" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Serverns URL är inte giltig." + "value" : "Export" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "L'URL del server non è valido.", - "state" : "translated" + "state" : "translated", + "value" : "Exportar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O URL do servidor não é válido." + "value" : "Exporter" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The server URL is not valid.", - "state" : "translated" + "state" : "translated", + "value" : "Esporta" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De server-URL is niet geldig.", - "state" : "translated" + "state" : "translated", + "value" : "エクスポート" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "L’URL du serveur n’est pas valide.", - "state" : "translated" + "state" : "translated", + "value" : "Exporteren" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバーのURLが無効です。", - "state" : "translated" + "state" : "translated", + "value" : "Exportar" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Die Server-URL ist ungültig.", - "state" : "translated" + "state" : "translated", + "value" : "Exportera" } } } }, - "Find conversation settings, favourites, files, and export options in this menu." : { + "Export Backup" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Βρείτε τις ρυθμίσεις συνομιλίας, τα αγαπημένα, τα αρχεία και τις επιλογές εξαγωγής σε αυτό το μενού.", - "state" : "translated" + "state" : "translated", + "value" : "Backup exportieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Encuentra la configuración de conversación, favoritos, archivos y opciones de exportación en este menú.", - "state" : "translated" + "state" : "translated", + "value" : "Εξαγωγή αντιγράφου ασφαλείας" } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Finde Konversationseinstellungen, Favoriten, Dateien und Exportoptionen in diesem Menü.", - "state" : "translated" + "state" : "translated", + "value" : "Export Backup" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Trova impostazioni della conversazione, preferiti, file e opzioni di esportazione in questo menu.", - "state" : "translated" + "state" : "translated", + "value" : "Exportar copia de seguridad" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Encontre definições de conversa, favoritos, ficheiros e opções de exportação neste menu." + "value" : "Exporter la sauvegarde" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Find conversation settings, favorites, files, and export options in this menu.", - "state" : "translated" + "state" : "translated", + "value" : "Esporta backup" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Trouvez les paramètres de conversation, favoris, fichiers et options d’exportation dans ce menu." + "value" : "バックアップをエクスポート" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vind gespreksinstellingen, favorieten, bestanden en exportopties in dit menu." + "value" : "Back-up exporteren" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "このメニューで会話設定、お気に入り、ファイル、エクスポートオプションを見つけられます。", - "state" : "translated" + "state" : "translated", + "value" : "Exportar Cópia de Segurança" } }, "sv" : { "stringUnit" : { - "value" : "Hitta konversationsinställningar, favoriter, filer och exportalternativ i den här menyn.", - "state" : "translated" + "state" : "translated", + "value" : "Exportera säkerhetskopia" } } - }, - "comment" : "A description of the chat options tip." + } }, - "Show Feature Tips Again" : { + "Extra Info" : { + "comment" : "A label displayed above the user's extra information.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "機能のヒントを再表示する" + "value" : "Zusätzliche Informationen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mostrar consejos de funciones nuevamente", - "state" : "translated" + "state" : "translated", + "value" : "Επιπλέον Πληροφορίες" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Funktionstipps erneut anzeigen" + "value" : "Extra Info" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Mostra di nuovo i suggerimenti sulle funzionalità", - "state" : "translated" + "state" : "translated", + "value" : "Información adicional" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Mostrar Dicas de Funcionalidades Novamente", - "state" : "translated" + "state" : "translated", + "value" : "Infos supplémentaires" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Show Feature Tips Again", - "state" : "translated" + "state" : "translated", + "value" : "Informazioni aggiuntive" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Afficher à nouveau les astuces de fonctionnalité", - "state" : "translated" + "state" : "translated", + "value" : "追加情報" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Toon functietips opnieuw" + "value" : "Extra info" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εμφάνιση συμβουλών λειτουργίας ξανά", - "state" : "translated" + "state" : "translated", + "value" : "Informação Extra" } }, "sv" : { "stringUnit" : { - "value" : "Visa tips om funktioner igen", - "state" : "translated" + "state" : "translated", + "value" : "Extra information" } } - }, - "comment" : "A button that shows the feature tips again." + } }, - "This information is added to every conversation so models can personalise their responses." : { + "Favourites" : { + "comment" : "A title for a screen that shows the user's favourite messages.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "この情報は、モデルが応答をパーソナライズできるように、すべての会話に追加されます。" + "value" : "Favoriten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Esta información se añade a cada conversación para que los modelos puedan personalizar sus respuestas.", - "state" : "translated" + "state" : "translated", + "value" : "Αγαπημένα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Denna information läggs till i varje konversation så att modeller kan anpassa sina svar." + "value" : "Favorites" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Queste informazioni vengono aggiunte a ogni conversazione affinché i modelli possano personalizzare le loro risposte." + "value" : "Favoritos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Esta informação é adicionada a cada conversa para que os modelos possam personalizar as suas respostas.", - "state" : "translated" + "state" : "translated", + "value" : "Favoris" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "This information is added to every conversation so models can personalize their responses.", - "state" : "translated" + "state" : "translated", + "value" : "Preferiti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Deze informatie wordt aan elk gesprek toegevoegd zodat modellen hun antwoorden kunnen personaliseren.", - "state" : "translated" + "state" : "translated", + "value" : "お気に入り" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ces informations sont ajoutées à chaque conversation pour que les modèles puissent personnaliser leurs réponses.", - "state" : "translated" + "state" : "translated", + "value" : "Favorieten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Diese Informationen werden jeder Unterhaltung hinzugefügt, damit Modelle ihre Antworten personalisieren können.", - "state" : "translated" + "state" : "translated", + "value" : "Favoritos" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αυτές οι πληροφορίες προστίθενται σε κάθε συνομιλία ώστε τα μοντέλα να προσωποποιούν τις απαντήσεις τους.", - "state" : "translated" + "state" : "translated", + "value" : "Favoriter" } } - }, - "comment" : "A description of the information that is added to every conversation." + } }, - "Blue" : { + "Feature Tips" : { + "comment" : "A section that allows users to dismiss feature tips.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Μπλε" + "value" : "Funktionstipps" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Azul", - "state" : "translated" + "state" : "translated", + "value" : "Συμβουλές λειτουργιών" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Blå" + "value" : "Feature Tips" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Blu", - "state" : "translated" + "state" : "translated", + "value" : "Consejos de funciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Azul", - "state" : "translated" + "state" : "translated", + "value" : "Conseils sur les fonctionnalités" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Blue" + "value" : "Suggerimenti sulle funzionalità" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Blauw", - "state" : "translated" + "state" : "translated", + "value" : "機能のヒント" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Bleu", - "state" : "translated" + "state" : "translated", + "value" : "Functietips" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Blau", - "state" : "translated" + "state" : "translated", + "value" : "Dicas de Funcionalidades" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "青", - "state" : "translated" + "state" : "translated", + "value" : "Funktionstips" } } - }, - "comment" : "Name of the color blue." + } }, - "API Key (Optional)" : { + "Feature tips can appear again when their conditions are met." : { + "comment" : "A message displayed in an alert when the user resets feature tips.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "APIキー(任意)" + "value" : "Feature-Tipps können erneut angezeigt werden, wenn ihre Bedingungen erfüllt sind." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Clave API (Opcional)", - "state" : "translated" + "state" : "translated", + "value" : "Οι συμβουλές λειτουργιών μπορούν να εμφανιστούν ξανά όταν πληρούνται οι προϋποθέσεις τους." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Κλειδί API (Προαιρετικό)" + "value" : "Feature tips can reappear when their conditions are met." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Chiave API (Opzionale)", - "state" : "translated" + "state" : "translated", + "value" : "Los consejos de funciones pueden aparecer de nuevo cuando se cumplan sus condiciones." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Chave API (Opcional)", - "state" : "translated" + "state" : "translated", + "value" : "Les astuces de fonctionnalité peuvent réapparaître lorsque leurs conditions sont remplies." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "API Key (Optional)", - "state" : "translated" + "state" : "translated", + "value" : "I suggerimenti delle funzionalità possono riapparire quando si verificano le condizioni." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "API-sleutel (optioneel)", - "state" : "translated" + "state" : "translated", + "value" : "条件が満たされると、機能のヒントが再度表示されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Clé API (facultatif)" + "value" : "Functietips kunnen opnieuw verschijnen wanneer aan de voorwaarden wordt voldaan." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "API-Schlüssel (optional)", - "state" : "translated" + "state" : "translated", + "value" : "As dicas de funcionalidades podem voltar a aparecer quando as suas condições forem cumpridas." } }, "sv" : { "stringUnit" : { - "value" : "API-nyckel (valfritt)", - "state" : "translated" + "state" : "translated", + "value" : "Tips om funktioner kan visas igen när deras villkor uppfylls." } } } }, - "Back" : { + "Feature Tips Reset" : { + "comment" : "A title for an alert that informs the user that the feature tips have been reset.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Zurück", - "state" : "translated" + "state" : "translated", + "value" : "Feature-Tipps zurücksetzen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Atrás", - "state" : "translated" + "state" : "translated", + "value" : "Επαναφορά Συμβουλών Χαρακτηριστικών" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tillbaka" + "value" : "Feature Tips Reset" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Indietro" + "value" : "Restablecer consejos de funciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Voltar", - "state" : "translated" + "state" : "translated", + "value" : "Réinitialisation des astuces de fonctionnalité" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Back", - "state" : "translated" + "state" : "translated", + "value" : "Suggerimenti Funzionalità Reimpostati" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Terug", - "state" : "translated" + "state" : "translated", + "value" : "機能ヒントのリセット" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Retour" + "value" : "Functietips resetten" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πίσω", - "state" : "translated" + "state" : "translated", + "value" : "Repor Dicas de Funcionalidades" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "戻る", - "state" : "translated" + "state" : "translated", + "value" : "Återställ tips för funktioner" } } } }, - "iCloud is downloading changes. Sync will continue automatically." : { + "Fetch the list of search tools configured in your LiteLLM server." : { + "comment" : "A description of the action to fetch the list of search tools.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το iCloud λαμβάνει αλλαγές. Ο συγχρονισμός θα συνεχιστεί αυτόματα.", - "state" : "translated" + "state" : "translated", + "value" : "Rufe die Liste der in deinem LiteLLM-Server konfigurierten Suchwerkzeuge ab." } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "iCloud lädt Änderungen herunter. Die Synchronisierung wird automatisch fortgesetzt.", - "state" : "translated" + "state" : "translated", + "value" : "Ανάκτηση της λίστας εργαλείων αναζήτησης που έχουν ρυθμιστεί στον διακομιστή LiteLLM σας." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud está descargando cambios. La sincronización continuará automáticamente." + "value" : "Fetch the list of search tools configured on your LiteLLM server." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud sta ricevendo le modifiche. La sincronizzazione continuerà automaticamente." + "value" : "Obtener la lista de herramientas de búsqueda configuradas en su servidor LiteLLM." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O iCloud está a transferir alterações. A sincronização continuará automaticamente.", - "state" : "translated" + "state" : "translated", + "value" : "Récupérer la liste des outils de recherche configurés sur votre serveur LiteLLM." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "iCloud is downloading changes. Sync will continue automatically.", - "state" : "translated" + "state" : "translated", + "value" : "Recupera l'elenco degli strumenti di ricerca configurati nel tuo server LiteLLM." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "iCloud is wijzigingen aan het ontvangen. Synchronisatie gaat automatisch door.", - "state" : "translated" + "state" : "translated", + "value" : "LiteLLMサーバーに設定されている検索ツールの一覧を取得します。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud télécharge les modifications. La synchronisation se poursuivra automatiquement." + "value" : "Haal de lijst met zoekhulpmiddelen op die zijn geconfigureerd in uw LiteLLM-server." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "iCloudが変更を受信しています。同期は自動的に続行されます。", - "state" : "translated" + "state" : "translated", + "value" : "Obter a lista de ferramentas de pesquisa configuradas no seu servidor LiteLLM." } }, "sv" : { "stringUnit" : { - "value" : "iCloud tar emot ändringar. Synkroniseringen fortsätter automatiskt.", - "state" : "translated" + "state" : "translated", + "value" : "Hämta listan över sökverktyg som är konfigurerade i din LiteLLM-server." } } - }, - "comment" : "A footer for the iCloud sync section." + } }, - "Extra Info" : { + "File" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Zusätzliche Informationen" + "value" : "Datei" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Información adicional" + "value" : "Αρχείο" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Extra information" + "value" : "File" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Informazioni aggiuntive", - "state" : "translated" + "state" : "translated", + "value" : "Archivo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Informação Extra", - "state" : "translated" + "state" : "translated", + "value" : "Fichier" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Extra Info", - "state" : "translated" + "state" : "translated", + "value" : "File" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Extra info", - "state" : "translated" + "state" : "translated", + "value" : "ファイル" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Infos supplémentaires", - "state" : "translated" + "state" : "translated", + "value" : "Bestand" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "追加情報", - "state" : "translated" + "state" : "translated", + "value" : "Ficheiro" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Επιπλέον Πληροφορίες", - "state" : "translated" + "state" : "translated", + "value" : "Fil" } } - }, - "comment" : "A label displayed above the user's extra information." + } }, - "Post" : { + "Find a conversation" : { + "comment" : "Text displayed in a shortcut item for searching conversations.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "投稿" + "value" : "Konversation finden" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Publicar" + "value" : "Βρες μια συνομιλία" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ανάρτηση" + "value" : "Find a conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Pubblica", - "state" : "translated" + "state" : "translated", + "value" : "Buscar una conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Publicar", - "state" : "translated" + "state" : "translated", + "value" : "Trouver une conversation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Post", - "state" : "translated" + "state" : "translated", + "value" : "Trova una conversazione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Plaatsen", - "state" : "translated" + "state" : "translated", + "value" : "会話を検索" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Publier", - "state" : "translated" + "state" : "translated", + "value" : "Zoek een gesprek" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Beitrag", - "state" : "translated" + "state" : "translated", + "value" : "Encontrar uma conversa" } }, "sv" : { "stringUnit" : { - "value" : "Inlägg", - "state" : "translated" + "state" : "translated", + "value" : "Hitta en konversation" } } } }, - "You'll need eggs, guanciale, Pecorino Romano..." : { + "Find conversation settings, favourites, files, and export options in this menu." : { + "comment" : "A description of the chat options tip.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Du brauchst Eier, Guanciale, Pecorino Romano...", - "state" : "translated" + "state" : "translated", + "value" : "Finde Konversationseinstellungen, Favoriten, Dateien und Exportoptionen in diesem Menü." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Necesitarás huevos, guanciale, Pecorino Romano...", - "state" : "translated" + "state" : "translated", + "value" : "Βρείτε τις ρυθμίσεις συνομιλίας, τα αγαπημένα, τα αρχεία και τις επιλογές εξαγωγής σε αυτό το μενού." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Du behöver ägg, guanciale, Pecorino Romano..." + "value" : "Find conversation settings, favorites, files, and export options in this menu." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Ti serviranno uova, guanciale, Pecorino Romano...", - "state" : "translated" + "state" : "translated", + "value" : "Encuentra la configuración de conversación, favoritos, archivos y opciones de exportación en este menú." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Vai precisar de ovos, guanciale, Pecorino Romano..." + "value" : "Trouvez les paramètres de conversation, favoris, fichiers et options d’exportation dans ce menu." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "You'll need eggs, guanciale, Pecorino Romano..." + "value" : "Trova impostazioni della conversazione, preferiti, file e opzioni di esportazione in questo menu." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je hebt eieren, guanciale, Pecorino Romano nodig...", - "state" : "translated" + "state" : "translated", + "value" : "このメニューで会話設定、お気に入り、ファイル、エクスポートオプションを見つけられます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Vous aurez besoin d'œufs, de guanciale, de Pecorino Romano...", - "state" : "translated" + "state" : "translated", + "value" : "Vind gespreksinstellingen, favorieten, bestanden en exportopties in dit menu." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Θα χρειαστείς αυγά, γκουαντσιάλε, Πεκορίνο Ρομάνο...", - "state" : "translated" + "state" : "translated", + "value" : "Encontre definições de conversa, favoritos, ficheiros e opções de exportação neste menu." } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "卵、グアンチャーレ、ペコリーノ・ロマーノが必要です...", - "state" : "translated" + "state" : "translated", + "value" : "Hitta konversationsinställningar, favoriter, filer och exportalternativ i den här menyn." } } - }, - "comment" : "Last message preview text in a conversation widget." + } }, - "Feature Tips" : { + "Find past conversations" : { + "comment" : "Subtitle for the \"Search\" action button in the Quick Actions widget.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Funktionstips" + "value" : "Vergangene Unterhaltungen finden" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "機能のヒント" + "value" : "Βρείτε προηγούμενες συνομιλίες" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Consejos de funciones" + "value" : "Find past conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Suggerimenti sulle funzionalità", - "state" : "translated" + "state" : "translated", + "value" : "Buscar conversaciones pasadas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Dicas de Funcionalidades", - "state" : "translated" + "state" : "translated", + "value" : "Rechercher des conversations passées" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Feature Tips", - "state" : "translated" + "state" : "translated", + "value" : "Trova conversazioni passate" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Functietips", - "state" : "translated" + "state" : "translated", + "value" : "過去の会話を検索" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Conseils sur les fonctionnalités", - "state" : "translated" + "state" : "translated", + "value" : "Vind eerdere gesprekken" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Funktionstipps", - "state" : "translated" + "state" : "translated", + "value" : "Encontrar conversas anteriores" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Συμβουλές λειτουργιών", - "state" : "translated" + "state" : "translated", + "value" : "Hitta tidigare konversationer" } } - }, - "comment" : "A section that allows users to dismiss feature tips." + } }, - "or" : { + "Focused" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "eller" + "value" : "Fokussiert" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "o", - "state" : "translated" + "state" : "translated", + "value" : "Εστιασμένο" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "または" + "value" : "Focused" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "o", - "state" : "translated" + "state" : "translated", + "value" : "Enfocado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "ou", - "state" : "translated" + "state" : "translated", + "value" : "Concentré" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "or" + "value" : "Concentrato" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "ou", - "state" : "translated" + "state" : "translated", + "value" : "フォーカス済み" } }, "nl" : { "stringUnit" : { - "value" : "of", - "state" : "translated" + "state" : "translated", + "value" : "Gefocust" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "ή", - "state" : "translated" + "state" : "translated", + "value" : "Focado" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "oder", - "state" : "translated" + "state" : "translated", + "value" : "Fokuserad" } } - }, - "comment" : "Text for the \"or\" option in a list of options." + } }, - "Conversations are synchronized across your devices via iCloud." : { + "Fork from here" : { + "comment" : "A label for a button that forks a message.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Οι συνομιλίες συγχρονίζονται σε όλες τις συσκευές σας μέσω iCloud." + "value" : "Abzweigen von hier" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Las conversaciones se sincronizan entre tus dispositivos mediante iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Δημιουργία αντιγράφου από εδώ" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Konversationen werden über iCloud auf all Ihren Geräten synchronisiert." + "value" : "Fork from here" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Le conversazioni sono sincronizzate tra i tuoi dispositivi tramite iCloud." + "value" : "Bifurcar desde aquí" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "As conversas são sincronizadas entre os seus dispositivos através do iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Créer une branche ici" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Conversations are synchronized across your devices via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Crea fork da qui" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gesprekken worden via iCloud gesynchroniseerd op al je apparaten.", - "state" : "translated" + "state" : "translated", + "value" : "ここからフォーク" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Les conversations sont synchronisées entre vos appareils via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Vertakking vanaf hier" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話はiCloudを通じてデバイス間で同期されます。", - "state" : "translated" + "state" : "translated", + "value" : "Criar bifurcação daqui" } }, "sv" : { "stringUnit" : { - "value" : "Samtal synkroniseras mellan dina enheter via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Gaffla härifrån" } } - }, - "comment" : "A description of how conversations are synchronized across devices." + } }, - "Unable to read the backup file." : { + "Fully open source on GitHub — inspect or contribute" : { + "comment" : "A description of the Open Source aspect of OpenClient.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Die Sicherungsdatei kann nicht gelesen werden.", - "state" : "translated" + "state" : "translated", + "value" : "Vollständig Open Source auf GitHub — ansehen oder mitwirken" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Kan inte läsa säkerhetskopieringsfilen." + "value" : "Πλήρως ανοιχτού κώδικα στο GitHub — επιθεωρήστε ή συνεισφέρετε" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "No se puede leer el archivo de copia de seguridad." + "value" : "Fully open source on GitHub — inspect or contribute" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile leggere il file di backup.", - "state" : "translated" + "state" : "translated", + "value" : "Totalmente de código abierto en GitHub: revisa o contribuye" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Não foi possível ler o ficheiro de backup." + "value" : "Entièrement open source sur GitHub — inspectez ou contribuez" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Unable to read the backup file.", - "state" : "translated" + "state" : "translated", + "value" : "Completamente open source su GitHub — ispeziona o contribuisci" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Kan het back-upbestand niet lezen.", - "state" : "translated" + "state" : "translated", + "value" : "GitHubで完全にオープンソース — 調査や貢献が可能" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Impossible de lire le fichier de sauvegarde.", - "state" : "translated" + "state" : "translated", + "value" : "Volledig open source op GitHub — bekijken of bijdragen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "バックアップファイルを読み取れません。", - "state" : "translated" + "state" : "translated", + "value" : "Totalmente open source no GitHub — inspecione ou contribua" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αδυναμία ανάγνωσης του αρχείου αντιγράφου ασφαλείας.", - "state" : "translated" + "state" : "translated", + "value" : "Helt öppen källkod på GitHub — granska eller bidra" } } } }, - "iCloud Sync Conflict" : { + "Generated Image" : { + "comment" : "Name of the image attachment displayed in the chat.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud-synkroniseringskonflikt" + "value" : "Generiertes Bild" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Conflicto de sincronización de iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Παραγόμενη εικόνα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud同期の競合" + "value" : "Generated Image" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Conflitto di sincronizzazione iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Imagen generada" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Conflito de Sincronização do iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Image générée" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud Sync Conflict" + "value" : "Immagine generata" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "iCloud-synchronisatieconflict", - "state" : "translated" + "state" : "translated", + "value" : "生成画像" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Conflit de synchronisation iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Gegenereerde afbeelding" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Σύγκρουση Συγχρονισμού iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Imagem Gerada" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "iCloud-Synchronisierungskonflikt", - "state" : "translated" + "state" : "translated", + "value" : "Genererad bild" } } - }, - "comment" : "A title for an alert that appears when there is a conflict between iCloud data and local data." + } }, - "Done" : { + "Get Started" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "ΤΕΛΕΙΩΣΕ", - "state" : "translated" + "state" : "translated", + "value" : "Loslegen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Hecho", - "state" : "translated" + "state" : "translated", + "value" : "Ξεκινήστε" } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "Klart", - "state" : "translated" + "state" : "translated", + "value" : "Get Started" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Fatto", - "state" : "translated" + "state" : "translated", + "value" : "Comenzar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Concluído" + "value" : "Commencer" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Done" + "value" : "Inizia" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Terminé", - "state" : "translated" + "state" : "translated", + "value" : "はじめる" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gereed" + "value" : "Aan de slag" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Fertig", - "state" : "translated" + "state" : "translated", + "value" : "Começar" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "完了", - "state" : "translated" + "state" : "translated", + "value" : "Kom igång" } } } }, - "Private Chat" : { + "GitHub Profile" : { + "comment" : "Title of a web destination that opens the user's GitHub profile.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Privatchatt" + "value" : "GitHub-Profil" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Chat privado", - "state" : "translated" + "state" : "translated", + "value" : "Προφίλ GitHub" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ιδιωτική Συνομιλία" + "value" : "GitHub Profile" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Chat privata", - "state" : "translated" + "state" : "translated", + "value" : "Perfil de GitHub" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Conversa Privada", - "state" : "translated" + "state" : "translated", + "value" : "Profil GitHub" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Private Chat", - "state" : "translated" + "state" : "translated", + "value" : "Profilo GitHub" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Privéchat", - "state" : "translated" + "state" : "translated", + "value" : "GitHubプロフィール" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Discussion privée" + "value" : "GitHub-profiel" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "プライベートチャット", - "state" : "translated" + "state" : "translated", + "value" : "Perfil do GitHub" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Privater Chat", - "state" : "translated" + "state" : "translated", + "value" : "GitHub-profil" } } - }, - "comment" : "A label displayed in the empty state view." + } }, - "tag.image.generation" : { + "GPT, Claude, Gemini, Llama and more via LiteLLM, Ollama, LM Studio..." : { + "comment" : "A description of the features of the app.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Image" + "value" : "GPT, Claude, Gemini, Llama und mehr über LiteLLM, Ollama, LM Studio..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "GPT, Claude, Gemini, Llama και άλλα μέσω LiteLLM, Ollama, LM Studio..." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Image" + "value" : "GPT, Claude, Gemini, Llama and more via LiteLLM, Ollama, LM Studio..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "GPT, Claude, Gemini, Llama y más a través de LiteLLM, Ollama, LM Studio..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Image" + "value" : "GPT, Claude, Gemini, Llama et plus encore via LiteLLM, Ollama, LM Studio..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "GPT, Claude, Gemini, Llama e altri tramite LiteLLM, Ollama, LM Studio..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "LiteLLM、Ollama、LM Studioを通じて利用可能なGPT、Claude、Gemini、Llamaなど..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "GPT, Claude, Gemini, Llama en meer via LiteLLM, Ollama, LM Studio..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "GPT, Claude, Gemini, Llama e mais via LiteLLM, Ollama, LM Studio..." } }, "sv" : { "stringUnit" : { - "value" : "Image", - "state" : "translated" + "state" : "translated", + "value" : "GPT, Claude, Gemini, Llama med flera via LiteLLM, Ollama, LM Studio..." } } - }, - "comment" : "Label for the image generation capability." + } }, - "Record Audio" : { + "Green" : { + "comment" : "Name of the color green.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Audio aufnehmen", - "state" : "translated" + "state" : "translated", + "value" : "Grün" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Grabar audio", - "state" : "translated" + "state" : "translated", + "value" : "Πράσινο" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Spela in ljud" + "value" : "Green" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Registra audio" + "value" : "Verde" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Gravar Áudio", - "state" : "translated" + "state" : "translated", + "value" : "Vert" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Record Audio", - "state" : "translated" - } + "state" : "translated", + "value" : "Verde" + } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Audio opnemen", - "state" : "translated" + "state" : "translated", + "value" : "緑" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Enregistrer l’audio" + "value" : "Groen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "音声を録音", - "state" : "translated" + "state" : "translated", + "value" : "Verde" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Εγγραφή ήχου", - "state" : "translated" + "state" : "translated", + "value" : "Grön" } } - }, - "comment" : "A label for the record audio button." + } }, - "tag.tools" : { + "Help" : { + "comment" : "The title of the help screen.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Tools", - "state" : "translated" + "state" : "translated", + "value" : "Hilfe" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Tools", - "state" : "translated" + "state" : "translated", + "value" : "Βοήθεια" } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "Tools", - "state" : "translated" + "state" : "translated", + "value" : "Help" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tools", - "state" : "translated" + "state" : "translated", + "value" : "Ayuda" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Tools" + "value" : "Aide" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Tools" + "value" : "Aiuto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Tools", - "state" : "translated" + "state" : "translated", + "value" : "ヘルプ" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tools" + "value" : "Help" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Tools", - "state" : "translated" + "state" : "translated", + "value" : "Ajuda" } }, "sv" : { "stringUnit" : { - "value" : "Tools", - "state" : "translated" + "state" : "translated", + "value" : "Hjälp" } } - }, - "comment" : "Label for a capability that allows calling functions in other tools." + } }, - "Teal" : { + "Help me with my code" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Τιρκουάζ" + "value" : "Hilf mir bei meinem Code" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Verde azulado", - "state" : "translated" + "state" : "translated", + "value" : "Βοήθησέ με με τον κώδικά μου" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Blågrön" + "value" : "Help me with my code" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Turchese", - "state" : "translated" + "state" : "translated", + "value" : "Ayúdame con mi código" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Verde-azulado", - "state" : "translated" + "state" : "translated", + "value" : "Aide-moi avec mon code" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Teal", - "state" : "translated" + "state" : "translated", + "value" : "Aiutami con il mio codice" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Blauwgroen", - "state" : "translated" + "state" : "translated", + "value" : "コードの助けをしてください" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Sarcelle", - "state" : "translated" + "state" : "translated", + "value" : "Help me met mijn code" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ティール", - "state" : "translated" + "state" : "translated", + "value" : "Ajuda-me com o meu código" } }, - "de" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Blaugrün" + "value" : "Hjälp mig med min kod" } } - }, - "comment" : "Name of the color teal." + } }, - "Open **Shortcuts** and create a new shortcut." : { + "Help us fix it by describing the issue you encountered." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Άνοιξε τις **Συντομεύσεις** και δημιούργησε μια νέα συντόμευση.", - "state" : "translated" + "state" : "translated", + "value" : "Hilf uns, das Problem zu beheben, indem du das aufgetretene Problem beschreibst." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Abre **Atajos** y crea un nuevo atajo." + "value" : "Βοηθήστε μας να το διορθώσουμε περιγράφοντας το πρόβλημα που αντιμετωπίσατε." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Öppna **Genvägar** och skapa en ny genväg." + "value" : "Help us fix it by describing the issue you encountered." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apri **Comandi** e crea un nuovo comando.", - "state" : "translated" + "state" : "translated", + "value" : "Ayúdanos a solucionarlo describiendo el problema que encontraste." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Abra as **Atalhos** e crie um novo atalho.", - "state" : "translated" + "state" : "translated", + "value" : "Aidez-nous à le corriger en décrivant le problème rencontré." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Open **Shortcuts** and create a new shortcut.", - "state" : "translated" + "state" : "translated", + "value" : "Aiutaci a risolverlo descrivendo il problema riscontrato." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Ouvrez **Raccourcis** et créez un nouveau raccourci.", - "state" : "translated" + "state" : "translated", + "value" : "発生した問題について説明して、修正にご協力ください。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Open **Opdrachten** en maak een nieuwe opdracht aan." + "value" : "Help ons het op te lossen door het probleem dat je bent tegengekomen te beschrijven." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "**ショートカット**を開き、新しいショートカットを作成します。", - "state" : "translated" + "state" : "translated", + "value" : "Ajude-nos a corrigir descrevendo o problema que encontrou." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Öffne **Kurzbefehle** und erstelle einen neuen Kurzbefehl.", - "state" : "translated" + "state" : "translated", + "value" : "Hjälp oss att åtgärda det genom att beskriva problemet du stötte på." } } - }, - "comment" : "Step 1 of creating a shortcut using the Shortcuts app." + } }, - "tag.audio" : { + "Help us improve by suggesting new features or improvements." : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Audio", - "state" : "translated" + "state" : "translated", + "value" : "Hilf uns, indem du neue Funktionen oder Verbesserungen vorschlägst." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Audio", - "state" : "translated" + "state" : "translated", + "value" : "Βοηθήστε μας να βελτιωθούμε προτείνοντας νέες λειτουργίες ή βελτιώσεις." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Audio" + "value" : "Help us improve by suggesting new features or improvements." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Audio", - "state" : "translated" + "state" : "translated", + "value" : "Ayúdanos a mejorar sugiriendo nuevas funciones o mejoras." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Audio", - "state" : "translated" + "state" : "translated", + "value" : "Aidez-nous à améliorer en suggérant de nouvelles fonctionnalités ou améliorations." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Audio", - "state" : "translated" + "state" : "translated", + "value" : "Aiutaci a migliorare suggerendo nuove funzionalità o miglioramenti." } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Audio" + "value" : "新機能や改善点の提案でご協力ください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Audio" + "value" : "Help ons verbeteren door nieuwe functies of verbeteringen voor te stellen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "Audio", - "state" : "translated" + "state" : "translated", + "value" : "Ajude-nos a melhorar sugerindo novas funcionalidades ou melhorias." } }, "sv" : { "stringUnit" : { - "value" : "Audio", - "state" : "translated" + "state" : "translated", + "value" : "Hjälp oss förbättra genom att föreslå nya funktioner eller förbättringar." } } - }, - "comment" : "Label for the audio input capability." + } }, - "%lld of %lld MCP tool(s) enabled. Tools can also be managed from the chat input bar." : { + "Here is a concise summary of the meeting." : { + "comment" : "Last message preview text for a conversation.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "%1$lld av %2$lld MCP-verktyg aktiverade. Verktyg kan också hanteras från chattinmatningsfältet.", - "state" : "translated" + "state" : "translated", + "value" : "Hier ist eine kurze Zusammenfassung des Treffens." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld de %2$lld herramienta(s) MCP activada(s). Las herramientas también se pueden gestionar desde la barra de entrada del chat." + "value" : "Εδώ είναι μια σύντομη περίληψη της συνάντησης." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld από %2$lld εργαλεία MCP ενεργοποιημένα. Τα εργαλεία μπορούν επίσης να διαχειριστούν από τη γραμμή εισαγωγής συνομιλίας." + "value" : "Here is a concise summary of the meeting" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "%1$lld di %2$lld strumenti MCP abilitati. Gli strumenti possono essere gestiti anche dalla barra di input della chat.", - "state" : "translated" + "state" : "translated", + "value" : "Aquí un resumen conciso de la reunión." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld de %2$lld ferramenta(s) MCP ativada(s). As ferramentas também podem ser geridas a partir da barra de entrada do chat." + "value" : "Voici un résumé concis de la réunion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%1$lld of %2$lld MCP tool(s) enabled. Tools can also be managed from the chat input bar.", - "state" : "new" + "state" : "translated", + "value" : "Ecco un riassunto conciso della riunione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%1$lld van %2$lld MCP-hulpmiddel(en) ingeschakeld. Hulpmiddelen kunnen ook worden beheerd via de chatinvoerbalk.", - "state" : "translated" + "state" : "translated", + "value" : "会議の簡潔な要約です" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "%1$lld sur %2$lld outil(s) MCP activé(s). Les outils peuvent également être gérés depuis la barre de saisie du chat.", - "state" : "translated" + "state" : "translated", + "value" : "Hier is een beknopte samenvatting van de vergadering." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "%2$lld 個中 %1$lld 個の MCP ツールが有効です。ツールはチャット入力バーからも管理できます。", - "state" : "translated" + "state" : "translated", + "value" : "Aqui está um resumo conciso da reunião." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "%1$lld von %2$lld MCP-Werkzeugen aktiviert. Werkzeuge können auch über die Chat-Eingabeleiste verwaltet werden.", - "state" : "translated" + "state" : "translated", + "value" : "Här är en kort sammanfattning av mötet." } } - }, - "comment" : "A footer that shows the number of MCP tools that are enabled." + } }, - "Very creative" : { + "Hide Actions" : { + "comment" : "A label for hiding the available actions.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Πολύ δημιουργικό", - "state" : "translated" + "state" : "translated", + "value" : "Aktionen ausblenden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Muy creativo", - "state" : "translated" + "state" : "translated", + "value" : "Απόκρυψη ενεργειών" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Mycket kreativ" + "value" : "Hide Actions" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Molto creativo" + "value" : "Ocultar acciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Muito criativo", - "state" : "translated" + "state" : "translated", + "value" : "Masquer les actions" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Very creative", - "state" : "translated" + "state" : "translated", + "value" : "Nascondi azioni" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Très créatif", - "state" : "translated" + "state" : "translated", + "value" : "アクションを非表示" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Zeer creatief" + "value" : "Acties verbergen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Sehr kreativ", - "state" : "translated" + "state" : "translated", + "value" : "Ocultar Ações" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "とても創造的", - "state" : "translated" + "state" : "translated", + "value" : "Dölj åtgärder" } } } }, - "You are a data analysis expert. Help interpret data, identify patterns, suggest visualisations, and explain statistical concepts. Provide clear and actionable insights from any data the user shares." : { + "Hide API Key" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Είστε ειδικός στην ανάλυση δεδομένων. Βοηθήστε στην ερμηνεία δεδομένων, την αναγνώριση προτύπων, την πρόταση οπτικοποιήσεων και την εξήγηση στατιστικών εννοιών. Παρέχετε σαφείς και εφαρμόσιμες πληροφορίες από οποιαδήποτε δεδομένα μοιραστεί ο χρήστης.", - "state" : "translated" + "state" : "translated", + "value" : "API-Schlüssel verbergen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eres un experto en análisis de datos. Ayuda a interpretar datos, identificar patrones, sugerir visualizaciones y explicar conceptos estadísticos. Proporciona información clara y accionable a partir de cualquier dato que el usuario comparta.", - "state" : "translated" + "state" : "translated", + "value" : "Απόκρυψη κλειδιού API" } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "Du är en expert på dataanalys. Hjälp till att tolka data, identifiera mönster, föreslå visualiseringar och förklara statistiska begrepp. Ge tydliga och användbara insikter från all data som användaren delar.", - "state" : "translated" + "state" : "translated", + "value" : "Hide API Key" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sei un esperto di analisi dei dati. Aiuta a interpretare i dati, identificare modelli, suggerire visualizzazioni e spiegare concetti statistici. Fornisci approfondimenti chiari e concreti da qualsiasi dato l’utente condivida.", - "state" : "translated" + "state" : "translated", + "value" : "Ocultar clave API" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "É um especialista em análise de dados. Ajuda a interpretar dados, identificar padrões, sugerir visualizações e explicar conceitos estatísticos. Fornece insights claros e acionáveis a partir de quaisquer dados que o utilizador partilhe." + "value" : "Masquer la clé API" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "You are a data analysis expert. Help interpret data, identify patterns, suggest visualizations, and explain statistical concepts. Provide clear and actionable insights from any data the user shares.", - "state" : "translated" + "state" : "translated", + "value" : "Nascondi chiave API" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Vous êtes un expert en analyse de données. Aidez à interpréter les données, identifier les tendances, suggérer des visualisations et expliquer les concepts statistiques. Fournissez des analyses claires et exploitables à partir de toutes les données partagées par l’utilisateur." + "value" : "APIキーを隠す" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Je bent een expert in data-analyse. Help met het interpreteren van data, het identificeren van patronen, het voorstellen van visualisaties en het uitleggen van statistische concepten. Bied duidelijke en bruikbare inzichten uit alle data die de gebruiker deelt." + "value" : "API-sleutel verbergen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "あなたはデータ分析の専門家です。データの解釈、パターンの特定、可視化の提案、統計概念の説明を行います。ユーザーが共有するあらゆるデータから明確で実用的な洞察を提供します。", - "state" : "translated" + "state" : "translated", + "value" : "Ocultar chave API" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Sie sind ein Experte für Datenanalyse. Helfen Sie dabei, Daten zu interpretieren, Muster zu erkennen, Visualisierungen vorzuschlagen und statistische Konzepte zu erklären. Liefern Sie klare und umsetzbare Erkenntnisse aus allen vom Nutzer bereitgestellten Daten.", - "state" : "translated" + "state" : "translated", + "value" : "Dölj API-nyckel" } } - }, - "comment" : "Description of a data analyst assistant." + } }, - "Processing..." : { + "Hide Content in App Switcher" : { + "comment" : "A toggle that hides app content when switching between apps.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "処理中..." + "value" : "Inhalt im App-Umschalter verbergen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Procesando...", - "state" : "translated" + "state" : "translated", + "value" : "Απόκρυψη περιεχομένου στον εναλλάκτη εφαρμογών" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bearbetar..." + "value" : "Hide Content in App Switcher" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Elaborazione in corso...", - "state" : "translated" + "state" : "translated", + "value" : "Ocultar contenido en el selector de aplicaciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A processar...", - "state" : "translated" + "state" : "translated", + "value" : "Masquer le contenu dans le sélecteur d’applications" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Processing...", - "state" : "translated" + "state" : "translated", + "value" : "Nascondi contenuto nel selettore app" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bezig met verwerken...", - "state" : "translated" + "state" : "translated", + "value" : "Appスイッチャーでコンテンツを非表示" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Traitement en cours..." + "value" : "Inhoud verbergen in app-wisselaar" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Verarbeitung...", - "state" : "translated" + "state" : "translated", + "value" : "Ocultar conteúdo no alternador de aplicações" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Επεξεργασία...", - "state" : "translated" + "state" : "translated", + "value" : "Dölj innehåll i appväxlaren" } } - }, - "comment" : "A message displayed when the user is being processed." + } }, - "Explain quantum entanglement" : { + "How can I help you?" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εξήγηση της κβαντικής εμπλοκής", - "state" : "translated" + "state" : "translated", + "value" : "Wie kann ich Ihnen helfen?" } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Quantenverschränkung erklären" + "value" : "Πώς μπορώ να σας βοηθήσω;" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Explicar el entrelazamiento cuántico" + "value" : "How can I help you?" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Spiegare l’entanglement quantistico", - "state" : "translated" + "state" : "translated", + "value" : "¿Cómo puedo ayudarte?" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Explicar o entrelaçamento quântico", - "state" : "translated" + "state" : "translated", + "value" : "Comment puis-je vous aider ?" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Explain quantum entanglement", - "state" : "translated" + "state" : "translated", + "value" : "Come posso aiutarti?" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Leg kwantumverstrengeling uit", - "state" : "translated" + "state" : "translated", + "value" : "どうされましたか?" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Expliquer l’intrication quantique" + "value" : "Hoe kan ik u helpen?" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "量子もつれについて説明する", - "state" : "translated" + "state" : "translated", + "value" : "Como posso ajudar?" } }, "sv" : { "stringUnit" : { - "value" : "Förklara kvantintrassling", - "state" : "translated" + "state" : "translated", + "value" : "Hur kan jag hjälpa dig?" } } - }, - "comment" : "Title of a conversation." + } }, - "Important conversation" : { + "How does Swift concurrency work?" : { + "comment" : "Title of a conversation.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wichtige Unterhaltung" + "value" : "Wie funktioniert Swift Concurrency?" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Conversación importante", - "state" : "translated" + "state" : "translated", + "value" : "Πώς λειτουργεί η ασύγχρονη εκτέλεση στο Swift;" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Σημαντική συνομιλία" + "value" : "How does Swift concurrency work?" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Conversazione importante", - "state" : "translated" + "state" : "translated", + "value" : "¿Cómo funciona la concurrencia en Swift?" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Conversa importante", - "state" : "translated" + "state" : "translated", + "value" : "Comment fonctionne la concurrence en Swift ?" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Important conversation" + "value" : "Come funziona la concorrenza in Swift?" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Belangrijk gesprek", - "state" : "translated" + "state" : "translated", + "value" : "Swiftの並行処理はどう機能するのか?" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Conversation importante", - "state" : "translated" + "state" : "translated", + "value" : "Hoe werkt Swift-concurrentie?" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "重要な会話", - "state" : "translated" + "state" : "translated", + "value" : "Como funciona a concorrência em Swift?" } }, "sv" : { "stringUnit" : { - "value" : "Viktig konversation", - "state" : "translated" + "state" : "translated", + "value" : "Hur fungerar Swift-konkurens?" } } - }, - "comment" : "Title of a placeholder pinned conversation." + } }, - "1 source" : { + "How the assistant will address you. Max 50 characters." : { + "comment" : "A description of how the assistant will address the user.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "1つのソース", - "state" : "translated" + "state" : "translated", + "value" : "Wie der Assistent Sie ansprechen wird. Maximal 50 Zeichen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "1 fuente", - "state" : "translated" + "state" : "translated", + "value" : "Πώς θα σας απευθύνεται ο βοηθός. Μέγιστο 50 χαρακτήρες." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "1 Quelle" + "value" : "How the assistant will address you. Max 50 characters" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "1 fonte" + "value" : "Cómo se dirigirá a ti el asistente. Máx 50 caracteres" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "1 fonte", - "state" : "translated" + "state" : "translated", + "value" : "Comment l’assistant s’adressera à vous. 50 caractères max." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "1 source", - "state" : "translated" + "state" : "translated", + "value" : "Come l’assistente si rivolgerà a te. Max 50 caratteri" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "1 source", - "state" : "translated" + "state" : "translated", + "value" : "アシスタントがあなたを呼ぶ名前。最大50文字。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "1 bron" + "value" : "Hoe de assistent u zal aanspreken. Maximaal 50 tekens" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "1 πηγή", - "state" : "translated" + "state" : "translated", + "value" : "Como o assistente se dirigirá a si. Máx. 50 caracteres." } }, "sv" : { "stringUnit" : { - "value" : "1 källa", - "state" : "translated" + "state" : "translated", + "value" : "Hur assistenten kommer att tilltala dig. Max 50 tecken." } } - }, - "comment" : "A label that indicates that there is 1 source." + } }, - "The model returned an empty response. Please try again." : { + "http://localhost:4000" : { + "comment" : "A placeholder URL for the server URL field.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το μοντέλο επέστρεψε κενή απάντηση. Παρακαλώ δοκιμάστε ξανά.", - "state" : "translated" + "state" : "translated", + "value" : "http://localhost:4000" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Das Modell hat eine leere Antwort zurückgegeben. Bitte versuchen Sie es erneut.", - "state" : "translated" + "state" : "translated", + "value" : "http://localhost:4000" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "El modelo devolvió una respuesta vacía. Por favor, inténtalo de nuevo.", - "state" : "translated" + "state" : "translated", + "value" : "http://localhost:4000" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il modello ha restituito una risposta vuota. Riprova." + "value" : "http://localhost:4000" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O modelo devolveu uma resposta vazia. Por favor, tente novamente.", - "state" : "translated" + "state" : "translated", + "value" : "http://localhost:4000" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The model returned an empty response. Please try again." + "value" : "http://localhost:4000" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Het model gaf een lege reactie terug. Probeer het opnieuw.", - "state" : "translated" + "state" : "translated", + "value" : "http://localhost:4000" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Le modèle a renvoyé une réponse vide. Veuillez réessayer." + "value" : "http://localhost:4000" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "モデルが空の応答を返しました。もう一度お試しください。", - "state" : "translated" + "state" : "translated", + "value" : "http://localhost:4000" } }, "sv" : { "stringUnit" : { - "value" : "Modellen gav inget svar. Försök igen.", - "state" : "translated" + "state" : "translated", + "value" : "http://localhost:4000" } } - }, - "comment" : "Error message displayed when the assistant returns an empty response." + } }, - "Your pinned conversation appears here." : { + "iCloud data changed during synchronization." : { + "comment" : "Error description when iCloud data changes during synchronization.", + "isCommentAutoGenerated" : true + }, + "iCloud is downloading changes. Sync will continue automatically." : { + "comment" : "A footer for the iCloud sync section.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { - "value" : "Deine angeheftete Unterhaltung erscheint hier.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud lädt Änderungen herunter. Die Synchronisierung wird automatisch fortgesetzt." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Tu conversación fijada aparece aquí." + "value" : "Το iCloud λαμβάνει αλλαγές. Ο συγχρονισμός θα συνεχιστεί αυτόματα." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Din fastnålad konversation visas här." + "value" : "iCloud is downloading changes. Sync will continue automatically." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "La tua conversazione fissata appare qui.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud está descargando cambios. La sincronización continuará automáticamente." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A sua conversa fixada aparece aqui.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud télécharge les modifications. La synchronisation se poursuivra automatiquement." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Your pinned conversation appears here." + "value" : "iCloud sta ricevendo le modifiche. La sincronizzazione continuerà automaticamente." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Votre conversation épinglée apparaît ici.", - "state" : "translated" + "state" : "translated", + "value" : "iCloudが変更を受信しています。同期は自動的に続行されます。" } }, "nl" : { "stringUnit" : { - "value" : "Je vastgezette gesprek verschijnt hier.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud is wijzigingen aan het ontvangen. Synchronisatie gaat automatisch door." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ピン留めした会話がここに表示されます。", - "state" : "translated" + "state" : "translated", + "value" : "O iCloud está a transferir alterações. A sincronização continuará automaticamente." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Η καρφιτσωμένη συνομιλία σας εμφανίζεται εδώ.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud tar emot ändringar. Synkroniseringen fortsätter automatiskt." } } } }, - "Today" : { + "iCloud is unavailable for: %@. Local changes are retained." : { + + }, + "iCloud is unavailable. Your changes will stay on this device until sync resumes." : { + "comment" : "A footer for the iCloud sync section.", + "extractionState" : "stale", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "今日" + "value" : "iCloud ist nicht verfügbar. Ihre Änderungen bleiben auf diesem Gerät, bis die Synchronisierung fortgesetzt wird." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Hoy", - "state" : "translated" + "state" : "translated", + "value" : "Το iCloud δεν είναι διαθέσιμο. Οι αλλαγές σας θα παραμείνουν σε αυτή τη συσκευή μέχρι να επανέλθει ο συγχρονισμός." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Heute" + "value" : "iCloud is unavailable. Your changes will remain on this device until syncing resumes." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Oggi", - "state" : "translated" + "state" : "translated", + "value" : "iCloud no está disponible. Tus cambios permanecerán en este dispositivo hasta que se reanude la sincronización." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Hoje", - "state" : "translated" + "state" : "translated", + "value" : "iCloud est indisponible. Vos modifications resteront sur cet appareil jusqu’à la reprise de la synchronisation." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Today" + "value" : "iCloud non è disponibile. Le tue modifiche rimarranno su questo dispositivo finché la sincronizzazione non riprenderà." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vandaag", - "state" : "translated" + "state" : "translated", + "value" : "iCloudは利用できません。同期が再開されるまで、変更はこのデバイスに保存されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aujourd’hui", - "state" : "translated" + "state" : "translated", + "value" : "iCloud is niet beschikbaar. Je wijzigingen blijven op dit apparaat staan totdat de synchronisatie hervat wordt." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Σήμερα", - "state" : "translated" + "state" : "translated", + "value" : "O iCloud não está disponível. As suas alterações permanecerão neste dispositivo até que a sincronização seja retomada." } }, "sv" : { "stringUnit" : { - "value" : "Idag", - "state" : "translated" + "state" : "translated", + "value" : "iCloud är otillgängligt. Dina ändringar kommer att finnas kvar på den här enheten tills synkroniseringen återupptas." } } - }, - "comment" : "Title of a conversation section for conversations from today." + } }, - "Jump back into your latest conversation." : { + "iCloud Sync" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Springe zurück zu deinem letzten Gespräch." + "value" : "iCloud-Synchronisierung" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Vuelve a tu última conversación.", - "state" : "translated" + "state" : "translated", + "value" : "Συγχρονισμός iCloud" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hoppa tillbaka till din senaste konversation." + "value" : "iCloud Sync" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Ritorna alla tua ultima conversazione." + "value" : "Sincronización iCloud" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Voltar à sua conversa mais recente.", - "state" : "translated" + "state" : "translated", + "value" : "Synchronisation iCloud" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Jump back into your latest conversation", - "state" : "translated" + "state" : "translated", + "value" : "Sincronizzazione iCloud" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Ga terug naar je laatste gesprek.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud同期" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Reprenez votre dernière conversation.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud-synchronisatie" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επιστροφή στην πιο πρόσφατη συνομιλία σας.", - "state" : "translated" + "state" : "translated", + "value" : "Sincronização iCloud" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "最新の会話に戻る", - "state" : "translated" + "state" : "translated", + "value" : "iCloud-synkronisering" } } - }, - "comment" : "Description of the widget that opens the most recently updated conversation in OpenClient." + } }, - "The server certificate is not trusted." : { + "iCloud Sync Conflict" : { + "comment" : "A title for an alert that appears when there is a conflict between iCloud data and local data.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Das Serverzertifikat wird nicht vertraut." + "value" : "iCloud-Synchronisierungskonflikt" } }, - "en" : { + "el" : { "stringUnit" : { - "value" : "The server certificate is not trusted.", - "state" : "translated" + "state" : "translated", + "value" : "Σύγκρουση Συγχρονισμού iCloud" } }, - "pt-PT" : { + "en" : { "stringUnit" : { - "value" : "O certificado do servidor não é confiável.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud Sync Conflict" } }, - "nl" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Het servercertificaat wordt niet vertrouwd." + "value" : "Conflicto de sincronización de iCloud" } }, - "ja" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "サーバー証明書は信頼されていません。" + "value" : "Conflit de synchronisation iCloud" } }, - "fr" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Le certificat du serveur n’est pas fiable." + "value" : "Conflitto di sincronizzazione iCloud" } }, - "it" : { + "ja" : { "stringUnit" : { - "value" : "Il certificato del server non è attendibile.", - "state" : "translated" + "state" : "translated", + "value" : "iCloud同期の競合" } }, - "sv" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Serverns certifikat är inte betrott." + "value" : "iCloud-synchronisatieconflict" } }, - "el" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Το πιστοποιητικό διακομιστή δεν είναι αξιόπιστο." + "value" : "Conflito de Sincronização do iCloud" } }, - "es" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "El certificado del servidor no es de confianza." + "value" : "iCloud-synkroniseringskonflikt" } } } }, - "No conversations found with the selected tag" : { + "Image" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δεν βρέθηκαν συνομιλίες με την επιλεγμένη ετικέτα", - "state" : "translated" + "state" : "translated", + "value" : "Bild" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "No se encontraron conversaciones con la etiqueta seleccionada" + "value" : "Εικόνα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "選択したタグの会話は見つかりませんでした" + "value" : "Image" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessuna conversazione trovata con il tag selezionato", - "state" : "translated" + "state" : "translated", + "value" : "Imagen" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nenhuma conversa encontrada com a etiqueta selecionada" + "value" : "Image" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No conversations found with the selected tag", - "state" : "translated" + "state" : "translated", + "value" : "Immagine" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen gesprekken gevonden met het geselecteerde label", - "state" : "translated" + "state" : "translated", + "value" : "画像" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucune conversation trouvée avec le tag sélectionné", - "state" : "translated" + "state" : "translated", + "value" : "Afbeelding" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Keine Unterhaltungen mit dem ausgewählten Tag gefunden", - "state" : "translated" + "state" : "translated", + "value" : "Imagem" } }, "sv" : { "stringUnit" : { - "value" : "Inga konversationer hittades med den valda taggen", - "state" : "translated" + "state" : "translated", + "value" : "Bild" } } - }, - "comment" : "A message displayed when there are no conversations with a specific tag." + } }, - "No Templates" : { + "Image could not be loaded" : { + "comment" : "A message displayed when an image fails to load.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Inga mallar", - "state" : "translated" + "state" : "translated", + "value" : "Bild konnte nicht geladen werden" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Sin plantillas" + "value" : "Η εικόνα δεν μπόρεσε να φορτωθεί" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Χωρίς Πρότυπα" + "value" : "Image could not be loaded" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessun modello", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo cargar la imagen" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sem Modelos", - "state" : "translated" + "state" : "translated", + "value" : "Impossible de charger l’image" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No Templates", - "state" : "translated" + "state" : "translated", + "value" : "Immagine non caricabile" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen sjablonen", - "state" : "translated" + "state" : "translated", + "value" : "画像を読み込めませんでした" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aucun modèle" + "value" : "Afbeelding kon niet worden geladen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "テンプレートなし", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível carregar a imagem" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Keine Vorlagen", - "state" : "translated" + "state" : "translated", + "value" : "Bilden kunde inte laddas" } } - }, - "comment" : "A title that describes the absence of templates." - }, - "·" : { - "shouldTranslate" : false + } }, - "Save to Photos" : { + "Image File..." : { + "comment" : "A label for selecting an image file.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Spara till Foton" + "value" : "Bilddatei..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Guardar en Fotos", - "state" : "translated" + "state" : "translated", + "value" : "Αρχείο εικόνας..." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "In Fotos speichern" + "value" : "Image File..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Salva in Foto", - "state" : "translated" + "state" : "translated", + "value" : "Archivo de imagen..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Guardar nas Fotografias" + "value" : "Fichier image..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Save to Photos", - "state" : "translated" + "state" : "translated", + "value" : "File immagine..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Opslaan in Foto's", - "state" : "translated" + "state" : "translated", + "value" : "画像ファイル..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Enregistrer dans Photos", - "state" : "translated" + "state" : "translated", + "value" : "Afbeeldingsbestand..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "写真に保存", - "state" : "translated" + "state" : "translated", + "value" : "Ficheiro de Imagem..." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αποθήκευση στις Φωτογραφίες", - "state" : "translated" + "state" : "translated", + "value" : "Bildfil..." } } - }, - "comment" : "A label for a context menu item that saves an image to the user's photo library." + } }, - "Pin" : { + "Image Generation" : { + "comment" : "A name for an LLM model that generates images.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Anheften", - "state" : "translated" + "state" : "translated", + "value" : "Bildgenerierung" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Καρφίτσωμα" + "value" : "Δημιουργία Εικόνων" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Fijar" + "value" : "Image Generation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Fissa", - "state" : "translated" + "state" : "translated", + "value" : "Generación de imágenes" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Alfinete" + "value" : "Génération d’images" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Pin", - "state" : "translated" + "state" : "translated", + "value" : "Generazione Immagini" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vastzetten", - "state" : "translated" + "state" : "translated", + "value" : "画像生成" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Épingler", - "state" : "translated" + "state" : "translated", + "value" : "Beeldgeneratie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ピン", - "state" : "translated" + "state" : "translated", + "value" : "Geração de Imagens" } }, "sv" : { "stringUnit" : { - "value" : "Stift", - "state" : "translated" + "state" : "translated", + "value" : "Bildgenerering" } } - }, - "comment" : "A pin icon." + } }, - "Estimated cost" : { + "Image generation requires a text prompt without attachments." : { + "comment" : "Error message displayed when trying to generate an image without providing a text prompt.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Beräknad kostnad" + "value" : "Für die Bildgenerierung ist eine Texteingabe ohne Anhänge erforderlich." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Costo estimado" + "value" : "Η δημιουργία εικόνας απαιτεί μια περιγραφή κειμένου χωρίς συνημμένα." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εκτιμώμενο κόστος" + "value" : "Image generation requires a text prompt without attachments." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Costo stimato", - "state" : "translated" + "state" : "translated", + "value" : "La generación de imágenes requiere un texto descriptivo sin archivos adjuntos." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Custo estimado", - "state" : "translated" + "state" : "translated", + "value" : "La génération d’images nécessite une invite textuelle sans pièces jointes." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Estimated cost", - "state" : "translated" + "state" : "translated", + "value" : "La generazione dell'immagine richiede un prompt testuale senza allegati." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geschatte kosten", - "state" : "translated" + "state" : "translated", + "value" : "画像を生成するには、添付ファイルなしでテキストプロンプトを入力してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Coût estimé", - "state" : "translated" + "state" : "translated", + "value" : "Voor het genereren van een afbeelding is een tekstprompt zonder bijlagen vereist." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "推定費用", - "state" : "translated" + "state" : "translated", + "value" : "A geração de imagens requer um prompt de texto sem anexos." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Geschätzte Kosten", - "state" : "translated" + "state" : "translated", + "value" : "Bildgenerering kräver en textprompt utan bilagor." } } - }, - "comment" : "A label for the estimated cost of a conversation." + } }, - "Blocked" : { + "Images" : { + "comment" : "A section header for a list of images.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "ブロック済み" + "value" : "Bilder" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Bloqueado", - "state" : "translated" + "state" : "translated", + "value" : "Εικόνες" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Blockerad" + "value" : "Images" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Bloccato", - "state" : "translated" + "state" : "translated", + "value" : "Imágenes" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Bloqueado", - "state" : "translated" + "state" : "translated", + "value" : "Images" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Blocked", - "state" : "translated" + "state" : "translated", + "value" : "Immagini" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Bloqué", - "state" : "translated" + "state" : "translated", + "value" : "画像" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geblokkeerd" + "value" : "Afbeeldingen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αποκλεισμένο", - "state" : "translated" + "state" : "translated", + "value" : "Imagens" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Blockiert", - "state" : "translated" + "state" : "translated", + "value" : "Bilder" } } } }, - "Cancel Recording" : { + "Images and documents you attach to messages will appear here." : { + "comment" : "A description of the content of the view.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Avbryt inspelning" + "value" : "Bilder und Dokumente, die Sie Nachrichten anhängen, werden hier angezeigt." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cancelar grabación", - "state" : "translated" + "state" : "translated", + "value" : "Οι εικόνες και τα έγγραφα που επισυνάπτετε στα μηνύματα θα εμφανίζονται εδώ." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "録音をキャンセル" + "value" : "Images and documents you attach to messages will appear here." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Annulla registrazione", - "state" : "translated" + "state" : "translated", + "value" : "Las imágenes y documentos que adjuntes a los mensajes aparecerán aquí." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Cancelar Gravação", - "state" : "translated" + "state" : "translated", + "value" : "Les images et documents que vous joignez aux messages apparaîtront ici." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Cancel Recording", - "state" : "translated" + "state" : "translated", + "value" : "Le immagini e i documenti che alleghi ai messaggi appariranno qui." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Annuler l’enregistrement", - "state" : "translated" + "state" : "translated", + "value" : "メッセージに添付した画像や書類はここに表示されます。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Opname annuleren" + "value" : "Afbeeldingen en documenten die je aan berichten toevoegt, verschijnen hier." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Aufnahme abbrechen", - "state" : "translated" + "state" : "translated", + "value" : "As imagens e documentos que anexar às mensagens aparecerão aqui." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Ακύρωση εγγραφής", - "state" : "translated" + "state" : "translated", + "value" : "Bilder och dokument som du bifogar i meddelanden visas här." } } - }, - "comment" : "A button that cancels the current recording." + } }, - "Open the search screen in OpenClient." : { + "Import Complete" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Öffne den Suchbildschirm in OpenClient." + "value" : "Import abgeschlossen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Abrir la pantalla de búsqueda en OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Η εισαγωγή ολοκληρώθηκε" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Öppna sökskärmen i OpenClient." + "value" : "Import Complete" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apri la schermata di ricerca in OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Importación completada" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir o ecrã de pesquisa no OpenClient." + "value" : "Importation terminée" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Open the search screen in OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Importazione completata" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Open het zoekscherm in OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "インポート完了" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ouvrir l’écran de recherche dans OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Import voltooid" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenClientで検索画面を開く", - "state" : "translated" + "state" : "translated", + "value" : "Importação concluída" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Άνοιγμα της οθόνης αναζήτησης στο OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Import klar" } } - }, - "comment" : "Description of the Search widget." + } }, - "http:\/\/localhost:4000" : { + "Import Conversations" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "http:\/\/localhost:4000", - "state" : "translated" + "state" : "translated", + "value" : "Konversationen importieren" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "http:\/\/localhost:4000" + "value" : "Εισαγωγή Συνομιλιών" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "http:\/\/localhost:4000" + "value" : "Import Conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "http:\/\/localhost:4000", - "state" : "translated" + "state" : "translated", + "value" : "Importar conversaciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "http:\/\/localhost:4000", - "state" : "translated" + "state" : "translated", + "value" : "Importer les conversations" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "http:\/\/localhost:4000", - "state" : "translated" + "state" : "translated", + "value" : "Importa conversazioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "http:\/\/localhost:4000", - "state" : "translated" + "state" : "translated", + "value" : "会話をインポート" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "http:\/\/localhost:4000" + "value" : "Gesprekken importeren" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "http:\/\/localhost:4000", - "state" : "translated" + "state" : "translated", + "value" : "Importar Conversas" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "http:\/\/localhost:4000", - "state" : "translated" + "state" : "translated", + "value" : "Importera konversationer" } } - }, - "comment" : "A placeholder URL for the server URL field." + } }, - "Continue your latest chat" : { + "Important conversation" : { + "comment" : "Title of a placeholder pinned conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "最新のチャットを続ける", - "state" : "translated" + "state" : "translated", + "value" : "Wichtige Unterhaltung" } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Führe deinen letzten Chat fort" + "value" : "Σημαντική συνομιλία" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Continúa tu último chat" + "value" : "Important conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Continua la tua ultima chat", - "state" : "translated" + "state" : "translated", + "value" : "Conversación importante" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Continue a sua última conversa" + "value" : "Conversation importante" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Continue your latest chat", - "state" : "translated" + "state" : "translated", + "value" : "Conversazione importante" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Ga door met je laatste gesprek", - "state" : "translated" + "state" : "translated", + "value" : "重要な会話" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Poursuivre votre dernière conversation", - "state" : "translated" + "state" : "translated", + "value" : "Belangrijk gesprek" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Συνέχισε την τελευταία σου συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Conversa importante" } }, "sv" : { "stringUnit" : { - "value" : "Fortsätt din senaste chatt", - "state" : "translated" + "state" : "translated", + "value" : "Viktig konversation" } } - }, - "comment" : "Title of a placeholder conversation." + } }, - "Camera" : { + "Imported %lld conversations and restored %lld attachments." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κάμερα", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld Konversationen importiert und %2$lld Anhänge wiederhergestellt." } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Kamera" + "value" : "Εισήχθησαν %1$lld συνομιλίες και αποκαταστάθηκαν %2$lld συνημμένα." } }, - "es" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Cámara" + "state" : "new", + "value" : "Imported %1$lld conversations and restored %2$lld attachments." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Fotocamera", - "state" : "translated" + "state" : "translated", + "value" : "Se importaron %1$lld conversaciones y se restauraron %2$lld archivos adjuntos." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Câmara", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld conversations importées et %2$lld pièces jointes restaurées." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Camera", - "state" : "translated" + "state" : "translated", + "value" : "Importate %1$lld conversazioni e ripristinati %2$lld allegati." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Camera", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld 件の会話をインポートし、%2$lld 件の添付ファイルを復元しました。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Appareil photo" + "value" : "%1$lld gesprekken geïmporteerd en %2$lld bijlagen hersteld." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "カメラ", - "state" : "translated" + "state" : "translated", + "value" : "Importadas %1$lld conversas e restaurados %2$lld anexos." } }, "sv" : { "stringUnit" : { - "value" : "Kamera", - "state" : "translated" + "state" : "translated", + "value" : "Importerade %1$lld konversationer och återställde %2$lld bilagor." } } } }, - "Use Local Data" : { + "Imported %lld conversations, restored %lld attachments, and skipped %lld attachments." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "ローカルデータを使用" + "value" : "%1$lld Konversationen importiert, %2$lld Anhänge wiederhergestellt und %3$lld Anhänge übersprungen." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Usar datos locales" + "value" : "Εισήχθησαν %1$lld συνομιλίες, αποκαταστάθηκαν %2$lld συνημμένα και παραλείφθηκαν %3$lld συνημμένα." } }, - "de" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Lokale Daten verwenden" + "state" : "new", + "value" : "Imported %1$lld conversations, restored %2$lld attachments, and skipped %3$lld attachments." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Usa dati locali", - "state" : "translated" + "state" : "translated", + "value" : "Se importaron %1$lld conversaciones, se restauraron %2$lld archivos adjuntos y se omitieron %3$lld archivos adjuntos." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Usar Dados Locais", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld conversations importées, %2$lld pièces jointes restaurées, et %3$lld pièces jointes ignorées." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Use Local Data", - "state" : "translated" + "state" : "translated", + "value" : "Importate %1$lld conversazioni, ripristinati %2$lld allegati e saltati %3$lld allegati." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Utiliser les données locales", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld 件の会話をインポートし、%2$lld 件の添付ファイルを復元し、%3$lld 件の添付ファイルをスキップしました。" } }, "nl" : { "stringUnit" : { - "value" : "Gebruik lokale gegevens", - "state" : "translated" + "state" : "translated", + "value" : "%1$lld gesprekken geïmporteerd, %2$lld bijlagen hersteld en %3$lld bijlagen overgeslagen." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Χρήση τοπικών δεδομένων", - "state" : "translated" + "state" : "translated", + "value" : "Importadas %1$lld conversas, restaurados %2$lld anexos e ignorados %3$lld anexos." } }, "sv" : { "stringUnit" : { - "value" : "Använd lokal data", - "state" : "translated" + "state" : "translated", + "value" : "Importerade %1$lld konversationer, återställde %2$lld bilagor och hoppade över %3$lld bilagor." } } - }, - "comment" : "A button that uses the local data." + } }, - "Copy Image" : { + "In Progress" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αντιγραφή εικόνας", - "state" : "translated" + "state" : "translated", + "value" : "In Bearbeitung" } }, - "ja" : { + "el" : { "stringUnit" : { - "value" : "画像をコピー", - "state" : "translated" + "state" : "translated", + "value" : "Σε εξέλιξη" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Copiar imagen" + "value" : "In Progress" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Copia immagine", - "state" : "translated" + "state" : "translated", + "value" : "En progreso" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Copiar imagem" + "value" : "En cours" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Copy Image", - "state" : "translated" + "state" : "translated", + "value" : "In corso" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeelding kopiëren", - "state" : "translated" + "state" : "translated", + "value" : "進行中" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Copier l’image" + "value" : "Bezig" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Bild kopieren", - "state" : "translated" + "state" : "translated", + "value" : "Em progresso" } }, "sv" : { "stringUnit" : { - "value" : "Kopiera bild", - "state" : "translated" + "state" : "translated", + "value" : "Pågår" } } - }, - "comment" : "A label for copying an image to the clipboard." + } }, - "A brief description about yourself" : { + "Indigo" : { + "comment" : "Name of the color indigo.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Eine kurze Beschreibung von dir", - "state" : "translated" + "state" : "translated", + "value" : "Indigo" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Una breve descripción sobre ti mismo", - "state" : "translated" + "state" : "translated", + "value" : "Ινδικό" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "En kort beskrivning om dig själv" + "value" : "Indigo" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Una breve descrizione di te stesso" + "value" : "Índigo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Uma breve descrição sobre si próprio", - "state" : "translated" + "state" : "translated", + "value" : "Indigo" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "A brief description about yourself" + "value" : "Indaco" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Een korte beschrijving over jezelf", - "state" : "translated" + "state" : "translated", + "value" : "インディゴ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Une brève description de vous-même", - "state" : "translated" + "state" : "translated", + "value" : "Indigo" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Μια σύντομη περιγραφή για εσάς", - "state" : "translated" + "state" : "translated", + "value" : "Índigo" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "あなたについての簡単な説明", - "state" : "translated" + "state" : "translated", + "value" : "Indigo" } } - }, - "comment" : "A placeholder for a user's description." + } }, - "Suggestion" : { + "Information" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Πρόταση", - "state" : "translated" + "state" : "translated", + "value" : "Information" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sugerencia", - "state" : "translated" + "state" : "translated", + "value" : "Πληροφορίες" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Förslag" + "value" : "Information" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Suggerimento" + "value" : "Información" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sugestão", - "state" : "translated" + "state" : "translated", + "value" : "Informations" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Suggestion" + "value" : "Informazioni" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Suggestion", - "state" : "translated" + "state" : "translated", + "value" : "情報" } }, "nl" : { "stringUnit" : { - "value" : "Suggestie", - "state" : "translated" + "state" : "translated", + "value" : "Informatie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "提案", - "state" : "translated" + "state" : "translated", + "value" : "Informação" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Vorschlag", - "state" : "translated" + "state" : "translated", + "value" : "Information" } } } }, - "Private chats are not saved or synced, and they do not read or change personal memory." : { + "Input" : { + "comment" : "A label for the cost of input tokens.", + "shouldTranslate" : false + }, + "Input tokens" : { + "comment" : "A label for the maximum number of input tokens for a model.", + "shouldTranslate" : false + }, + "Invalid API key. Please check your credentials." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "プライベートチャットは保存や同期されず、個人の記憶を読み取ったり変更したりしません。" + "value" : "Ungültiger API-Schlüssel. Bitte überprüfen Sie Ihre Zugangsdaten." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Los chats privados no se guardan ni sincronizan, y no leen ni modifican la memoria personal.", - "state" : "translated" + "state" : "translated", + "value" : "Μη έγκυρο κλειδί API. Ελέγξτε τα διαπιστευτήριά σας." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Privata chattar sparas inte eller synkroniseras, och de läser inte eller ändrar personlig minne." + "value" : "Invalid API key. Please check your credentials." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Le chat private non vengono salvate né sincronizzate, e non leggono né modificano la memoria personale.", - "state" : "translated" + "state" : "translated", + "value" : "Clave API no válida. Por favor, verifica tus credenciales." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "As conversas privadas não são guardadas nem sincronizadas, e não leem nem alteram a memória pessoal.", - "state" : "translated" + "state" : "translated", + "value" : "Clé API invalide. Veuillez vérifier vos identifiants." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Private chats are not saved or synced, and they do not read or modify personal memory.", - "state" : "translated" + "state" : "translated", + "value" : "Chiave API non valida. Controlla le tue credenziali." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Privégesprekken worden niet opgeslagen of gesynchroniseerd en lezen of wijzigen geen persoonlijke herinneringen.", - "state" : "translated" + "state" : "translated", + "value" : "無効なAPIキーです。認証情報を確認してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Les discussions privées ne sont pas enregistrées ni synchronisées, et elles ne lisent ni ne modifient la mémoire personnelle." + "value" : "Ongeldige API-sleutel. Controleer uw gegevens." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Private Chats werden nicht gespeichert oder synchronisiert und lesen oder ändern das persönliche Gedächtnis nicht.", - "state" : "translated" + "state" : "translated", + "value" : "Chave API inválida. Por favor, verifique as suas credenciais." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Οι ιδιωτικές συνομιλίες δεν αποθηκεύονται ή συγχρονίζονται και δεν διαβάζουν ούτε αλλάζουν την προσωπική μνήμη.", - "state" : "translated" + "state" : "translated", + "value" : "Ogiltig API-nyckel. Kontrollera dina uppgifter." } } - }, - "comment" : "A description of private chats." + } }, - "The selected file is not a valid image." : { + "Issue" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "選択したファイルは有効な画像ではありません。" + "value" : "Problem" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El archivo seleccionado no es una imagen válida.", - "state" : "translated" + "state" : "translated", + "value" : "Πρόβλημα" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Το επιλεγμένο αρχείο δεν είναι έγκυρη εικόνα." + "value" : "Issue" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il file selezionato non è un'immagine valida." + "value" : "Problema" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O ficheiro selecionado não é uma imagem válida.", - "state" : "translated" + "state" : "translated", + "value" : "Problème" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The selected file is not a valid image.", - "state" : "translated" + "state" : "translated", + "value" : "Problema" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Het geselecteerde bestand is geen geldige afbeelding.", - "state" : "translated" + "state" : "translated", + "value" : "問題" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Le fichier sélectionné n’est pas une image valide.", - "state" : "translated" + "state" : "translated", + "value" : "Probleem" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Die ausgewählte Datei ist kein gültiges Bild.", - "state" : "translated" + "state" : "translated", + "value" : "Problema" } }, "sv" : { "stringUnit" : { - "value" : "Den valda filen är inte en giltig bild.", - "state" : "translated" + "state" : "translated", + "value" : "Problem" } } - }, - "comment" : "Error message when the selected file is not a valid image." + } }, - "Any additional context you want the assistant to know. Max 500 characters." : { + "Issue Image" : { + "comment" : "Title of the section where the user can attach an image of the issue.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Περιγραφή της ενότητας με τις επιπλέον πληροφορίες.", - "state" : "translated" + "state" : "translated", + "value" : "Problemfoto" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cualquier información adicional que desees que el asistente conozca. Máximo 500 caracteres.", - "state" : "translated" + "state" : "translated", + "value" : "Εικόνα προβλήματος" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "追加情報セクションの説明です。" + "value" : "Issue Image" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Qualsiasi informazione aggiuntiva che desideri comunicare all’assistente. Massimo 500 caratteri.", - "state" : "translated" + "state" : "translated", + "value" : "Imagen del problema" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Qualquer informação adicional que queira que o assistente saiba. Máx. 500 caracteres." + "value" : "Image du problème" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Any additional context you want the assistant to know. Max 500 characters.", - "state" : "translated" + "state" : "translated", + "value" : "Immagine del problema" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Eventuele aanvullende context die u wilt dat de assistent weet. Maximaal 500 tekens.", - "state" : "translated" + "state" : "translated", + "value" : "問題の画像" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Toute information supplémentaire que vous souhaitez que l’assistant connaisse. Maximum 500 caractères." + "value" : "Afbeelding van het probleem" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Zusätzliche Informationen, die Sie dem Assistenten mitteilen möchten. Maximal 500 Zeichen.", - "state" : "translated" + "state" : "translated", + "value" : "Imagem do Problema" } }, "sv" : { "stringUnit" : { - "value" : "Eventuell ytterligare information du vill att assistenten ska känna till. Max 500 tecken.", - "state" : "translated" + "state" : "translated", + "value" : "Bild på problemet" } } - }, - "comment" : "A description of the extra information section." + } }, - "Drag image here" : { + "Jump back into your latest conversation." : { + "comment" : "Description of the widget that opens the most recently updated conversation in OpenClient.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ここに画像をドラッグしてください", - "state" : "translated" + "state" : "translated", + "value" : "Springe zurück zu deinem letzten Gespräch." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Arrastra la imagen aquí" + "value" : "Επιστροφή στην πιο πρόσφατη συνομιλία σας." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Σύρετε την εικόνα εδώ" + "value" : "Jump back into your latest conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Trascina l'immagine qui", - "state" : "translated" + "state" : "translated", + "value" : "Vuelve a tu última conversación." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Arraste a imagem aqui", - "state" : "translated" + "state" : "translated", + "value" : "Reprenez votre dernière conversation." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Drag image here", - "state" : "translated" + "state" : "translated", + "value" : "Ritorna alla tua ultima conversazione." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Glissez l’image ici", - "state" : "translated" + "state" : "translated", + "value" : "最新の会話に戻る" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sleep afbeelding hierheen" + "value" : "Ga terug naar je laatste gesprek." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Bild hierher ziehen", - "state" : "translated" + "state" : "translated", + "value" : "Voltar à sua conversa mais recente." } }, "sv" : { "stringUnit" : { - "value" : "Dra bilden hit", - "state" : "translated" + "state" : "translated", + "value" : "Hoppa tillbaka till din senaste konversation." } } } }, - "Server URL" : { + "Keep it short and descriptive" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Server-URL", - "state" : "translated" + "state" : "translated", + "value" : "Kurz und prägnant" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "URL del servidor" + "value" : "Κρατήστε το σύντομο και περιγραφικό" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Server-URL" + "value" : "Keep it short and descriptive" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "URL del server", - "state" : "translated" + "state" : "translated", + "value" : "Sé breve y descriptivo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "URL do servidor", - "state" : "translated" + "state" : "translated", + "value" : "Soyez bref et descriptif" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Server URL", - "state" : "translated" + "state" : "translated", + "value" : "Mantienilo breve e descrittivo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Server-URL", - "state" : "translated" + "state" : "translated", + "value" : "短く分かりやすく" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "URL du serveur" + "value" : "Houd het kort en duidelijk" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバーURL", - "state" : "translated" + "state" : "translated", + "value" : "Seja breve e descritivo" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Διεύθυνση URL διακομιστή", - "state" : "translated" + "state" : "translated", + "value" : "Håll det kort och beskrivande" } } } }, - "Edit Message" : { + "Keep track of context" : { + "comment" : "A tip that explains how OpenClient may summarise or exclude older messages without removing them from your history.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Επεξεργασία μηνύματος", - "state" : "translated" + "state" : "translated", + "value" : "Kontext im Blick behalten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Editar mensaje", - "state" : "translated" + "state" : "translated", + "value" : "Παρακολουθήστε το πλαίσιο" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "メッセージを編集" + "value" : "Keep track of context" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Modifica messaggio", - "state" : "translated" + "state" : "translated", + "value" : "Mantén el seguimiento del contexto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Editar Mensagem", - "state" : "translated" + "state" : "translated", + "value" : "Suivez le contexte" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Edit Message", - "state" : "translated" + "state" : "translated", + "value" : "Tieni traccia del contesto" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Bericht bewerken" + "value" : "コンテキストを追跡する" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Modifier le message" + "value" : "Houd de context bij" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Nachricht bearbeiten", - "state" : "translated" + "state" : "translated", + "value" : "Acompanhe o contexto" } }, "sv" : { "stringUnit" : { - "value" : "Redigera meddelande", - "state" : "translated" + "state" : "translated", + "value" : "Håll koll på sammanhanget" } } - }, - "comment" : "A label for the view that appears when editing a message." + } }, - "New suggestion" : { + "Keep your important conversations close at hand." : { + "comment" : "Description of the Pinned Conversations widget.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "新しい提案", - "state" : "translated" + "state" : "translated", + "value" : "Behalte deine wichtigen Unterhaltungen griffbereit." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nueva sugerencia", - "state" : "translated" + "state" : "translated", + "value" : "Κρατήστε τις σημαντικές συνομιλίες σας κοντά σας." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Νέα πρόταση" + "value" : "Keep your important conversations close at hand." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuovo suggerimento", - "state" : "translated" + "state" : "translated", + "value" : "Mantén tus conversaciones importantes a mano." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nova sugestão" + "value" : "Gardez vos conversations importantes à portée de main." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New suggestion", - "state" : "translated" + "state" : "translated", + "value" : "Tieni le tue conversazioni importanti sempre a portata di mano." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nouvelle suggestion", - "state" : "translated" + "state" : "translated", + "value" : "重要な会話をすぐにアクセスできる場所に保ちましょう" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nieuwe suggestie" + "value" : "Houd je belangrijke gesprekken binnen handbereik." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Neuer Vorschlag", - "state" : "translated" + "state" : "translated", + "value" : "Tenha as suas conversas importantes sempre à mão." } }, "sv" : { "stringUnit" : { - "value" : "Nytt förslag", - "state" : "translated" + "state" : "translated", + "value" : "Ha dina viktiga konversationer nära till hands." } } } }, - "Generated Image" : { + "Leave empty to submit anonymously" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Παραγόμενη εικόνα" + "value" : "Leer lassen, um anonym zu senden" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Genererad bild" + "value" : "Αφήστε κενό για ανώνυμη υποβολή" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Imagen generada" + "value" : "Leave empty to submit anonymously" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Immagine generata", - "state" : "translated" + "state" : "translated", + "value" : "Dejar vacío para enviar de forma anónima" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Imagem Gerada", - "state" : "translated" + "state" : "translated", + "value" : "Laisser vide pour soumettre anonymement" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Generated Image", - "state" : "translated" + "state" : "translated", + "value" : "Lascia vuoto per inviare in modo anonimo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gegenereerde afbeelding", - "state" : "translated" + "state" : "translated", + "value" : "匿名で送信するには空欄のままにしてください" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Image générée", - "state" : "translated" + "state" : "translated", + "value" : "Laat leeg om anoniem te verzenden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "生成画像", - "state" : "translated" + "state" : "translated", + "value" : "Deixe vazio para enviar anonimamente" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Generiertes Bild", - "state" : "translated" + "state" : "translated", + "value" : "Lämna tomt för att skicka anonymt" } } - }, - "comment" : "Name of the image attachment displayed in the chat." + } }, - "Model Info" : { + "Let the model find current information and include the sources it used." : { + "comment" : "A description of the Web Search feature.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Πληροφορίες Μοντέλου", - "state" : "translated" + "state" : "translated", + "value" : "Lassen Sie das Modell aktuelle Informationen finden und die verwendeten Quellen angeben." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Información del modelo", - "state" : "translated" + "state" : "translated", + "value" : "Αφήστε το μοντέλο να βρει τρέχουσες πληροφορίες και να συμπεριλάβει τις πηγές που χρησιμοποίησε." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "モデル情報" + "value" : "Allow the model to find current information and include the sources it used." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Informazioni sul modello", - "state" : "translated" + "state" : "translated", + "value" : "Permite que el modelo busque información actual e incluya las fuentes que utilizó." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Informações do Modelo", - "state" : "translated" + "state" : "translated", + "value" : "Laissez le modèle trouver des informations actuelles et inclure les sources utilisées." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Model Info" + "value" : "Lascia che il modello trovi informazioni aggiornate e includa le fonti utilizzate." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Modelinformatie", - "state" : "translated" + "state" : "translated", + "value" : "モデルに最新情報を検索させ、使用した情報源を含めるようにします。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Infos sur le modèle" + "value" : "Laat het model actuele informatie vinden en de gebruikte bronnen vermelden." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Modellinformationen", - "state" : "translated" + "state" : "translated", + "value" : "Deixe o modelo encontrar informações atuais e incluir as fontes que utilizou." } }, "sv" : { "stringUnit" : { - "value" : "Modellinformation", - "state" : "translated" + "state" : "translated", + "value" : "Låt modellen hitta aktuell information och inkludera de källor den använde." } } - }, - "comment" : "A title for a screen that shows information about a specific LLM model." + } }, - "Server error (code %lld)." : { + "Listen" : { + "comment" : "A button that triggers the speech-to-text feature.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Σφάλμα διακομιστή (κωδικός %lld).", - "state" : "translated" + "state" : "translated", + "value" : "Anhören" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Error del servidor (código %lld).", - "state" : "translated" + "state" : "translated", + "value" : "Άκουσμα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Serverfel (kod %lld)." + "value" : "Listen" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Errore del server (codice %lld)." + "value" : "Escuchar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Erro do servidor (código %lld).", - "state" : "translated" + "state" : "translated", + "value" : "Écouter" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Server error (code %lld).", - "state" : "translated" + "state" : "translated", + "value" : "Ascolta" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Erreur serveur (code %lld).", - "state" : "translated" + "state" : "translated", + "value" : "聞く" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Serverfout (code %lld)." + "value" : "Luisteren" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバーエラー(コード %lld)", - "state" : "translated" + "state" : "translated", + "value" : "Ouvir" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Serverfehler (Code %lld).", - "state" : "translated" + "state" : "translated", + "value" : "Lyssna" } } } }, - "Output" : { - "shouldTranslate" : false, - "comment" : "A label for the cost of output tokens." - }, - "Update OpenClient" : { + "Load Available Tools" : { + "comment" : "A button that fetches the list of search tools configured in the user's LiteLLM server.", "localizations" : { "de" : { "stringUnit" : { - "value" : "OpenClient aktualisieren", - "state" : "translated" + "state" : "translated", + "value" : "Verfügbare Werkzeuge laden" } }, "el" : { "stringUnit" : { - "value" : "Ενημέρωση του OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Φόρτωση Διαθέσιμων Εργαλείων" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Actualizar OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Load Available Tools" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiorna OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Cargar herramientas disponibles" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Atualizar o OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Charger les outils disponibles" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Update OpenClient" + "value" : "Carica strumenti disponibili" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Mettre à jour OpenClient" + "value" : "利用可能なツールを読み込む" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient bijwerken" + "value" : "Beschikbare tools laden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenClientをアップデート", - "state" : "translated" + "state" : "translated", + "value" : "Carregar Ferramentas Disponíveis" } }, "sv" : { "stringUnit" : { - "value" : "Uppdatera OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Ladda tillgängliga verktyg" } } - }, - "comment" : "A button that updates the OpenClient app." + } }, - "Saved to memory: %@" : { + "Loading comments..." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Sparat i minnet: %@" + "value" : "Kommentare werden geladen..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Guardado en la memoria: %@", - "state" : "translated" + "state" : "translated", + "value" : "Φόρτωση σχολίων..." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Αποθηκεύτηκε στη μνήμη: %@" + "value" : "Loading comments..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Salvato nella memoria: %@", - "state" : "translated" + "state" : "translated", + "value" : "Cargando comentarios..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Guardado na memória: %@", - "state" : "translated" + "state" : "translated", + "value" : "Chargement des commentaires..." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Saved to memory: %@" + "value" : "Caricamento commenti..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Opgeslagen in geheugen: %@", - "state" : "translated" + "state" : "translated", + "value" : "コメントを読み込み中..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Enregistré en mémoire : %@", - "state" : "translated" + "state" : "translated", + "value" : "Reacties laden..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メモリに保存されました: %@", - "state" : "translated" + "state" : "translated", + "value" : "A carregar comentários..." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "In den Speicher gespeichert: %@", - "state" : "translated" + "state" : "translated", + "value" : "Läser in kommentarer..." } } - }, - "comment" : "A message that is displayed when a piece of information is successfully saved to the user's memory. The argument is the content that was saved." + } }, - "Title" : { + "Loading image..." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Τίτλος", - "state" : "translated" + "state" : "translated", + "value" : "Bild wird geladen..." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Título" + "value" : "Φόρτωση εικόνας..." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Titel" + "value" : "Loading image..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Titolo", - "state" : "translated" + "state" : "translated", + "value" : "Cargando imagen..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Título", - "state" : "translated" + "state" : "translated", + "value" : "Chargement de l’image..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Title", - "state" : "translated" + "state" : "translated", + "value" : "Caricamento immagine..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Titel", - "state" : "translated" + "state" : "translated", + "value" : "画像を読み込み中..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Titre" + "value" : "Afbeelding laden..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "タイトル", - "state" : "translated" + "state" : "translated", + "value" : "A carregar imagem..." } }, "sv" : { "stringUnit" : { - "value" : "Titel", - "state" : "translated" + "state" : "translated", + "value" : "Laddar bild..." } } - }, - "comment" : "A label displayed above the title field." + } }, - "Reset" : { + "Loading more..." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Επαναφορά", - "state" : "translated" + "state" : "translated", + "value" : "Mehr laden..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Restablecer", - "state" : "translated" + "state" : "translated", + "value" : "Φόρτωση περισσότερων..." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "リセット" + "value" : "Loading more..." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Reimposta" + "value" : "Cargando más..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Repor" + "value" : "Chargement de plus..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Reset", - "state" : "translated" + "state" : "translated", + "value" : "Caricamento in corso..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Resetten", - "state" : "translated" + "state" : "translated", + "value" : "さらに読み込み中..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Réinitialiser", - "state" : "translated" + "state" : "translated", + "value" : "Meer laden..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Zurücksetzen", - "state" : "translated" + "state" : "translated", + "value" : "A carregar mais..." } }, "sv" : { "stringUnit" : { - "value" : "Återställ", - "state" : "translated" + "state" : "translated", + "value" : "Laddar mer..." } } } }, - "Error" : { + "Loading suggestions..." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "エラー", - "state" : "translated" + "state" : "translated", + "value" : "Vorschläge werden geladen..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Error", - "state" : "translated" + "state" : "translated", + "value" : "Φόρτωση προτάσεων..." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Fel" + "value" : "Loading suggestions..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Errore", - "state" : "translated" + "state" : "translated", + "value" : "Cargando sugerencias..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Erro", - "state" : "translated" + "state" : "translated", + "value" : "Chargement des suggestions..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Error", - "state" : "translated" + "state" : "translated", + "value" : "Caricamento suggerimenti..." } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Fout" + "value" : "提案を読み込み中..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Erreur" + "value" : "Suggesties laden..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Fehler", - "state" : "translated" + "state" : "translated", + "value" : "A carregar sugestões..." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Σφάλμα", - "state" : "translated" + "state" : "translated", + "value" : "Laddar förslag..." } } } }, - "Find past conversations" : { + "Loading tools..." : { + "comment" : "A loading message for MCP tools.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Βρείτε προηγούμενες συνομιλίες", - "state" : "translated" + "state" : "translated", + "value" : "Werkzeuge werden geladen..." } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Hitta tidigare konversationer" + "value" : "Φόρτωση εργαλείων..." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Buscar conversaciones pasadas" + "value" : "Loading tools..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Trova conversazioni passate", - "state" : "translated" + "state" : "translated", + "value" : "Cargando herramientas..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Encontrar conversas anteriores", - "state" : "translated" + "state" : "translated", + "value" : "Chargement des outils..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Find past conversations", - "state" : "translated" + "state" : "translated", + "value" : "Caricamento strumenti..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vind eerdere gesprekken", - "state" : "translated" + "state" : "translated", + "value" : "ツールを読み込み中..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rechercher des conversations passées" + "value" : "Tools laden..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "過去の会話を検索", - "state" : "translated" + "state" : "translated", + "value" : "A carregar ferramentas..." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Vergangene Unterhaltungen finden", - "state" : "translated" + "state" : "translated", + "value" : "Laddar verktyg..." } } - }, - "comment" : "Subtitle for the \"Search\" action button in the Quick Actions widget." + } }, - "Hide Content in App Switcher" : { + "Loading..." : { + "comment" : "A loading indicator displayed when fetching search tools.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Inhalt im App-Umschalter verbergen", - "state" : "translated" + "state" : "translated", + "value" : "Lädt..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Ocultar contenido en el selector de aplicaciones", - "state" : "translated" + "state" : "translated", + "value" : "Φόρτωση..." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Απόκρυψη περιεχομένου στον εναλλάκτη εφαρμογών" + "value" : "Loading..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nascondi contenuto nel selettore app", - "state" : "translated" + "state" : "translated", + "value" : "Cargando..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ocultar conteúdo no alternador de aplicações" + "value" : "Chargement..." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Hide Content in App Switcher" + "value" : "Caricamento..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Inhoud verbergen in app-wisselaar", - "state" : "translated" + "state" : "translated", + "value" : "読み込み中..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Masquer le contenu dans le sélecteur d’applications", - "state" : "translated" + "state" : "translated", + "value" : "Bezig met laden..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "Appスイッチャーでコンテンツを非表示", - "state" : "translated" + "state" : "translated", + "value" : "A carregar..." } }, "sv" : { "stringUnit" : { - "value" : "Dölj innehåll i appväxlaren", - "state" : "translated" + "state" : "translated", + "value" : "Läser in..." } } - }, - "comment" : "A toggle that hides app content when switching between apps." + } }, - "tag.thinking" : { + "Local" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "Lokal" } }, - "ja" : { + "el" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "Τοπικό" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Thinking" + "value" : "Local" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Thinking" + "value" : "Local" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "Local" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Thinking" + "value" : "Locale" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "ローカル" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "Lokaal" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "Localização" } }, "sv" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "Lokal" } } - }, - "comment" : "Label for a capability that allows the LLM to think and generate complex responses." + } }, - "Temperature" : { + "Long-press any message and tap \"Add to Favourites\" to save it here." : { + "comment" : "A description of the action to add a message to the favourites.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Θερμοκρασία" + "value" : "Halte eine Nachricht gedrückt und tippe auf „Zu Favoriten hinzufügen“, um sie hier zu speichern." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Temperatura", - "state" : "translated" + "state" : "translated", + "value" : "Πατήστε παρατεταμένα οποιοδήποτε μήνυμα και επιλέξτε «Προσθήκη στα Αγαπημένα» για να το αποθηκεύσετε εδώ." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Temperatur" + "value" : "Long-press any message and tap \"Add to Favorites\" to save it here." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Temperatura", - "state" : "translated" + "state" : "translated", + "value" : "Mantén pulsado cualquier mensaje y toca \"Añadir a Favoritos\" para guardarlo aquí." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Temperatura", - "state" : "translated" + "state" : "translated", + "value" : "Appuyez longuement sur un message et touchez « Ajouter aux favoris » pour l’enregistrer ici." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Temperature", - "state" : "translated" + "state" : "translated", + "value" : "Tieni premuto un messaggio e tocca \"Aggiungi ai Preferiti\" per salvarlo qui." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Temperatuur", - "state" : "translated" + "state" : "translated", + "value" : "メッセージを長押しして「お気に入りに追加」をタップすると、ここに保存されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Température" + "value" : "Houd een bericht ingedrukt en tik op \"Toevoegen aan favorieten\" om het hier op te slaan." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Temperatur", - "state" : "translated" + "state" : "translated", + "value" : "Pressione longamente qualquer mensagem e toque em \"Adicionar aos Favoritos\" para guardá-la aqui." } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "温度", - "state" : "translated" + "state" : "translated", + "value" : "Tryck länge på ett meddelande och tryck på \"Lägg till i favoriter\" för att spara det här." } } } }, - "Could not find the server. Please check the URL." : { + "Max Tokens" : { + "comment" : "A slider that lets the user adjust the maximum number of tokens.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Server konnte nicht gefunden werden. Bitte überprüfen Sie die URL.", - "state" : "translated" + "state" : "translated", + "value" : "Maximale Tokenanzahl" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudo encontrar el servidor. Por favor, verifica la URL.", - "state" : "translated" + "state" : "translated", + "value" : "Μέγιστοι χαρακτήρες" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーが見つかりません。URLを確認してください。" + "value" : "Max Tokens" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile trovare il server. Controlla l'URL.", - "state" : "translated" + "state" : "translated", + "value" : "Máximo de tokens" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Não foi possível encontrar o servidor. Por favor, verifique o URL.", - "state" : "translated" + "state" : "translated", + "value" : "Nombre maximal de jetons" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Could not find the server. Please check the URL.", - "state" : "translated" + "state" : "translated", + "value" : "Token massimi" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Kan de server niet vinden. Controleer de URL." + "value" : "最大トークン数" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Serveur introuvable. Veuillez vérifier l’URL." + "value" : "Maximaal aantal tokens" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δεν βρέθηκε ο διακομιστής. Ελέγξτε τη διεύθυνση URL.", - "state" : "translated" + "state" : "translated", + "value" : "Tokens Máximos" } }, "sv" : { "stringUnit" : { - "value" : "Kunde inte hitta servern. Kontrollera URL:en.", - "state" : "translated" + "state" : "translated", + "value" : "Maximalt antal token" } } } }, - "Configure your personal context and memory items to personalise model responses." : { + "Maximum number of tokens in the response." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Διαμορφώστε το προσωπικό σας πλαίσιο και τα στοιχεία μνήμης για να εξατομικεύσετε τις απαντήσεις του μοντέλου." + "value" : "Maximale Anzahl der Tokens in der Antwort." } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Konfigurera din personliga kontext och minnesobjekt för att anpassa modellens svar." + "value" : "Μέγιστος αριθμός συμβόλων στην απάντηση." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Configura tu contexto personal y elementos de memoria para personalizar las respuestas del modelo." + "value" : "Maximum number of tokens in the response" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Configura il tuo contesto personale e gli elementi di memoria per personalizzare le risposte del modello.", - "state" : "translated" + "state" : "translated", + "value" : "Número máximo de tokens en la respuesta." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Configure o seu contexto pessoal e itens de memória para personalizar as respostas do modelo.", - "state" : "translated" + "state" : "translated", + "value" : "Nombre maximal de jetons dans la réponse." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Configure your personal context and memory items to personalize model responses.", - "state" : "translated" + "state" : "translated", + "value" : "Numero massimo di token nella risposta." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Configureer je persoonlijke context- en geheugenitems om modelantwoorden te personaliseren.", - "state" : "translated" + "state" : "translated", + "value" : "応答の最大トークン数" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Configurez votre contexte personnel et vos éléments de mémoire pour personnaliser les réponses du modèle.", - "state" : "translated" + "state" : "translated", + "value" : "Maximaal aantal tokens in het antwoord" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "モデルの応答をパーソナライズするために、個人のコンテキストとメモリ項目を設定してください。", - "state" : "translated" + "state" : "translated", + "value" : "Número máximo de tokens na resposta." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Konfigurieren Sie Ihre persönlichen Kontext- und Speicherobjekte, um die Modellantworten zu personalisieren.", - "state" : "translated" + "state" : "translated", + "value" : "Maximalt antal tecken i svaret." } } - }, - "comment" : "A description of the personalization section." + } }, - "Are you sure you want to delete this conversation? This action cannot be undone." : { + "Maximum of 3 tags reached. Remove one to add another." : { + "comment" : "A message displayed when the user tries to add a tag when they've already reached the maximum of 3.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "この会話を削除してもよろしいですか?この操作は元に戻せません。" + "value" : "Maximal 3 Tags erreicht. Entferne einen, um einen weiteren hinzuzufügen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¿Seguro que quieres eliminar esta conversación? Esta acción no se puede deshacer.", - "state" : "translated" + "state" : "translated", + "value" : "Έχετε φτάσει το μέγιστο όριο των 3 ετικετών. Αφαιρέστε μία για να προσθέσετε άλλη." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Är du säker på att du vill radera den här konversationen? Denna åtgärd kan inte ångras." + "value" : "Maximum of 3 tags reached. Remove one to add another." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Sei sicuro di voler eliminare questa conversazione? Questa azione non può essere annullata." + "value" : "Se alcanzó el máximo de 3 etiquetas. Elimina una para añadir otra." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tem a certeza de que pretende eliminar esta conversa? Esta ação não pode ser desfeita.", - "state" : "translated" + "state" : "translated", + "value" : "Nombre maximum de 3 tags atteint. Supprimez-en un pour en ajouter un autre." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Are you sure you want to delete this conversation? This action cannot be undone.", - "state" : "translated" + "state" : "translated", + "value" : "Raggiunto il massimo di 3 tag. Rimuovi uno per aggiungerne un altro." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Weet u zeker dat u dit gesprek wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", - "state" : "translated" + "state" : "translated", + "value" : "タグは最大3つまでです。追加するには1つ削除してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Voulez-vous vraiment supprimer cette conversation ? Cette action est irréversible.", - "state" : "translated" + "state" : "translated", + "value" : "Maximum van 3 tags bereikt. Verwijder er één om een nieuwe toe te voegen." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή τη συνομιλία; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", - "state" : "translated" + "state" : "translated", + "value" : "Máximo de 3 etiquetas atingido. Remova uma para adicionar outra." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Möchten Sie diese Unterhaltung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", - "state" : "translated" + "state" : "translated", + "value" : "Maximalt 3 taggar nådda. Ta bort en för att lägga till en annan." } } - }, - "comment" : "A confirmation dialog message for deleting a conversation." + } }, - "Plan the next project" : { + "MCP Servers" : { + "comment" : "A button that dismisses the MCP Tools sheet.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Planera nästa projekt" + "value" : "MCP-Server" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Planificar el próximo proyecto", - "state" : "translated" + "state" : "translated", + "value" : "Διακομιστές MCP" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "次のプロジェクトを計画する" + "value" : "MCP Servers" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Pianifica il prossimo progetto", - "state" : "translated" + "state" : "translated", + "value" : "Servidores MCP" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Planear o próximo projeto", - "state" : "translated" + "state" : "translated", + "value" : "Serveurs MCP" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Plan the next project", - "state" : "translated" + "state" : "translated", + "value" : "Server MCP" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Plan het volgende project", - "state" : "translated" + "state" : "translated", + "value" : "MCPサーバー" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Planifier le prochain projet" + "value" : "MCP-servers" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Das nächste Projekt planen", - "state" : "translated" + "state" : "translated", + "value" : "Servidores MCP" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Σχεδίαση του επόμενου έργου", - "state" : "translated" + "state" : "translated", + "value" : "MCP-servrar" } } - }, - "comment" : "Title of a conversation." + } }, - "Mint" : { + "MCP servers are configured in your LiteLLM server. Fetch to see what's available and toggle them on or off." : { + "comment" : "A description of MCP servers.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Münze", - "state" : "translated" + "state" : "translated", + "value" : "MCP-Server sind in Ihrem LiteLLM-Server konfiguriert. Abrufen, um zu sehen, was verfügbar ist, und sie ein- oder auszuschalten." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Menta", - "state" : "translated" + "state" : "translated", + "value" : "Οι διακομιστές MCP έχουν ρυθμιστεί στον διακομιστή LiteLLM σας. Φέρτε τα για να δείτε τι είναι διαθέσιμο και ενεργοποιήστε ή απενεργοποιήστε τα." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ミント" + "value" : "MCP servers are configured in your LiteLLM server. Fetch to see what's available and toggle them on or off." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Menta", - "state" : "translated" + "state" : "translated", + "value" : "Los servidores MCP están configurados en tu servidor LiteLLM. Obtén la información para ver qué está disponible y actívalos o desactívalos." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Menta" + "value" : "Les serveurs MCP sont configurés dans votre serveur LiteLLM. Récupérez-les pour voir ce qui est disponible et activez-les ou désactivez-les." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Mint" + "value" : "I server MCP sono configurati nel tuo server LiteLLM. Recupera per vedere cosa è disponibile e attivali o disattivali." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Menthe", - "state" : "translated" + "state" : "translated", + "value" : "MCPサーバーはLiteLLMサーバーで設定されています。利用可能なものを取得してオンまたはオフに切り替えてください。" } }, "nl" : { "stringUnit" : { - "value" : "Munt", - "state" : "translated" + "state" : "translated", + "value" : "MCP-servers zijn geconfigureerd in je LiteLLM-server. Ophalen om te zien wat beschikbaar is en ze aan- of uitzetten." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Μέντα", - "state" : "translated" + "state" : "translated", + "value" : "Os servidores MCP estão configurados no seu servidor LiteLLM. Atualize para ver o que está disponível e ative-os ou desative-os." } }, "sv" : { "stringUnit" : { - "value" : "Mynta", - "state" : "translated" + "state" : "translated", + "value" : "MCP-servrar är konfigurerade i din LiteLLM-server. Hämta för att se vad som finns tillgängligt och slå på eller av dem." } } - }, - "comment" : "Name of a tag color." + } }, - "iCloud Sync" : { + "MCP Servers Unavailable" : { + "comment" : "A label that describes the unavailable state of the MCP servers.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud同期" + "value" : "MCP-Server nicht verfügbar" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud-synkronisering" + "value" : "Οι διακομιστές MCP δεν είναι διαθέσιμοι" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sincronización iCloud" + "value" : "MCP Servers Unavailable" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sincronizzazione iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Servidores MCP no disponibles" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sincronização iCloud", - "state" : "translated" + "state" : "translated", + "value" : "Serveurs MCP indisponibles" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "iCloud Sync", - "state" : "translated" + "state" : "translated", + "value" : "Server MCP non disponibili" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "iCloud-synchronisatie", - "state" : "translated" + "state" : "translated", + "value" : "MCPサーバー利用不可" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Synchronisation iCloud", - "state" : "translated" + "state" : "translated", + "value" : "MCP-servers niet beschikbaar" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "iCloud-Synchronisierung", - "state" : "translated" + "state" : "translated", + "value" : "Servidores MCP Indisponíveis" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Συγχρονισμός iCloud", - "state" : "translated" + "state" : "translated", + "value" : "MCP-servrar otillgängliga" } } } }, - "Deleted from memory: %@" : { + "Media & Files" : { + "comment" : "A button that displays a sheet for selecting and viewing media files and attachments.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Διαγράφηκε από τη μνήμη: %@", - "state" : "translated" + "state" : "translated", + "value" : "Medien & Dateien" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eliminado de la memoria: %@", - "state" : "translated" + "state" : "translated", + "value" : "Μέσα & Αρχεία" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Aus dem Speicher gelöscht: %@" + "value" : "Media & Files" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Eliminato dalla memoria: %@", - "state" : "translated" + "state" : "translated", + "value" : "Medios y archivos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Eliminado da memória: %@" + "value" : "Médias et fichiers" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Deleted from memory: %@", - "state" : "translated" + "state" : "translated", + "value" : "Media e file" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Supprimé de la mémoire : %@", - "state" : "translated" + "state" : "translated", + "value" : "メディアとファイル" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijderd uit geheugen: %@" + "value" : "Media en bestanden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メモリから削除しました: %@", - "state" : "translated" + "state" : "translated", + "value" : "Média e Ficheiros" } }, "sv" : { "stringUnit" : { - "value" : "Borttaget från minnet: %@", - "state" : "translated" + "state" : "translated", + "value" : "Media och filer" } } - }, - "comment" : "A notification that a memory item has been deleted. The argument is the content of the memory item." + } }, - "Imported %lld conversations and restored %lld attachments." : { + "Memory" : { + "comment" : "A title for a screen that lists and manages user-created notes.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Importerade %1$lld konversationer och återställde %2$lld bilagor." + "value" : "Notizen" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Εισήχθησαν %1$lld συνομιλίες και αποκαταστάθηκαν %2$lld συνημμένα." + "value" : "Μνήμη" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Se importaron %1$lld conversaciones y se restauraron %2$lld archivos adjuntos." + "value" : "Notes" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Importate %1$lld conversazioni e ripristinati %2$lld allegati.", - "state" : "translated" + "state" : "translated", + "value" : "Memoria" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Importadas %1$lld conversas e restaurados %2$lld anexos.", - "state" : "translated" + "state" : "translated", + "value" : "Mémoire" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Imported %1$lld conversations and restored %2$lld attachments.", - "state" : "new" + "state" : "translated", + "value" : "Memoria" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%1$lld gesprekken geïmporteerd en %2$lld bijlagen hersteld.", - "state" : "translated" + "state" : "translated", + "value" : "メモリー" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "%1$lld conversations importées et %2$lld pièces jointes restaurées.", - "state" : "translated" + "state" : "translated", + "value" : "Geheugen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "%1$lld 件の会話をインポートし、%2$lld 件の添付ファイルを復元しました。", - "state" : "translated" + "state" : "translated", + "value" : "Memórias" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "%1$lld Konversationen importiert und %2$lld Anhänge wiederhergestellt.", - "state" : "translated" + "state" : "translated", + "value" : "Anteckningar" } } } }, - "%lld" : { + "Memory Content" : { + "comment" : "A label displayed above the text field for the memory content.", "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "value" : "%lld", - "state" : "translated" + "state" : "translated", + "value" : "Speicherinhalt" } - } - }, - "shouldTranslate" : false, - "comment" : "A label displaying the number of search results. The argument is the number of search results." - }, - "Explain why this feature would be useful" : { - "localizations" : { + }, "el" : { "stringUnit" : { - "value" : "Εξηγήστε γιατί αυτή η λειτουργία θα ήταν χρήσιμη", - "state" : "translated" + "state" : "translated", + "value" : "Περιεχόμενο μνήμης" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Explica por qué esta función sería útil" + "value" : "Memory Content" } }, - "ja" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "この機能が役立つ理由を説明してください" + "value" : "Contenido de la memoria" } }, - "it" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Spiega perché questa funzione sarebbe utile" + "value" : "Contenu de la mémoire" } }, - "pt-PT" : { + "it" : { "stringUnit" : { - "value" : "Explique por que esta funcionalidade seria útil", - "state" : "translated" + "state" : "translated", + "value" : "Contenuto della memoria" } }, - "en" : { + "ja" : { "stringUnit" : { - "value" : "Explain why this feature would be useful", - "state" : "translated" + "state" : "translated", + "value" : "メモリ内容" } }, "nl" : { "stringUnit" : { - "value" : "Leg uit waarom deze functie nuttig zou zijn", - "state" : "translated" - } - }, - "fr" : { - "stringUnit" : { - "value" : "Expliquez pourquoi cette fonctionnalité serait utile", - "state" : "translated" + "state" : "translated", + "value" : "Geheugeninhoud" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Erklären Sie, warum diese Funktion nützlich wäre", - "state" : "translated" + "state" : "translated", + "value" : "Conteúdo da Memória" } }, "sv" : { "stringUnit" : { - "value" : "Förklara varför denna funktion skulle vara användbar", - "state" : "translated" + "state" : "translated", + "value" : "Minnesinnehåll" } } } }, - "The latest turn exceeds the available context" : { + "Memory could not be synchronized. Your local items are retained." : { + "comment" : "Error message displayed when an error occurs during synchronization.", + "isCommentAutoGenerated" : true + }, + "Message" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η τελευταία κίνηση υπερβαίνει το διαθέσιμο πλαίσιο", - "state" : "translated" + "state" : "translated", + "value" : "Nachricht" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El último turno supera el contexto disponible", - "state" : "translated" + "state" : "translated", + "value" : "Μήνυμα" } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "Det senaste draget överskrider det tillgängliga sammanhanget", - "state" : "translated" + "state" : "translated", + "value" : "Message" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "L'ultimo turno supera il contesto disponibile", - "state" : "translated" + "state" : "translated", + "value" : "Mensaje" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A última jogada excede o contexto disponível", - "state" : "translated" + "state" : "translated", + "value" : "Message" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The latest turn exceeds the available context" + "value" : "Messaggio" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Le dernier tour dépasse le contexte disponible" + "value" : "メッセージ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "De laatste beurt overschrijdt de beschikbare context" + "value" : "Bericht" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "最新のターンが利用可能なコンテキストを超えています", - "state" : "translated" + "state" : "translated", + "value" : "Mensagem" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Der letzte Zug überschreitet den verfügbaren Kontext", - "state" : "translated" + "state" : "translated", + "value" : "Meddelande" } } } }, - "Data Analyst" : { + "Message..." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "データアナリスト", - "state" : "translated" + "state" : "translated", + "value" : "Nachricht..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Analista de datos", - "state" : "translated" + "state" : "translated", + "value" : "Μήνυμα..." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Αναλυτής Δεδομένων" + "value" : "Message..." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Analista Dati" + "value" : "Mensaje..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Analista de Dados", - "state" : "translated" + "state" : "translated", + "value" : "Message..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Data Analyst", - "state" : "translated" + "state" : "translated", + "value" : "Messaggio..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Data-analist", - "state" : "translated" + "state" : "translated", + "value" : "メッセージ..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Analyste de données" + "value" : "Bericht..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Datenanalyst", - "state" : "translated" + "state" : "translated", + "value" : "Mensagem..." } }, "sv" : { "stringUnit" : { - "value" : "Dataanalytiker", - "state" : "translated" + "state" : "translated", + "value" : "Meddelande..." } } - }, - "comment" : "Description of a prompt template for a data analyst assistant." + } }, - "Sync could not finish. Your changes remain safely stored on this device." : { + "Mint" : { + "comment" : "Name of a tag color.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Synkroniseringen kunde inte slutföras. Dina ändringar är säkert sparade på den här enheten." + "value" : "Münze" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La sincronización no pudo completarse. Tus cambios permanecen guardados de forma segura en este dispositivo.", - "state" : "translated" + "state" : "translated", + "value" : "Μέντα" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ο συγχρονισμός δεν ολοκληρώθηκε. Οι αλλαγές σας παραμένουν αποθηκευμένες με ασφάλεια σε αυτή τη συσκευή." + "value" : "Mint" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "La sincronizzazione non è riuscita. Le tue modifiche sono comunque salvate in modo sicuro su questo dispositivo." + "value" : "Menta" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A sincronização não pôde ser concluída. As suas alterações permanecem guardadas com segurança neste dispositivo.", - "state" : "translated" + "state" : "translated", + "value" : "Menthe" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Sync could not complete. Your changes are safely stored on this device.", - "state" : "translated" + "state" : "translated", + "value" : "Menta" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Synchronisatie kon niet worden voltooid. Je wijzigingen zijn veilig opgeslagen op dit apparaat.", - "state" : "translated" + "state" : "translated", + "value" : "ミント" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "La synchronisation n’a pas pu se terminer. Vos modifications restent en sécurité sur cet appareil.", - "state" : "translated" + "state" : "translated", + "value" : "Munt" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "同期を完了できませんでした。変更内容はこのデバイスに安全に保存されています。", - "state" : "translated" + "state" : "translated", + "value" : "Menta" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Die Synchronisierung konnte nicht abgeschlossen werden. Ihre Änderungen sind sicher auf diesem Gerät gespeichert.", - "state" : "translated" + "state" : "translated", + "value" : "Mynta" } } - }, - "comment" : "A description of a failed iCloud sync." + } }, - "You are a professional email writing assistant. Draft clear, concise, and appropriately toned emails based on the user's brief. Adapt the tone (formal, casual, or persuasive) to the context described." : { + "Model" : { + "comment" : "A label for a memory item that was generated by the model.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Du är en professionell assistent för e-postskrivning. Skapa tydliga, koncisa och passande tonade e-postmeddelanden baserat på användarens sammanfattning. Anpassa tonen (formell, avslappnad eller övertygande) efter den beskrivna kontexten." + "value" : "Modell" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eres un asistente profesional para redactar correos electrónicos. Redacta correos claros, concisos y con el tono adecuado según el resumen del usuario. Adapta el tono (formal, informal o persuasivo) al contexto descrito.", - "state" : "translated" + "state" : "translated", + "value" : "Μοντέλο" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "あなたはプロのメール作成アシスタントです。ユーザーの要望に基づき、明確で簡潔かつ適切なトーンのメールを作成します。状況に応じてトーン(フォーマル、カジュアル、説得力のある)を調整します。" + "value" : "Model" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sei un assistente professionale per la scrittura di email. Redigi email chiare, concise e con un tono adeguato in base al breve riassunto fornito dall’utente. Adatti il tono (formale, informale o persuasivo) al contesto descritto.", - "state" : "translated" + "state" : "translated", + "value" : "Modelo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "É um assistente profissional de redação de emails. Elabore emails claros, concisos e com o tom adequado com base no resumo do utilizador. Adapte o tom (formal, informal ou persuasivo) ao contexto descrito.", - "state" : "translated" + "state" : "translated", + "value" : "Modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "You are a professional email writing assistant. Draft clear, concise, and appropriately toned emails based on the user's brief. Adapt the tone (formal, casual, or persuasive) to the context described.", - "state" : "translated" + "state" : "translated", + "value" : "Modello" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je bent een professionele e-mailassistent. Stel heldere, beknopte en passend getoonde e-mails op op basis van de samenvatting van de gebruiker. Pas de toon (formeel, informeel of overtuigend) aan op de beschreven context.", - "state" : "translated" + "state" : "translated", + "value" : "モデル" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vous êtes un assistant professionnel de rédaction d’e-mails. Rédigez des e-mails clairs, concis et au ton approprié selon le résumé de l’utilisateur. Adaptez le ton (formel, informel ou persuasif) au contexte décrit." + "value" : "Model" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Sie sind ein professioneller Assistent zum Verfassen von E-Mails. Erstellen Sie klare, prägnante und angemessen formulierte E-Mails basierend auf der Kurzzusammenfassung des Nutzers. Passen Sie den Ton (formell, locker oder überzeugend) an den beschriebenen Kontext an.", - "state" : "translated" + "state" : "translated", + "value" : "Modelo" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Είστε επαγγελματίας βοηθός σύνταξης email. Δημιουργήστε σαφή, συνοπτικά και κατάλληλα διατυπωμένα email βάσει της περίληψης του χρήστη. Προσαρμόστε τον τόνο (επίσημο, ανεπίσημο ή πειστικό) ανάλογα με το περιγραφόμενο πλαίσιο.", - "state" : "translated" + "state" : "translated", + "value" : "Modell" } } - }, - "comment" : "Description of an email composer prompt template." + } }, - "Resend" : { + "Model Info" : { + "comment" : "A title for a screen that shows information about a specific LLM model.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Skicka igen", - "state" : "translated" + "state" : "translated", + "value" : "Modellinformationen" } }, - "ja" : { + "el" : { "stringUnit" : { - "value" : "再送信", - "state" : "translated" + "state" : "translated", + "value" : "Πληροφορίες Μοντέλου" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Reenviar", - "state" : "translated" + "state" : "translated", + "value" : "Model Info" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Reinvia" + "value" : "Información del modelo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Reenviar" + "value" : "Infos sur le modèle" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Resend" + "value" : "Informazioni sul modello" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Renvoyer", - "state" : "translated" + "state" : "translated", + "value" : "モデル情報" } }, "nl" : { "stringUnit" : { - "value" : "Opnieuw verzenden", - "state" : "translated" + "state" : "translated", + "value" : "Modelinformatie" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Erneut senden", - "state" : "translated" + "state" : "translated", + "value" : "Informações do Modelo" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αποστολή ξανά", - "state" : "translated" + "state" : "translated", + "value" : "Modellinformation" } } - }, - "comment" : "A button that resends a message." + } }, - "Add an **Open URLs** action." : { + "Model Parameters" : { + "comment" : "A title for a view that allows the user to configure the parameters of a chat model.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Lägg till en åtgärd för **Öppna URL:er**.", - "state" : "translated" + "state" : "translated", + "value" : "Modellparameter" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar una acción **Abrir URLs**." + "value" : "Παράμετροι Μοντέλου" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "**URLを開く**アクションを追加してください。" + "value" : "Model Parameters" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Aggiungi un’azione **Apri URL**." + "value" : "Parámetros del modelo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Adicionar uma ação **Abrir URLs**.", - "state" : "translated" + "state" : "translated", + "value" : "Paramètres du modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Add an **Open URLs** action", - "state" : "translated" + "state" : "translated", + "value" : "Parametri del modello" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voeg een **Open URL's**-actie toe.", - "state" : "translated" + "state" : "translated", + "value" : "モデルパラメータ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ajouter une action **Ouvrir des URL**.", - "state" : "translated" + "state" : "translated", + "value" : "Modelparameters" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Füge eine Aktion **URLs öffnen** hinzu.", - "state" : "translated" + "state" : "translated", + "value" : "Parâmetros do Modelo" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Προσθέστε μια ενέργεια **Άνοιγμα URL**.", - "state" : "translated" + "state" : "translated", + "value" : "Modellparametrar" } } - }, - "comment" : "Step 2 of creating a shortcut using the Shortcuts app." + } }, - "Update OpenClient to version %@ to continue using the app." : { + "Models" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ενημερώστε το OpenClient στην έκδοση %@ για να συνεχίσετε να χρησιμοποιείτε την εφαρμογή.", - "state" : "translated" + "state" : "translated", + "value" : "Modelle" } }, - "sv" : { + "el" : { "stringUnit" : { - "value" : "Uppdatera OpenClient till version %@ för att fortsätta använda appen.", - "state" : "translated" + "state" : "translated", + "value" : "Μοντέλα" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Actualiza OpenClient a la versión %@ para seguir usando la aplicación." + "value" : "Models" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiorna OpenClient alla versione %@ per continuare a utilizzare l’app.", - "state" : "translated" + "state" : "translated", + "value" : "Modelos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Atualize o OpenClient para a versão %@ para continuar a utilizar a aplicação." + "value" : "Modèles" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Update OpenClient to version %@ to continue using the app." + "value" : "Modelli" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Mettez OpenClient à jour vers la version %@ pour continuer à utiliser l’app.", - "state" : "translated" + "state" : "translated", + "value" : "モデル" } }, "nl" : { "stringUnit" : { - "value" : "Werk OpenClient bij naar versie %@ om de app te blijven gebruiken.", - "state" : "translated" + "state" : "translated", + "value" : "Modellen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アプリを引き続き使用するには、OpenClientをバージョン%@にアップデートしてください。", - "state" : "translated" + "state" : "translated", + "value" : "Modelos" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Aktualisieren Sie OpenClient auf Version %@, um die App weiterhin zu verwenden.", - "state" : "translated" + "state" : "translated", + "value" : "Modeller" } } - }, - "comment" : "A description of the update process." + } }, - "Connection successful" : { + "More" : { + "comment" : "A button that opens a menu with options to export and import conversations.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Verbindung erfolgreich", - "state" : "translated" + "state" : "translated", + "value" : "Mehr" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Σύνδεση επιτυχής" + "value" : "Περισσότερα" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Conexión exitosa" + "value" : "More" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Connessione riuscita", - "state" : "translated" + "state" : "translated", + "value" : "Más" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ligação bem-sucedida", - "state" : "translated" + "state" : "translated", + "value" : "Plus" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Connection successful", - "state" : "translated" + "state" : "translated", + "value" : "Altro" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Verbinding geslaagd", - "state" : "translated" + "state" : "translated", + "value" : "もっと見る" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Connexion réussie" + "value" : "Meer" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "接続に成功しました", - "state" : "translated" + "state" : "translated", + "value" : "Mais" } }, "sv" : { "stringUnit" : { - "value" : "Anslutning lyckades", - "state" : "translated" + "state" : "translated", + "value" : "Mer" } } } }, - "Attach a photo or PDF so the model can analyse its content." : { + "More actions for messages" : { + "comment" : "A tip that shows when the user has enabled the message actions.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "写真またはPDFを添付して、モデルが内容を分析できるようにしてください。" + "value" : "Weitere Aktionen für Nachrichten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Adjunta una foto o PDF para que el modelo pueda analizar su contenido.", - "state" : "translated" + "state" : "translated", + "value" : "Περισσότερες ενέργειες για μηνύματα" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Επισυνάψτε μια φωτογραφία ή PDF ώστε το μοντέλο να αναλύσει το περιεχόμενό του." + "value" : "More actions for messages" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Allega una foto o un PDF in modo che il modello possa analizzarne il contenuto.", - "state" : "translated" + "state" : "translated", + "value" : "Más acciones para mensajes" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Anexe uma foto ou PDF para que o modelo possa analisar o seu conteúdo." + "value" : "Plus d’actions pour les messages" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Attach a photo or PDF so the model can analyze its content.", - "state" : "translated" + "state" : "translated", + "value" : "Altre azioni per i messaggi" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voeg een foto of PDF toe zodat het model de inhoud kan analyseren.", - "state" : "translated" + "state" : "translated", + "value" : "メッセージの追加操作" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Joignez une photo ou un PDF pour que le modèle puisse analyser son contenu.", - "state" : "translated" + "state" : "translated", + "value" : "Meer acties voor berichten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Fügen Sie ein Foto oder eine PDF-Datei an, damit das Modell den Inhalt analysieren kann.", - "state" : "translated" + "state" : "translated", + "value" : "Mais ações para mensagens" } }, "sv" : { "stringUnit" : { - "value" : "Bifoga ett foto eller en PDF så att modellen kan analysera dess innehåll.", - "state" : "translated" + "state" : "translated", + "value" : "Fler åtgärder för meddelanden" } } - }, - "comment" : "A description of how to attach images or PDFs to a message." + } }, - "Search conversations..." : { + "More Options" : { + "comment" : "A label for the \"More Options\" button.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "会話を検索...", - "state" : "translated" + "state" : "translated", + "value" : "Weitere Optionen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Buscar conversaciones...", - "state" : "translated" + "state" : "translated", + "value" : "Περισσότερες επιλογές" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Αναζήτηση συνομιλιών..." + "value" : "More Options" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Cerca conversazioni...", - "state" : "translated" + "state" : "translated", + "value" : "Más opciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Procurar conversas...", - "state" : "translated" + "state" : "translated", + "value" : "Plus d’options" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Search conversations..." + "value" : "Altre opzioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gesprekken zoeken...", - "state" : "translated" + "state" : "translated", + "value" : "その他のオプション" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rechercher des conversations..." + "value" : "Meer opties" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Konversationen durchsuchen...", - "state" : "translated" + "state" : "translated", + "value" : "Mais Opções" } }, "sv" : { "stringUnit" : { - "value" : "Sök konversationer...", - "state" : "translated" + "state" : "translated", + "value" : "Fler alternativ" } } } }, - "Custom" : { + "Name" : { + "comment" : "A label displayed above the user's name.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Benutzerdefiniert", - "state" : "translated" + "state" : "translated", + "value" : "Name" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Personalizado", - "state" : "translated" + "state" : "translated", + "value" : "Όνομα" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Προσαρμοσμένο" + "value" : "Name" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Personalizzato", - "state" : "translated" + "state" : "translated", + "value" : "Nombre" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Personalizado" + "value" : "Nom" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Custom", - "state" : "translated" + "state" : "translated", + "value" : "Nome" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Personnalisé", - "state" : "translated" + "state" : "translated", + "value" : "名前" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aangepast" + "value" : "Naam" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "カスタム", - "state" : "translated" + "state" : "translated", + "value" : "Nome" } }, "sv" : { "stringUnit" : { - "value" : "Anpassad", - "state" : "translated" + "state" : "translated", + "value" : "Namn" } } - }, - "comment" : "A section title for the user's custom prompt templates." + } }, - "Loading image..." : { + "New Chat" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Φόρτωση εικόνας...", - "state" : "translated" + "state" : "translated", + "value" : "Neuer Chat" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cargando imagen...", - "state" : "translated" + "state" : "translated", + "value" : "Νέα Συνομιλία" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bild wird geladen..." + "value" : "New Chat" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Caricamento immagine..." + "value" : "Nuevo chat" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A carregar imagem..." + "value" : "Nouveau chat" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Loading image...", - "state" : "translated" + "state" : "translated", + "value" : "Nuova chat" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeelding laden...", - "state" : "translated" + "state" : "translated", + "value" : "新しいチャット" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Chargement de l’image...", - "state" : "translated" + "state" : "translated", + "value" : "Nieuw gesprek" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "画像を読み込み中...", - "state" : "translated" + "state" : "translated", + "value" : "Nova Conversa" } }, "sv" : { "stringUnit" : { - "value" : "Laddar bild...", - "state" : "translated" + "state" : "translated", + "value" : "Ny chatt" } } } }, - "Edit Memory" : { + "New chat with a URL" : { + "comment" : "A description of how to open a chat with a URL using the URL scheme.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Erinnerung bearbeiten", - "state" : "translated" + "state" : "translated", + "value" : "Neuer Chat mit einer URL" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Editar memoria", - "state" : "translated" + "state" : "translated", + "value" : "Νέα συνομιλία με URL" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Επεξεργασία Μνήμης", - "state" : "translated" + "state" : "translated", + "value" : "New chat using a URL" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Modifica memoria", - "state" : "translated" + "state" : "translated", + "value" : "Nueva conversación con una URL" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Editar Memória" + "value" : "Nouvelle conversation avec une URL" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Edit Memory", - "state" : "translated" + "state" : "translated", + "value" : "Nuova chat con un URL" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Geheugen bewerken" + "value" : "URLで新しいチャットを開始" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Modifier la mémoire" + "value" : "Nieuw gesprek met een URL" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メモリを編集", - "state" : "translated" + "state" : "translated", + "value" : "Nova conversa com um URL" } }, "sv" : { "stringUnit" : { - "value" : "Redigera minne", - "state" : "translated" + "state" : "translated", + "value" : "Ny chatt med en URL" } } - }, - "comment" : "A title for a view that edits a memory item." + } }, - "More Options" : { + "New chat with text" : { + "comment" : "A description of how to open a new chat with a text message.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "その他のオプション" + "value" : "Neuer Chat mit Text" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Más opciones", - "state" : "translated" + "state" : "translated", + "value" : "Νέα συνομιλία με κείμενο" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Fler alternativ" + "value" : "New chat with text" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Altre opzioni", - "state" : "translated" + "state" : "translated", + "value" : "Nuevo chat con texto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mais Opções" + "value" : "Nouvelle conversation avec texte" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "More Options", - "state" : "translated" + "state" : "translated", + "value" : "Nuova chat con testo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Meer opties", - "state" : "translated" + "state" : "translated", + "value" : "テキストで新しいチャットを開始" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Plus d’options", - "state" : "translated" + "state" : "translated", + "value" : "Nieuw gesprek met tekst" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Περισσότερες επιλογές", - "state" : "translated" + "state" : "translated", + "value" : "Nova conversa com texto" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Weitere Optionen", - "state" : "translated" + "state" : "translated", + "value" : "Ny chatt med text" } } - }, - "comment" : "A label for the \"More Options\" button." + } }, - "This will be injected into every conversation's system prompt." : { + "New comment" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "これはすべての会話のシステムプロンプトに挿入されます。" + "value" : "Neuer Kommentar" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Esto se añadirá en el prompt del sistema de cada conversación.", - "state" : "translated" + "state" : "translated", + "value" : "Νέα σχόλια" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Detta kommer att injiceras i systemprompten för varje konversation." + "value" : "New comment" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Questo verrà inserito nel prompt di sistema di ogni conversazione.", - "state" : "translated" + "state" : "translated", + "value" : "Nuevo comentario" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Isto será inserido no prompt do sistema de cada conversa.", - "state" : "translated" + "state" : "translated", + "value" : "Nouveau commentaire" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "This will be injected into every conversation's system prompt.", - "state" : "translated" + "state" : "translated", + "value" : "Nuovo commento" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Dit wordt in de systeemopdracht van elk gesprek geïnjecteerd.", - "state" : "translated" + "state" : "translated", + "value" : "新しいコメント" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ceci sera injecté dans l’invite système de chaque conversation." + "value" : "Nieuwe opmerking" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Dies wird in die Systemaufforderung jedes Gesprächs eingefügt.", - "state" : "translated" + "state" : "translated", + "value" : "Novo comentário" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αυτό θα εισαχθεί στην προτροπή συστήματος κάθε συνομιλίας.", - "state" : "translated" + "state" : "translated", + "value" : "Ny kommentar" } } - }, - "comment" : "A description of the content of a memory." + } }, - "You're all set!" : { + "New Conversation" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Alles bereit!", - "state" : "translated" + "state" : "translated", + "value" : "Neues Gespräch" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¡Todo listo!", - "state" : "translated" + "state" : "translated", + "value" : "Νέα Συνομιλία" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Είστε έτοιμοι!" + "value" : "New Conversation" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tutto pronto!" + "value" : "Nueva conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Está tudo pronto!", - "state" : "translated" + "state" : "translated", + "value" : "Nouvelle conversation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "You're all set!", - "state" : "translated" + "state" : "translated", + "value" : "Nuova conversazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Tout est prêt !", - "state" : "translated" + "state" : "translated", + "value" : "新しい会話" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Je bent helemaal klaar!" + "value" : "Nieuw gesprek" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "準備完了です!", - "state" : "translated" + "state" : "translated", + "value" : "Nova Conversa" } }, "sv" : { "stringUnit" : { - "value" : "Allt är klart!", - "state" : "translated" + "state" : "translated", + "value" : "Ny konversation" } } - }, - "comment" : "A title displayed in the onboarding view when the server is ready." + } }, - "Report Issue" : { + "New Memory" : { + "comment" : "A label for a new memory item.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αναφορά προβλήματος", - "state" : "translated" + "state" : "translated", + "value" : "Neue Erinnerung" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Problem melden", - "state" : "translated" + "state" : "translated", + "value" : "Νέα Μνήμη" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reportar problema" + "value" : "New Memory" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Segnala problema" + "value" : "Nueva memoria" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Reportar problema", - "state" : "translated" + "state" : "translated", + "value" : "Nouvelle mémoire" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Report Issue", - "state" : "translated" + "state" : "translated", + "value" : "Nuova memoria" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Probleem melden", - "state" : "translated" + "state" : "translated", + "value" : "新しいメモリー" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Signaler un problème" + "value" : "Nieuwe herinnering" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "問題を報告する", - "state" : "translated" + "state" : "translated", + "value" : "Nova Memória" } }, "sv" : { "stringUnit" : { - "value" : "Rapportera problem", - "state" : "translated" + "state" : "translated", + "value" : "Nytt minne" } } } }, - "The message to fork from could not be found." : { + "New Private Chat" : { + "comment" : "A label for a button that opens a new private chat.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το μήνυμα για διακλάδωση δεν βρέθηκε.", - "state" : "translated" + "state" : "translated", + "value" : "Neuer privater Chat" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudo encontrar el mensaje del cual bifurcar.", - "state" : "translated" + "state" : "translated", + "value" : "Νέα Ιδιωτική Συνομιλία" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "フォーク元のメッセージが見つかりませんでした。" + "value" : "New Private Chat" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile trovare il messaggio da cui fare il fork.", - "state" : "translated" + "state" : "translated", + "value" : "Nuevo chat privado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A mensagem para a qual se pretende criar um fork não foi encontrada." + "value" : "Nouvelle discussion privée" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The message to fork from could not be found.", - "state" : "translated" + "state" : "translated", + "value" : "Nuova chat privata" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Het bericht om van te forken kon niet worden gevonden.", - "state" : "translated" + "state" : "translated", + "value" : "新しいプライベートチャット" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Le message à partir duquel bifurquer est introuvable." + "value" : "Nieuw privégesprek" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Die Nachricht, von der verzweigt werden soll, konnte nicht gefunden werden.", - "state" : "translated" + "state" : "translated", + "value" : "Nova Conversa Privada" } }, "sv" : { "stringUnit" : { - "value" : "Meddelandet att förgrena från kunde inte hittas.", - "state" : "translated" + "state" : "translated", + "value" : "Ny privatchatt" } } - }, - "comment" : "Error message displayed when the message to fork from cannot be found." + } }, - "One-time purchase · Doesn't unlock any features, everything is already free" : { + "New suggestion" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Einmaliger Kauf · Schaltet keine Funktionen frei, alles ist bereits kostenlos", - "state" : "translated" + "state" : "translated", + "value" : "Neuer Vorschlag" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Compra única · No desbloquea funciones, todo ya es gratis", - "state" : "translated" + "state" : "translated", + "value" : "Νέα πρόταση" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εφάπαξ αγορά · Δεν ξεκλειδώνει καμία λειτουργία, όλα είναι ήδη δωρεάν" + "value" : "New suggestion" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Acquisto una tantum · Non sblocca funzionalità, tutto è già gratuito" + "value" : "Nueva sugerencia" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Compra única · Não desbloqueia funcionalidades, tudo já é gratuito" + "value" : "Nouvelle suggestion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "One-time purchase · Does not unlock any features, everything is already free", - "state" : "translated" + "state" : "translated", + "value" : "Nuovo suggerimento" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Eenmalige aankoop · Ontgrendelt geen functies, alles is al gratis", - "state" : "translated" + "state" : "translated", + "value" : "新しい提案" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Achat unique · Ne débloque aucune fonctionnalité, tout est déjà gratuit", - "state" : "translated" + "state" : "translated", + "value" : "Nieuwe suggestie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "一度きりの購入 · 機能のロック解除はなく、すべて既に無料です", - "state" : "translated" + "state" : "translated", + "value" : "Nova sugestão" } }, "sv" : { "stringUnit" : { - "value" : "Engångsköp · Låser inte upp några funktioner, allt är redan gratis", - "state" : "translated" + "state" : "translated", + "value" : "Nytt förslag" } } - }, - "comment" : "A description of the benefits of purchasing a tip for OpenClient." + } }, - "Unpin" : { + "New Tag" : { + "comment" : "A label displayed above a text field to add a new tag.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ta bort fästning" + "value" : "Neues Tag" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Desfijar", - "state" : "translated" + "state" : "translated", + "value" : "Νέα ετικέτα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ピン留め解除" + "value" : "New Tag" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sblocca dalla barra", - "state" : "translated" + "state" : "translated", + "value" : "Nueva etiqueta" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Desafixar", - "state" : "translated" + "state" : "translated", + "value" : "Nouveau tag" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Unpin", - "state" : "translated" + "state" : "translated", + "value" : "Nuovo tag" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Détacher", - "state" : "translated" + "state" : "translated", + "value" : "新しいタグ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Losmaken" + "value" : "Nieuwe tag" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Anheften aufheben", - "state" : "translated" + "state" : "translated", + "value" : "Nova Etiqueta" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αποκόλληση", - "state" : "translated" + "state" : "translated", + "value" : "Ny tagg" } } - }, - "comment" : "A label for un-pinning a conversation." + } }, - "Images and documents you attach to messages will appear here." : { + "New Template" : { + "comment" : "A title for a view that creates or edits a prompt template.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bilder och dokument som du bifogar i meddelanden visas här." + "value" : "Neue Vorlage" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Las imágenes y documentos que adjuntes a los mensajes aparecerán aquí.", - "state" : "translated" + "state" : "translated", + "value" : "Νέο Πρότυπο" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bilder und Dokumente, die Sie Nachrichten anhängen, werden hier angezeigt." + "value" : "New Template" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Le immagini e i documenti che alleghi ai messaggi appariranno qui.", - "state" : "translated" + "state" : "translated", + "value" : "Nueva plantilla" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "As imagens e documentos que anexar às mensagens aparecerão aqui.", - "state" : "translated" + "state" : "translated", + "value" : "Nouveau modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Images and documents you attach to messages will appear here.", - "state" : "translated" + "state" : "translated", + "value" : "Nuovo Modello" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeeldingen en documenten die je aan berichten toevoegt, verschijnen hier.", - "state" : "translated" + "state" : "translated", + "value" : "新しいテンプレート" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Les images et documents que vous joignez aux messages apparaîtront ici." + "value" : "Nieuwe sjabloon" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Οι εικόνες και τα έγγραφα που επισυνάπτετε στα μηνύματα θα εμφανίζονται εδώ.", - "state" : "translated" + "state" : "translated", + "value" : "Novo Modelo" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "メッセージに添付した画像や書類はここに表示されます。", - "state" : "translated" + "state" : "translated", + "value" : "Ny mall" } } - }, - "comment" : "A description of the content of the view." + } }, - "Title (Minimum 3 characters)" : { + "No comments yet. Be the first to comment!" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Titel (Minst 3 tecken)" + "value" : "Noch keine Kommentare. Sei der Erste, der kommentiert!" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Título (mínimo 3 caracteres)", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχουν σχόλια ακόμα. Γίνε ο πρώτος που θα σχολιάσει!" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Τίτλος (Ελάχιστο 3 χαρακτήρες)" + "value" : "No comments yet. Be the first to comment!" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Titolo (Minimo 3 caratteri)", - "state" : "translated" + "state" : "translated", + "value" : "Aún no hay comentarios. ¡Sé el primero en comentar!" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Título (Mínimo 3 caracteres)", - "state" : "translated" + "state" : "translated", + "value" : "Pas encore de commentaires. Soyez le premier à commenter !" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Title (Minimum 3 characters)" + "value" : "Nessun commento ancora. Sii il primo a commentare!" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Titel (Minimaal 3 tekens)", - "state" : "translated" + "state" : "translated", + "value" : "まだコメントはありません。最初のコメントを投稿しましょう!" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Titre (Minimum 3 caractères)", - "state" : "translated" + "state" : "translated", + "value" : "Nog geen reacties. Wees de eerste die reageert!" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Titel (mindestens 3 Zeichen)", - "state" : "translated" + "state" : "translated", + "value" : "Ainda sem comentários. Seja o primeiro a comentar!" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "タイトル(最低3文字)", - "state" : "translated" + "state" : "translated", + "value" : "Inga kommentarer än. Var den första att kommentera!" } } } }, - "Right-click a conversation to pin, rename, or add tags." : { + "No Conversations" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "会話を右クリックしてピン留め、名前変更、タグ追加を行います。", - "state" : "translated" + "state" : "translated", + "value" : "Keine Unterhaltungen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Haz clic derecho en una conversación para anclar, renombrar o agregar etiquetas." + "value" : "Καμία συνομιλία" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Κάντε δεξί κλικ σε μια συνομιλία για καρφίτσωμα, μετονομασία ή προσθήκη ετικετών." + "value" : "No Conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Fai clic con il tasto destro su una conversazione per fissarla, rinominarla o aggiungere tag.", - "state" : "translated" + "state" : "translated", + "value" : "Sin conversaciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Clique com o botão direito numa conversa para fixar, renomear ou adicionar etiquetas.", - "state" : "translated" + "state" : "translated", + "value" : "Aucune conversation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Right-click a conversation to pin, rename, or add tags.", - "state" : "translated" + "state" : "translated", + "value" : "Nessuna conversazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Cliquez avec le bouton droit sur une conversation pour l’épingler, la renommer ou ajouter des tags.", - "state" : "translated" + "state" : "translated", + "value" : "会話なし" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Klik met de rechtermuisknop op een gesprek om vast te zetten, hernoemen of tags toe te voegen." + "value" : "Geen gesprekken" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Klicken Sie mit der rechten Maustaste auf eine Unterhaltung, um sie anzuheften, umzubenennen oder Tags hinzuzufügen.", - "state" : "translated" + "state" : "translated", + "value" : "Sem Conversas" } }, "sv" : { "stringUnit" : { - "value" : "Högerklicka på en konversation för att fästa, byta namn eller lägga till taggar.", - "state" : "translated" + "state" : "translated", + "value" : "Inga konversationer" } } } }, - "The model finished responding. Tap to continue." : { + "No conversations for this tag" : { + "comment" : "A message displayed when a tag has no conversations.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Das Modell hat die Antwort beendet. Tippen, um fortzufahren." + "value" : "Keine Unterhaltungen für dieses Schlagwort" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "El modelo terminó de responder. Toca para continuar." + "value" : "Δεν υπάρχουν συνομιλίες για αυτή την ετικέτα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "モデルの応答が完了しました。タップして続行してください。" + "value" : "No conversations for this tag" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il modello ha terminato la risposta. Tocca per continuare.", - "state" : "translated" + "state" : "translated", + "value" : "No hay conversaciones para esta etiqueta" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O modelo terminou de responder. Toque para continuar.", - "state" : "translated" + "state" : "translated", + "value" : "Aucune conversation pour cette étiquette" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The model finished responding. Tap to continue.", - "state" : "translated" + "state" : "translated", + "value" : "Nessuna conversazione per questo tag" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Het model is klaar met antwoorden. Tik om door te gaan.", - "state" : "translated" + "state" : "translated", + "value" : "このタグの会話はありません" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Le modèle a terminé de répondre. Touchez pour continuer.", - "state" : "translated" + "state" : "translated", + "value" : "Geen gesprekken voor deze tag" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Το μοντέλο ολοκλήρωσε την απάντηση. Πατήστε για συνέχεια.", - "state" : "translated" + "state" : "translated", + "value" : "Sem conversas para esta etiqueta" } }, "sv" : { "stringUnit" : { - "value" : "Modellen har slutat svara. Tryck för att fortsätta.", - "state" : "translated" + "state" : "translated", + "value" : "Inga konversationer för denna tagg" } } - }, - "comment" : "Text displayed in a notification when the LLM has finished responding." + } }, - "Maximum number of tokens in the response." : { + "No conversations found with the selected tag" : { + "comment" : "A message displayed when there are no conversations with a specific tag.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Maximale Anzahl der Tokens in der Antwort.", - "state" : "translated" + "state" : "translated", + "value" : "Keine Unterhaltungen mit dem ausgewählten Tag gefunden" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Maximalt antal tecken i svaret." + "value" : "Δεν βρέθηκαν συνομιλίες με την επιλεγμένη ετικέτα" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Número máximo de tokens en la respuesta." + "value" : "No conversations found with the selected tag" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Numero massimo di token nella risposta.", - "state" : "translated" + "state" : "translated", + "value" : "No se encontraron conversaciones con la etiqueta seleccionada" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Número máximo de tokens na resposta." + "value" : "Aucune conversation trouvée avec le tag sélectionné" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Maximum number of tokens in the response", - "state" : "translated" + "state" : "translated", + "value" : "Nessuna conversazione trovata con il tag selezionato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Maximaal aantal tokens in het antwoord", - "state" : "translated" + "state" : "translated", + "value" : "選択したタグの会話は見つかりませんでした" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Nombre maximal de jetons dans la réponse.", - "state" : "translated" + "state" : "translated", + "value" : "Geen gesprekken gevonden met het geselecteerde label" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "応答の最大トークン数", - "state" : "translated" + "state" : "translated", + "value" : "Nenhuma conversa encontrada com a etiqueta selecionada" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Μέγιστος αριθμός συμβόλων στην απάντηση.", - "state" : "translated" + "state" : "translated", + "value" : "Inga konversationer hittades med den valda taggen" } } } }, - "Personal Context" : { + "No conversations yet" : { + "comment" : "A message displayed when the user has no conversations.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "個人情報" + "value" : "Noch keine Unterhaltungen vorhanden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Contexto personal", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχουν συνομιλίες ακόμα" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Persönlicher Kontext" + "value" : "No conversations yet" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Contesto personale", - "state" : "translated" + "state" : "translated", + "value" : "No hay conversaciones aún" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Contexto Pessoal" + "value" : "Aucune conversation pour le moment" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Personal Context", - "state" : "translated" + "state" : "translated", + "value" : "Nessuna conversazione ancora" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Persoonlijke context", - "state" : "translated" + "state" : "translated", + "value" : "まだ会話はありません" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Contexte personnel", - "state" : "translated" + "state" : "translated", + "value" : "Nog geen gesprekken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προσωπικό Πλαίσιο", - "state" : "translated" + "state" : "translated", + "value" : "Ainda sem conversas" } }, "sv" : { "stringUnit" : { - "value" : "Personlig kontext", - "state" : "translated" + "state" : "translated", + "value" : "Inga konversationer än så länge" } } - }, - "comment" : "A button that opens a sheet for configuring the user's name and personal context." + } }, - "Translate text to another language" : { + "No Favourites Yet" : { + "comment" : "A message displayed when a user has no favourite messages.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "テキストを別の言語に翻訳する" + "value" : "Noch keine Favoriten vorhanden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Traducir texto a otro idioma", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχουν αγαπημένα ακόμα" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Μεταφράστε το κείμενο σε άλλη γλώσσα" + "value" : "No Favorites Yet" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Traduci testo in un'altra lingua", - "state" : "translated" + "state" : "translated", + "value" : "Sin favoritos aún" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Traduzir texto para outra língua", - "state" : "translated" + "state" : "translated", + "value" : "Aucun favori pour le moment" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Translate text to another language", - "state" : "translated" + "state" : "translated", + "value" : "Nessun preferito ancora" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Traduire le texte dans une autre langue", - "state" : "translated" + "state" : "translated", + "value" : "お気に入りはまだありません" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vertaal tekst naar een andere taal" + "value" : "Nog geen favorieten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Text in eine andere Sprache übersetzen", - "state" : "translated" + "state" : "translated", + "value" : "Sem Favoritos Ainda" } }, "sv" : { "stringUnit" : { - "value" : "Översätt text till ett annat språk", - "state" : "translated" + "state" : "translated", + "value" : "Inga favoriter än" } } } }, - "No Conversations" : { + "No internet connection. Please check your network." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Καμία συνομιλία" + "value" : "Keine Internetverbindung. Bitte überprüfen Sie Ihr Netzwerk." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sin conversaciones", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχει σύνδεση στο διαδίκτυο. Ελέγξτε το δίκτυό σας." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "会話なし" + "value" : "No internet connection. Please check your network." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessuna conversazione", - "state" : "translated" + "state" : "translated", + "value" : "Sin conexión a internet. Por favor, verifica tu red." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sem Conversas" + "value" : "Pas de connexion Internet. Veuillez vérifier votre réseau." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No Conversations", - "state" : "translated" + "state" : "translated", + "value" : "Nessuna connessione a Internet. Controlla la tua rete." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen gesprekken", - "state" : "translated" + "state" : "translated", + "value" : "インターネットに接続されていません。ネットワークを確認してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucune conversation", - "state" : "translated" + "state" : "translated", + "value" : "Geen internetverbinding. Controleer uw netwerk." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Keine Unterhaltungen", - "state" : "translated" + "state" : "translated", + "value" : "Sem ligação à internet. Verifique a sua rede." } }, "sv" : { "stringUnit" : { - "value" : "Inga konversationer", - "state" : "translated" + "state" : "translated", + "value" : "Ingen internetanslutning. Kontrollera ditt nätverk." } } } }, - "This file is not an OpenClient backup." : { + "No MCP servers configured. Add them in your LiteLLM server's config.yaml." : { + "comment" : "A message that appears when there are no MCP servers configured.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "このファイルはOpenClientのバックアップではありません。" + "value" : "Keine MCP-Server konfiguriert. Fügen Sie sie in der config.yaml Ihres LiteLLM-Servers hinzu." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Este archivo no es una copia de seguridad de OpenClient." + "value" : "Δεν έχουν ρυθμιστεί MCP διακομιστές. Προσθέστε τους στο config.yaml του διακομιστή LiteLLM σας." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Diese Datei ist keine OpenClient-Sicherung." + "value" : "No MCP servers configured. Add them in your LiteLLM server's config.yaml." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Questo file non è un backup di OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "No hay servidores MCP configurados. Agréguelos en el config.yaml de su servidor LiteLLM." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Este ficheiro não é uma cópia de segurança OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Aucun serveur MCP configuré. Ajoutez-les dans le config.yaml de votre serveur LiteLLM." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "This file is not an OpenClient backup.", - "state" : "translated" + "state" : "translated", + "value" : "Nessun server MCP configurato. Aggiungili nel file config.yaml del tuo server LiteLLM." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Dit bestand is geen OpenClient-back-up.", - "state" : "translated" + "state" : "translated", + "value" : "MCPサーバーが設定されていません。LiteLLMサーバーのconfig.yamlに追加してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ce fichier n’est pas une sauvegarde OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Geen MCP-servers geconfigureerd. Voeg ze toe in de config.yaml van je LiteLLM-server." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αυτό το αρχείο δεν είναι αντίγραφο ασφαλείας OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Nenhum servidor MCP configurado. Adicione-os no config.yaml do seu servidor LiteLLM." } }, "sv" : { "stringUnit" : { - "value" : "Den här filen är inte en OpenClient-säkerhetskopia.", - "state" : "translated" + "state" : "translated", + "value" : "Inga MCP-servrar konfigurerade. Lägg till dem i din LiteLLM-servers config.yaml." } } } }, - "Conversation name" : { + "No MCP servers loaded. Tap \"Load Available Tools\" to fetch them from your server." : { + "comment" : "A label that describes the state when no MCP servers are available.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "会話名", - "state" : "translated" + "state" : "translated", + "value" : "Keine MCP-Server geladen. Tippen Sie auf „Verfügbare Tools laden“, um sie von Ihrem Server abzurufen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nombre de la conversación", - "state" : "translated" + "state" : "translated", + "value" : "Δεν έχουν φορτωθεί MCP διακομιστές. Πατήστε «Φόρτωση Διαθέσιμων Εργαλείων» για να τους λάβετε από τον διακομιστή σας." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Konversationsname" + "value" : "No MCP servers loaded. Tap \"Load Available Tools\" to fetch them from your server." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nome conversazione" + "value" : "No se cargaron servidores MCP. Toca \"Cargar herramientas disponibles\" para obtenerlos de tu servidor." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nome da conversa", - "state" : "translated" + "state" : "translated", + "value" : "Aucun serveur MCP chargé. Appuyez sur « Charger les outils disponibles » pour les récupérer depuis votre serveur." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Conversation name", - "state" : "translated" + "state" : "translated", + "value" : "Nessun server MCP caricato. Tocca \"Carica Strumenti Disponibili\" per recuperarli dal tuo server." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nom de la conversation", - "state" : "translated" + "state" : "translated", + "value" : "MCPサーバーが読み込まれていません。「利用可能なツールを読み込む」をタップしてサーバーから取得してください。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gespreksnaam" + "value" : "Geen MCP-servers geladen. Tik op \"Beschikbare tools laden\" om ze van je server op te halen." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Όνομα συνομιλίας", - "state" : "translated" + "state" : "translated", + "value" : "Nenhum servidor MCP carregado. Toque em \"Carregar Ferramentas Disponíveis\" para os obter do seu servidor." } }, "sv" : { "stringUnit" : { - "value" : "Konversationsnamn", - "state" : "translated" + "state" : "translated", + "value" : "Inga MCP-servrar laddade. Tryck på \"Ladda tillgängliga verktyg\" för att hämta dem från din server." } } - }, - "comment" : "A label for the name of a conversation." + } }, - "iCloud is unavailable. Your changes will stay on this device until sync resumes." : { + "No Media or Files" : { + "comment" : "A description of the state displayed when the user has no media or files.", "localizations" : { "de" : { "stringUnit" : { - "value" : "iCloud ist nicht verfügbar. Ihre Änderungen bleiben auf diesem Gerät, bis die Synchronisierung fortgesetzt wird.", - "state" : "translated" + "state" : "translated", + "value" : "Keine Medien oder Dateien" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "iCloud no está disponible. Tus cambios permanecerán en este dispositivo hasta que se reanude la sincronización.", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχουν μέσα ή αρχεία" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Το iCloud δεν είναι διαθέσιμο. Οι αλλαγές σας θα παραμείνουν σε αυτή τη συσκευή μέχρι να επανέλθει ο συγχρονισμός." + "value" : "No Media or Files" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud non è disponibile. Le tue modifiche rimarranno su questo dispositivo finché la sincronizzazione non riprenderà." + "value" : "Sin medios ni archivos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O iCloud não está disponível. As suas alterações permanecerão neste dispositivo até que a sincronização seja retomada.", - "state" : "translated" + "state" : "translated", + "value" : "Aucun média ni fichier" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "iCloud is unavailable. Your changes will remain on this device until syncing resumes.", - "state" : "translated" + "state" : "translated", + "value" : "Nessun media o file" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "iCloud est indisponible. Vos modifications resteront sur cet appareil jusqu’à la reprise de la synchronisation.", - "state" : "translated" + "state" : "translated", + "value" : "メディアやファイルがありません" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud is niet beschikbaar. Je wijzigingen blijven op dit apparaat staan totdat de synchronisatie hervat wordt." + "value" : "Geen media of bestanden" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "iCloudは利用できません。同期が再開されるまで、変更はこのデバイスに保存されます。", - "state" : "translated" + "state" : "translated", + "value" : "Sem Média ou Ficheiros" } }, "sv" : { "stringUnit" : { - "value" : "iCloud är otillgängligt. Dina ändringar kommer att finnas kvar på den här enheten tills synkroniseringen återupptas.", - "state" : "translated" + "state" : "translated", + "value" : "Inga medier eller filer" } } - }, - "comment" : "A footer for the iCloud sync section." + } }, - "Reset App Data" : { + "No Memory Items" : { + "comment" : "A message displayed when the user has no memory items.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "アプリデータをリセット", - "state" : "translated" + "state" : "translated", + "value" : "Keine Speicherobjekte" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Återställ appdata" + "value" : "Δεν υπάρχουν στοιχεία μνήμης" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Restablecer datos de la aplicación" + "value" : "No Memory Items" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Reimposta dati app", - "state" : "translated" + "state" : "translated", + "value" : "No hay elementos de memoria" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Repor Dados da App", - "state" : "translated" + "state" : "translated", + "value" : "Aucun élément mémorisé" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Reset App Data" + "value" : "Nessun elemento di memoria" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Réinitialiser les données de l’application", - "state" : "translated" + "state" : "translated", + "value" : "メモリ項目なし" } }, "nl" : { "stringUnit" : { - "value" : "Appgegevens resetten", - "state" : "translated" + "state" : "translated", + "value" : "Geen geheugenitems" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επαναφορά δεδομένων εφαρμογής", - "state" : "translated" + "state" : "translated", + "value" : "Sem itens de memória" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "App-Daten zurücksetzen", - "state" : "translated" + "state" : "translated", + "value" : "Inga minnesobjekt" } } - }, - "comment" : "A confirmation alert that lets the user reset all app data." + } }, - "Backup Error" : { + "No Model" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "バックアップエラー" + "value" : "Kein Modell" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Error de copia de seguridad", - "state" : "translated" + "state" : "translated", + "value" : "Χωρίς μοντέλο" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sicherungsfehler" + "value" : "No Model" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Errore di backup" + "value" : "Sin modelo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Erro de Cópia de Segurança", - "state" : "translated" + "state" : "translated", + "value" : "Aucun modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Backup Error", - "state" : "translated" + "state" : "translated", + "value" : "Nessun modello" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Back-upfout", - "state" : "translated" + "state" : "translated", + "value" : "モデルなし" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Erreur de sauvegarde", - "state" : "translated" + "state" : "translated", + "value" : "Geen model" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Σφάλμα αντιγράφου ασφαλείας", - "state" : "translated" + "state" : "translated", + "value" : "Sem modelo" } }, "sv" : { "stringUnit" : { - "value" : "Säkerhetskopieringsfel", - "state" : "translated" + "state" : "translated", + "value" : "Ingen modell" } } } }, - "Speech recognition permission was not granted." : { + "No pinned conversations" : { + "comment" : "A message displayed when the user has no pinned conversations.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "音声認識の許可が付与されていません。" + "value" : "Keine angehefteten Unterhaltungen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se concedió permiso para el reconocimiento de voz.", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχουν καρφιτσωμένες συνομιλίες" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tillstånd för taligenkänning beviljades inte." + "value" : "No pinned conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il permesso per il riconoscimento vocale non è stato concesso.", - "state" : "translated" + "state" : "translated", + "value" : "No hay conversaciones fijadas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A permissão para reconhecimento de voz não foi concedida.", - "state" : "translated" + "state" : "translated", + "value" : "Aucune conversation épinglée" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Speech recognition permission was not granted.", - "state" : "translated" + "state" : "translated", + "value" : "Nessuna conversazione fissata" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Toestemming voor spraakherkenning is niet verleend.", - "state" : "translated" + "state" : "translated", + "value" : "ピン留めされた会話はありません" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "La permission de reconnaissance vocale n’a pas été accordée." + "value" : "Geen vastgezette gesprekken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Η άδεια αναγνώρισης ομιλίας δεν δόθηκε.", - "state" : "translated" + "state" : "translated", + "value" : "Sem conversas fixadas" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Die Erlaubnis zur Spracherkennung wurde nicht erteilt.", - "state" : "translated" + "state" : "translated", + "value" : "Inga fastnålda konversationer" } } - }, - "comment" : "Error message when speech recognition permission is not granted." + } }, - "Invalid API key. Please check your credentials." : { + "No results found for: %@" : { + "comment" : "A message to display when no search results are found. The argument is the search query.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Μη έγκυρο κλειδί API. Ελέγξτε τα διαπιστευτήριά σας.", - "state" : "translated" + "state" : "translated", + "value" : "Keine Ergebnisse gefunden für: %@" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Clave API no válida. Por favor, verifica tus credenciales.", - "state" : "translated" + "state" : "translated", + "value" : "Δεν βρέθηκαν αποτελέσματα για: %@" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ogiltig API-nyckel. Kontrollera dina uppgifter." + "value" : "No results found for: %@" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Chiave API non valida. Controlla le tue credenziali." + "value" : "No se encontraron resultados para: %@" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Chave API inválida. Por favor, verifique as suas credenciais.", - "state" : "translated" + "state" : "translated", + "value" : "Aucun résultat trouvé pour : %@" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Invalid API key. Please check your credentials." + "value" : "Nessun risultato trovato per: %@" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Ongeldige API-sleutel. Controleer uw gegevens.", - "state" : "translated" + "state" : "translated", + "value" : "%@ の結果は見つかりませんでした" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Clé API invalide. Veuillez vérifier vos identifiants.", - "state" : "translated" + "state" : "translated", + "value" : "Geen resultaten gevonden voor: %@" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "無効なAPIキーです。認証情報を確認してください。", - "state" : "translated" + "state" : "translated", + "value" : "Nenhum resultado encontrado para: %@" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Ungültiger API-Schlüssel. Bitte überprüfen Sie Ihre Zugangsdaten.", - "state" : "translated" + "state" : "translated", + "value" : "Inga resultat hittades för: %@" } } } }, - "Email Composer" : { + "No search tools loaded. Tap \"Load Available Tools\" to fetch them from your server." : { + "comment" : "A label that appears when there are no search tools available.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "E-postkompositör" + "value" : "Keine Suchwerkzeuge geladen. Tippen Sie auf „Verfügbare Werkzeuge laden“, um sie von Ihrem Server abzurufen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Compositor de correo electrónico", - "state" : "translated" + "state" : "translated", + "value" : "Δεν έχουν φορτωθεί εργαλεία αναζήτησης. Πατήστε «Φόρτωση Διαθέσιμων Εργαλείων» για να τα κατεβάσετε από τον διακομιστή σας." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Σύνθετης Email" + "value" : "No search tools loaded. Tap \"Load Available Tools\" to fetch them from your server." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Compositore Email", - "state" : "translated" + "state" : "translated", + "value" : "No se cargaron herramientas de búsqueda. Toca \"Cargar herramientas disponibles\" para obtenerlas desde tu servidor." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Compositor de Email", - "state" : "translated" + "state" : "translated", + "value" : "Aucun outil de recherche chargé. Touchez « Charger les outils disponibles » pour les récupérer depuis votre serveur." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Email Composer", - "state" : "translated" + "state" : "translated", + "value" : "Nessuno strumento di ricerca caricato. Tocca \"Carica strumenti disponibili\" per recuperarli dal server." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "E-mailcomposer", - "state" : "translated" + "state" : "translated", + "value" : "検索ツールが読み込まれていません。「利用可能なツールを読み込む」をタップしてサーバーから取得してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Compositeur d’e-mails" + "value" : "Geen zoekhulpmiddelen geladen. Tik op \"Beschikbare hulpmiddelen laden\" om ze van uw server op te halen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メール作成ツール", - "state" : "translated" + "state" : "translated", + "value" : "Nenhuma ferramenta de pesquisa carregada. Toque em \"Carregar Ferramentas Disponíveis\" para as obter do seu servidor." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "E-Mail-Verfasser", - "state" : "translated" + "state" : "translated", + "value" : "Inga sökverktyg laddade. Tryck på \"Ladda tillgängliga verktyg\" för att hämta dem från din server." } } - }, - "comment" : "Name of a prompt template for composing emails." + } }, - "Could not read the server response." : { + "No speech-to-text model available. Configure a Whisper model in LiteLLM." : { + "comment" : "Error message displayed when no speech-to-text model is configured.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Serverantwort konnte nicht gelesen werden." + "value" : "Kein Speech-to-Text-Modell verfügbar. Konfigurieren Sie ein Whisper-Modell in LiteLLM." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudo leer la respuesta del servidor.", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχει διαθέσιμο μοντέλο ομιλίας σε κείμενο. Διαμορφώστε ένα μοντέλο Whisper στο LiteLLM." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν ήταν δυνατή η ανάγνωση της απάντησης του διακομιστή." + "value" : "No speech-to-text model available. Configure a Whisper model in LiteLLM." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile leggere la risposta del server.", - "state" : "translated" + "state" : "translated", + "value" : "No hay modelo de reconocimiento de voz disponible. Configure un modelo Whisper en LiteLLM." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Não foi possível ler a resposta do servidor." + "value" : "Aucun modèle de reconnaissance vocale disponible. Configurez un modèle Whisper dans LiteLLM." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Could not read the server response.", - "state" : "translated" + "state" : "translated", + "value" : "Nessun modello di riconoscimento vocale disponibile. Configura un modello Whisper in LiteLLM." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Kan de serverreactie niet lezen.", - "state" : "translated" + "state" : "translated", + "value" : "音声認識モデルが利用できません。LiteLLMでWhisperモデルを設定してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Impossible de lire la réponse du serveur.", - "state" : "translated" + "state" : "translated", + "value" : "Geen spraak-naar-tekstmodel beschikbaar. Stel een Whisper-model in LiteLLM in." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバーの応答を読み取れませんでした。", - "state" : "translated" + "state" : "translated", + "value" : "Nenhum modelo de reconhecimento de voz disponível. Configure um modelo Whisper no LiteLLM." } }, "sv" : { "stringUnit" : { - "value" : "Kunde inte läsa serverns svar.", - "state" : "translated" + "state" : "translated", + "value" : "Ingen tal-till-text-modell tillgänglig. Konfigurera en Whisper-modell i LiteLLM." } } } }, - "Enter a brief title for the issue" : { + "No suggestions yet." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Geben Sie einen kurzen Titel für das Problem ein" + "value" : "Noch keine Vorschläge." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Introduce un título breve para el problema", - "state" : "translated" + "state" : "translated", + "value" : "Δεν υπάρχουν προτάσεις ακόμα." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εισαγάγετε έναν σύντομο τίτλο για το ζήτημα" + "value" : "No suggestions yet." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inserisci un titolo breve per il problema", - "state" : "translated" + "state" : "translated", + "value" : "Aún no hay sugerencias." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Introduza um título breve para o problema", - "state" : "translated" + "state" : "translated", + "value" : "Pas encore de suggestions." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Enter a brief title for the issue", - "state" : "translated" + "state" : "translated", + "value" : "Nessun suggerimento ancora." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voer een korte titel voor het probleem in", - "state" : "translated" + "state" : "translated", + "value" : "まだ提案はありません。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Entrez un titre bref pour le problème" + "value" : "Nog geen suggesties." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "問題の簡単なタイトルを入力してください", - "state" : "translated" + "state" : "translated", + "value" : "Sem sugestões ainda." } }, "sv" : { "stringUnit" : { - "value" : "Ange en kort titel för problemet", - "state" : "translated" + "state" : "translated", + "value" : "Inga förslag än så länge." } } } }, - "Import Conversations" : { + "No Templates" : { + "comment" : "A title that describes the absence of templates.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Importera konversationer" + "value" : "Keine Vorlagen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Importar conversaciones", - "state" : "translated" + "state" : "translated", + "value" : "Χωρίς Πρότυπα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "会話をインポート" + "value" : "No Templates" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Importa conversazioni", - "state" : "translated" + "state" : "translated", + "value" : "Sin plantillas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Importar Conversas", - "state" : "translated" + "state" : "translated", + "value" : "Aucun modèle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Import Conversations", - "state" : "translated" + "state" : "translated", + "value" : "Nessun modello" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gesprekken importeren", - "state" : "translated" + "state" : "translated", + "value" : "テンプレートなし" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importer les conversations" + "value" : "Geen sjablonen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εισαγωγή Συνομιλιών", - "state" : "translated" + "state" : "translated", + "value" : "Sem Modelos" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Konversationen importieren", - "state" : "translated" + "state" : "translated", + "value" : "Inga mallar" } } } }, - "Your AI conversations" : { + "Not Now" : { + "comment" : "A button that dismisses an alert.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Οι συνομιλίες σας με την Τεχνητή Νοημοσύνη", - "state" : "translated" + "state" : "translated", + "value" : "Nicht jetzt" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Ihre KI-Gespräche", - "state" : "translated" + "state" : "translated", + "value" : "Όχι τώρα" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tus conversaciones con IA" + "value" : "Not Now" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Le tue conversazioni con l'IA", - "state" : "translated" + "state" : "translated", + "value" : "Ahora no" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "As suas conversas com IA", - "state" : "translated" + "state" : "translated", + "value" : "Pas maintenant" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Your AI conversations", - "state" : "translated" + "state" : "translated", + "value" : "Non ora" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Jouw AI-gesprekken" + "value" : "今はしない" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vos conversations avec l’IA" + "value" : "Niet nu" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "あなたのAIとの会話", - "state" : "translated" + "state" : "translated", + "value" : "Agora não" } }, "sv" : { "stringUnit" : { - "value" : "Dina AI-konversationer", - "state" : "translated" + "state" : "translated", + "value" : "Inte nu" } } - }, - "comment" : "A description of the app's privacy policy." + } }, - "Chat without saving history" : { + "Notifications disabled" : { + "comment" : "A label that indicates that notifications are disabled.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Συνομιλία χωρίς αποθήκευση ιστορικού", - "state" : "translated" + "state" : "translated", + "value" : "Benachrichtigungen deaktiviert" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Chat ohne Verlauf speichern", - "state" : "translated" + "state" : "translated", + "value" : "Ειδοποιήσεις απενεργοποιημένες" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Chat sin guardar historial" + "value" : "Notifications disabled" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Chat senza salvare la cronologia" + "value" : "Notificaciones desactivadas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Chat sem guardar histórico", - "state" : "translated" + "state" : "translated", + "value" : "Notifications désactivées" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Chat without saving history", - "state" : "translated" + "state" : "translated", + "value" : "Notifiche disattivate" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Discussion sans enregistrer l’historique", - "state" : "translated" + "state" : "translated", + "value" : "通知が無効になっています" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Chatten zonder geschiedenis op te slaan" + "value" : "Meldingen uitgeschakeld" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "履歴を保存しないチャット", - "state" : "translated" + "state" : "translated", + "value" : "Notificações desativadas" } }, "sv" : { "stringUnit" : { - "value" : "Chatt utan att spara historik", - "state" : "translated" + "state" : "translated", + "value" : "Aviseringar avstängda" } } - }, - "comment" : "Localized title for a shortcut action that opens a private chat." + } }, - "The request was cancelled." : { + "Notifications enabled" : { + "comment" : "A label that indicates that notifications are enabled.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το αίτημα ακυρώθηκε.", - "state" : "translated" + "state" : "translated", + "value" : "Benachrichtigungen aktiviert" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La solicitud fue cancelada.", - "state" : "translated" + "state" : "translated", + "value" : "Ειδοποιήσεις ενεργοποιημένες" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "リクエストはキャンセルされました。" + "value" : "Notifications enabled" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "La richiesta è stata annullata.", - "state" : "translated" + "state" : "translated", + "value" : "Notificaciones activadas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O pedido foi cancelado.", - "state" : "translated" + "state" : "translated", + "value" : "Notifications activées" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The request was cancelled." + "value" : "Notifiche attivate" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Het verzoek is geannuleerd.", - "state" : "translated" + "state" : "translated", + "value" : "通知が有効です" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "La requête a été annulée." + "value" : "Meldingen ingeschakeld" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Die Anfrage wurde abgebrochen.", - "state" : "translated" + "state" : "translated", + "value" : "Notificações ativadas" } }, "sv" : { "stringUnit" : { - "value" : "Begäran avbröts.", - "state" : "translated" + "state" : "translated", + "value" : "Aviseringar aktiverade" } } } }, - "Start a conversation" : { + "Notifications not authorized" : { + "comment" : "A label that indicates that the app has not yet been authorized to send notifications.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ξεκινήστε μια συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Benachrichtigungen nicht erlaubt" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Iniciar una conversación", - "state" : "translated" + "state" : "translated", + "value" : "Οι ειδοποιήσεις δεν έχουν εξουσιοδοτηθεί" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "会話を始める" + "value" : "Notifications not authorized" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Inizia una conversazione" + "value" : "Notificaciones no autorizadas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Iniciar uma conversa" + "value" : "Notifications non autorisées" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Start a conversation", - "state" : "translated" + "state" : "translated", + "value" : "Notifiche non autorizzate" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Begin een gesprek", - "state" : "translated" + "state" : "translated", + "value" : "通知が許可されていません" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Démarrer une conversation", - "state" : "translated" + "state" : "translated", + "value" : "Meldingen niet toegestaan" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Konversation starten", - "state" : "translated" + "state" : "translated", + "value" : "Notificações não autorizadas" } }, "sv" : { "stringUnit" : { - "value" : "Starta en konversation", - "state" : "translated" + "state" : "translated", + "value" : "Aviseringar inte godkända" } } - }, - "comment" : "Subtitle for the \"New Chat\" action button in the Quick Actions widget." + } }, - "Custom..." : { + "Nucleus sampling. Lower values make output more focused." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Anpassad..." - } - }, - "es" : { - "stringUnit" : { - "value" : "Personalizado...", - "state" : "translated" + "value" : "Nucleus-Sampling. Niedrigere Werte machen die Ausgabe fokussierter." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Προσαρμοσμένο..." + "value" : "Δειγματοληψία πυρήνα. Οι χαμηλότερες τιμές κάνουν την έξοδο πιο εστιασμένη." } }, - "it" : { + "en" : { "stringUnit" : { - "value" : "Personalizzato...", - "state" : "translated" + "state" : "translated", + "value" : "Nucleus sampling. Lower values make the output more focused." } }, - "pt-PT" : { + "es" : { "stringUnit" : { - "value" : "Personalizado...", - "state" : "translated" + "state" : "translated", + "value" : "Muestreo de núcleo. Valores más bajos hacen que la salida sea más enfocada." } }, - "en" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Custom..." + "value" : "Échantillonnage nucleus. Des valeurs plus basses rendent la sortie plus ciblée." } }, - "nl" : { + "it" : { "stringUnit" : { - "value" : "Aangepast...", - "state" : "translated" + "state" : "translated", + "value" : "Campionamento a nucleo. Valori più bassi rendono l'output più focalizzato." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Personnalisé...", - "state" : "translated" + "state" : "translated", + "value" : "ニュークレオスサンプリング。値を低くすると出力がより集中します。" } }, - "de" : { + "nl" : { "stringUnit" : { - "value" : "Benutzerdefiniert...", - "state" : "translated" + "state" : "translated", + "value" : "Nucleus sampling. Lagere waarden maken de output gerichter." } }, - "ja" : { + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amostragem por núcleo. Valores mais baixos tornam a saída mais focada." + } + }, + "sv" : { "stringUnit" : { - "value" : "カスタム...", - "state" : "translated" + "state" : "translated", + "value" : "Nukleussampling. Lägre värden gör resultatet mer fokuserat." } } - }, - "comment" : "A button that opens a sheet for entering a custom voice ID." + } }, - "Attach an image or PDF, or drag files into the chat for the model to analyse." : { + "Ok" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "画像またはPDFを添付するか、ファイルをチャットにドラッグしてモデルに解析させてください。" + "value" : "OK" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Adjunta una imagen o PDF, o arrastra archivos al chat para que el modelo los analice.", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bifoga en bild eller PDF, eller dra filer till chatten för modellen att analysera." + "value" : "Ok" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Allega un'immagine o un PDF, oppure trascina i file nella chat per farli analizzare dal modello.", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Anexe uma imagem ou PDF, ou arraste ficheiros para o chat para o modelo analisar.", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Attach an image or PDF, or drag files into the chat for the model to analyze." + "value" : "OK" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voeg een afbeelding of PDF toe, of sleep bestanden in de chat voor analyse door het model.", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Joignez une image ou un PDF, ou glissez des fichiers dans la conversation pour que le modèle les analyse.", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Fügen Sie ein Bild oder PDF an oder ziehen Sie Dateien in den Chat, damit das Modell sie analysieren kann.", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Επισυνάψτε μια εικόνα ή PDF, ή σύρετε αρχεία στη συνομιλία για ανάλυση από το μοντέλο.", - "state" : "translated" + "state" : "translated", + "value" : "OK" } } } }, - "Open a conversation by ID" : { + "OK" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Eine Unterhaltung über die ID öffnen", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Abrir una conversación por ID", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Άνοιγμα συνομιλίας με βάση το αναγνωριστικό", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apri una conversazione tramite ID", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir uma conversa pelo ID" + "value" : "OK" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Open a conversation by ID" + "value" : "OK" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Ouvrir une conversation par ID", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Open een gesprek via ID" + "value" : "OK" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "IDで会話を開く", - "state" : "translated" + "state" : "translated", + "value" : "OK" } }, "sv" : { "stringUnit" : { - "value" : "Öppna en konversation med ID", - "state" : "translated" + "state" : "translated", + "value" : "OK" } } - }, - "comment" : "A description of how to open a conversation by its ID using the URL scheme." + } }, - "Cyan" : { + "One-time purchase · Doesn't unlock any features, everything is already free" : { + "comment" : "A description of the benefits of purchasing a tip for OpenClient.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κυανό", - "state" : "translated" + "state" : "translated", + "value" : "Einmaliger Kauf · Schaltet keine Funktionen frei, alles ist bereits kostenlos" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Cian" + "value" : "Εφάπαξ αγορά · Δεν ξεκλειδώνει καμία λειτουργία, όλα είναι ήδη δωρεάν" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cyan" + "value" : "One-time purchase · Does not unlock any features, everything is already free" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Ciano" + "value" : "Compra única · No desbloquea funciones, todo ya es gratis" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ciano", - "state" : "translated" + "state" : "translated", + "value" : "Achat unique · Ne débloque aucune fonctionnalité, tout est déjà gratuit" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Cyan", - "state" : "translated" + "state" : "translated", + "value" : "Acquisto una tantum · Non sblocca funzionalità, tutto è già gratuito" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Cyan", - "state" : "translated" + "state" : "translated", + "value" : "一度きりの購入 · 機能のロック解除はなく、すべて既に無料です" } }, "nl" : { "stringUnit" : { - "value" : "Cyaan", - "state" : "translated" + "state" : "translated", + "value" : "Eenmalige aankoop · Ontgrendelt geen functies, alles is al gratis" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "シアン", - "state" : "translated" + "state" : "translated", + "value" : "Compra única · Não desbloqueia funcionalidades, tudo já é gratuito" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Cyan", - "state" : "translated" + "state" : "translated", + "value" : "Engångsköp · Låser inte upp några funktioner, allt är redan gratis" } } - }, - "comment" : "Name of the color cyan." + } }, - "e.g. User prefers concise answers" : { + "Only active" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "z. B. Nutzer bevorzugt kurze Antworten", - "state" : "translated" + "state" : "translated", + "value" : "Nur aktiv" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "π.χ. Ο χρήστης προτιμά σύντομες απαντήσεις" + "value" : "Μόνο ενεργά" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "p. ej. El usuario prefiere respuestas concisas" + "value" : "Only active" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "es. L’utente preferisce risposte concise", - "state" : "translated" + "state" : "translated", + "value" : "Solo activos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "ex. O utilizador prefere respostas concisas", - "state" : "translated" + "state" : "translated", + "value" : "Uniquement actif" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "e.g. User prefers concise answers", - "state" : "translated" + "state" : "translated", + "value" : "Solo attivi" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bijv. gebruiker geeft de voorkeur aan beknopte antwoorden", - "state" : "translated" + "state" : "translated", + "value" : "アクティブのみ" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "ex. L’utilisateur préfère des réponses concises" + "value" : "Alleen actief" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "例:ユーザーは簡潔な回答を好む", - "state" : "translated" + "state" : "translated", + "value" : "Apenas ativo" } }, "sv" : { "stringUnit" : { - "value" : "t.ex. Användaren föredrar korta svar", - "state" : "translated" + "state" : "translated", + "value" : "Endast aktiva" } } - }, - "comment" : "A placeholder text for a memory item's content." + } }, - "Completion" : { + "Only completed" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ολοκλήρωση" + "value" : "Nur abgeschlossen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Finalización", - "state" : "translated" + "state" : "translated", + "value" : "Μόνο ολοκληρωμένα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Slutförande" + "value" : "Only completed" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Completamento", - "state" : "translated" + "state" : "translated", + "value" : "Solo completados" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Conclusão" + "value" : "Uniquement terminés" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Completion", - "state" : "translated" + "state" : "translated", + "value" : "Solo completati" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voltooiing", - "state" : "translated" + "state" : "translated", + "value" : "完了のみ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Achèvement", - "state" : "translated" + "state" : "translated", + "value" : "Alleen voltooid" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Abschluss", - "state" : "translated" + "state" : "translated", + "value" : "Apenas concluídos" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "完了", - "state" : "translated" + "state" : "translated", + "value" : "Endast slutförda" } } - }, - "comment" : "A description of a completion LLM model." + } }, - "Search Conversations" : { + "Only images and PDFs are supported" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Konversationen suchen", - "state" : "translated" + "state" : "translated", + "value" : "Nur Bilder und PDFs werden unterstützt" } }, - "ja" : { + "el" : { "stringUnit" : { - "value" : "会話を検索", - "state" : "translated" + "state" : "translated", + "value" : "Υποστηρίζονται μόνο εικόνες και αρχεία PDF" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Buscar conversaciones", - "state" : "translated" + "state" : "translated", + "value" : "Only images and PDFs are supported" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Cerca conversazioni", - "state" : "translated" + "state" : "translated", + "value" : "Solo se admiten imágenes y PDFs" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Pesquisar Conversas" + "value" : "Seules les images et les PDF sont pris en charge" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Search Conversations" + "value" : "Sono supportate solo immagini e PDF" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Rechercher des conversations", - "state" : "translated" + "state" : "translated", + "value" : "画像とPDFのみ対応しています" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gesprekken zoeken" + "value" : "Alleen afbeeldingen en PDF's worden ondersteund" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αναζήτηση συνομιλιών", - "state" : "translated" + "state" : "translated", + "value" : "Apenas imagens e PDFs são suportados" } }, "sv" : { "stringUnit" : { - "value" : "Sök konversationer", - "state" : "translated" + "state" : "translated", + "value" : "Endast bilder och PDF-filer stöds" } } } }, - "Cloud" : { + "Open **Shortcuts** and create a new shortcut." : { + "comment" : "Step 1 of creating a shortcut using the Shortcuts app.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "クラウド", - "state" : "translated" + "state" : "translated", + "value" : "Öffne **Kurzbefehle** und erstelle einen neuen Kurzbefehl." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cloud", - "state" : "translated" + "state" : "translated", + "value" : "Άνοιξε τις **Συντομεύσεις** και δημιούργησε μια νέα συντόμευση." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Moln" + "value" : "Open **Shortcuts** and create a new shortcut." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Cloud" + "value" : "Abre **Atajos** y crea un nuevo atajo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nuvem", - "state" : "translated" + "state" : "translated", + "value" : "Ouvrez **Raccourcis** et créez un nouveau raccourci." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Cloud", - "state" : "translated" + "state" : "translated", + "value" : "Apri **Comandi** e crea un nuovo comando." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Cloud", - "state" : "translated" + "state" : "translated", + "value" : "**ショートカット**を開き、新しいショートカットを作成します。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Cloud" + "value" : "Open **Opdrachten** en maak een nieuwe opdracht aan." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Νέφος", - "state" : "translated" + "state" : "translated", + "value" : "Abra as **Atalhos** e crie um novo atalho." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Cloud", - "state" : "translated" + "state" : "translated", + "value" : "Öppna **Genvägar** och skapa en ny genväg." } } } }, - "Listen" : { + "Open a conversation by ID" : { + "comment" : "A description of how to open a conversation by its ID using the URL scheme.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Anhören", - "state" : "translated" + "state" : "translated", + "value" : "Eine Unterhaltung über die ID öffnen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Escuchar", - "state" : "translated" + "state" : "translated", + "value" : "Άνοιγμα συνομιλίας με βάση το αναγνωριστικό" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "聞く" + "value" : "Open a conversation by ID" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Ascolta", - "state" : "translated" + "state" : "translated", + "value" : "Abrir una conversación por ID" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ouvir", - "state" : "translated" + "state" : "translated", + "value" : "Ouvrir une conversation par ID" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Listen" + "value" : "Apri una conversazione tramite ID" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Luisteren", - "state" : "translated" + "state" : "translated", + "value" : "IDで会話を開く" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Écouter" + "value" : "Open een gesprek via ID" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Άκουσμα", - "state" : "translated" + "state" : "translated", + "value" : "Abrir uma conversa pelo ID" } }, "sv" : { "stringUnit" : { - "value" : "Lyssna", - "state" : "translated" + "state" : "translated", + "value" : "Öppna en konversation med ID" } } - }, - "comment" : "A button that triggers the speech-to-text feature." + } }, - "Could not write file to the shared container" : { + "Open a new conversation in OpenClient." : { + "comment" : "Description of the New Chat widget.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "共有コンテナにファイルを書き込めませんでした" + "value" : "Eine neue Unterhaltung in OpenClient starten." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudo escribir el archivo en el contenedor compartido", - "state" : "translated" + "state" : "translated", + "value" : "Άνοιγμα νέας συνομιλίας στο OpenClient" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν ήταν δυνατή η εγγραφή του αρχείου στον κοινόχρηστο φάκελο" + "value" : "Open a new conversation in OpenClient" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile scrivere il file nel contenitore condiviso", - "state" : "translated" + "state" : "translated", + "value" : "Abrir una nueva conversación en OpenClient." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Não foi possível gravar o ficheiro no contentor partilhado", - "state" : "translated" + "state" : "translated", + "value" : "Ouvrir une nouvelle conversation dans OpenClient" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Could not write file to the shared container", - "state" : "translated" + "state" : "translated", + "value" : "Apri una nuova conversazione in OpenClient" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Kon bestand niet naar de gedeelde container schrijven", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientで新しい会話を開始する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Impossible d’écrire le fichier dans le conteneur partagé", - "state" : "translated" + "state" : "translated", + "value" : "Open een nieuw gesprek in OpenClient." } }, - "de" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Datei konnte nicht im gemeinsamen Container gespeichert werden" + "value" : "Abrir uma nova conversa no OpenClient." } }, "sv" : { "stringUnit" : { - "value" : "Kunde inte skriva fil till den delade behållaren", - "state" : "translated" + "state" : "translated", + "value" : "Öppna en ny konversation i OpenClient." } } } }, - "App Data" : { + "Open in App" : { + "comment" : "A button that opens the app.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Δεδομένα εφαρμογής" + "value" : "In App öffnen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Datos de la app", - "state" : "translated" + "state" : "translated", + "value" : "Άνοιγμα στην εφαρμογή" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "App-Daten" + "value" : "Open in App" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Dati app", - "state" : "translated" + "state" : "translated", + "value" : "Abrir en la app" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Dados da App" + "value" : "Ouvrir dans l’app" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "App Data", - "state" : "translated" + "state" : "translated", + "value" : "Apri nell’app" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "App-gegevens", - "state" : "translated" + "state" : "translated", + "value" : "アプリで開く" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Données de l’application", - "state" : "translated" + "state" : "translated", + "value" : "Openen in app" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アプリデータ", - "state" : "translated" + "state" : "translated", + "value" : "Abrir na App" } }, "sv" : { "stringUnit" : { - "value" : "Appdata", - "state" : "translated" + "state" : "translated", + "value" : "Öppna i appen" } } - }, - "comment" : "A section in the settings view that allows the user to reset all local data." + } }, - "Red" : { + "Open Settings" : { + "comment" : "A button that opens the user's device settings.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κόκκινο", - "state" : "translated" + "state" : "translated", + "value" : "Einstellungen öffnen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Rojo" + "value" : "Άνοιγμα ρυθμίσεων" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Rot" + "value" : "Open Settings" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Rosso", - "state" : "translated" + "state" : "translated", + "value" : "Abrir ajustes" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Vermelho" + "value" : "Ouvrir les Réglages" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Red", - "state" : "translated" + "state" : "translated", + "value" : "Apri Impostazioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Rood", - "state" : "translated" + "state" : "translated", + "value" : "設定を開く" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Rouge", - "state" : "translated" + "state" : "translated", + "value" : "Open instellingen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "赤", - "state" : "translated" + "state" : "translated", + "value" : "Abrir Definições" } }, "sv" : { "stringUnit" : { - "value" : "Röd", - "state" : "translated" + "state" : "translated", + "value" : "Öppna inställningar" } } - }, - "comment" : "Name of the color red." + } }, - "All local settings and credentials will be deleted. iCloud data will not be affected." : { + "Open Source" : { + "comment" : "A feature of the onboarding view.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "すべてのローカル設定と認証情報が削除されます。iCloudのデータには影響しません。", - "state" : "translated" + "state" : "translated", + "value" : "Open Source" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Se eliminarán todas las configuraciones y credenciales locales. Los datos de iCloud no se verán afectados.", - "state" : "translated" + "state" : "translated", + "value" : "Ανοιχτού Κώδικα" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Alla lokala inställningar och inloggningsuppgifter kommer att raderas. iCloud-data påverkas inte." + "value" : "Open Source" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tutte le impostazioni locali e le credenziali verranno eliminate. I dati di iCloud non saranno interessati." + "value" : "Código abierto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Todas as definições locais e credenciais serão eliminadas. Os dados do iCloud não serão afetados." + "value" : "Open source" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "All local settings and credentials will be deleted. iCloud data will not be affected.", - "state" : "translated" + "state" : "translated", + "value" : "Open Source" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Alle lokale instellingen en inloggegevens worden verwijderd. iCloud-gegevens blijven ongewijzigd.", - "state" : "translated" + "state" : "translated", + "value" : "オープンソース" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Tous les paramètres locaux et identifiants seront supprimés. Les données iCloud ne seront pas affectées.", - "state" : "translated" + "state" : "translated", + "value" : "Open source" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Alle lokalen Einstellungen und Anmeldedaten werden gelöscht. iCloud-Daten bleiben unberührt.", - "state" : "translated" + "state" : "translated", + "value" : "Código Aberto" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Όλες οι τοπικές ρυθμίσεις και τα διαπιστευτήρια θα διαγραφούν. Τα δεδομένα iCloud δεν θα επηρεαστούν.", - "state" : "translated" + "state" : "translated", + "value" : "Öppen källkod" } } - }, - "comment" : "A confirmation alert message." + } }, - "Hide Actions" : { + "Open the app from Shortcuts, other apps, or a browser using `openclient://`." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Απόκρυψη ενεργειών", - "state" : "translated" + "state" : "translated", + "value" : "Öffnen Sie die App über Kurzbefehle, andere Apps oder einen Browser mit `openclient://`." } }, - "sv" : { + "el" : { "stringUnit" : { - "value" : "Dölj åtgärder", - "state" : "translated" + "state" : "translated", + "value" : "Άνοιξε την εφαρμογή από Συντομεύσεις, άλλες εφαρμογές ή πρόγραμμα περιήγησης χρησιμοποιώντας `openclient://`." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ocultar acciones" + "value" : "Open the app from Shortcuts, other apps, or a browser using `openclient://`." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nascondi azioni" + "value" : "Abre la app desde Atajos, otras apps o un navegador usando `openclient://`." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ocultar Ações", - "state" : "translated" + "state" : "translated", + "value" : "Ouvrez l’application depuis Raccourcis, d’autres applications ou un navigateur en utilisant `openclient://`." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Hide Actions", - "state" : "translated" + "state" : "translated", + "value" : "Apri l’app da Comandi, altre app o un browser usando `openclient://`." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Acties verbergen", - "state" : "translated" + "state" : "translated", + "value" : "ショートカット、他のアプリ、またはブラウザから `openclient://` を使ってアプリを開く" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Masquer les actions" + "value" : "Open de app via Opdrachten, andere apps of een browser met `openclient://`." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アクションを非表示", - "state" : "translated" + "state" : "translated", + "value" : "Abra a app a partir de Atalhos, outras apps ou um navegador usando `openclient://`." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Aktionen ausblenden", - "state" : "translated" + "state" : "translated", + "value" : "Öppna appen från Genvägar, andra appar eller en webbläsare med `openclient://`." } } - }, - "comment" : "A label for hiding the available actions." + } }, - "Loading..." : { + "Open the search screen in OpenClient." : { + "comment" : "Description of the Search widget.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Lädt...", - "state" : "translated" + "state" : "translated", + "value" : "Öffne den Suchbildschirm in OpenClient." } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "読み込み中..." + "value" : "Άνοιγμα της οθόνης αναζήτησης στο OpenClient" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cargando..." + "value" : "Open the search screen in OpenClient" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Caricamento...", - "state" : "translated" + "state" : "translated", + "value" : "Abrir la pantalla de búsqueda en OpenClient." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A carregar...", - "state" : "translated" + "state" : "translated", + "value" : "Ouvrir l’écran de recherche dans OpenClient." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Loading...", - "state" : "translated" + "state" : "translated", + "value" : "Apri la schermata di ricerca in OpenClient" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bezig met laden...", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientで検索画面を開く" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Chargement..." + "value" : "Open het zoekscherm in OpenClient." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Φόρτωση...", - "state" : "translated" + "state" : "translated", + "value" : "Abrir o ecrã de pesquisa no OpenClient." } }, "sv" : { "stringUnit" : { - "value" : "Läser in...", - "state" : "translated" + "state" : "translated", + "value" : "Öppna sökskärmen i OpenClient." } } - }, - "comment" : "A loading indicator displayed when fetching search tools." - }, - "Embedding" : { - "shouldTranslate" : false, - "comment" : "A label for an LLM model." + } }, - "New chat with text" : { + "OpenClient" : { + "comment" : "The name of the app.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Νέα συνομιλία με κείμενο", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Nuevo chat con texto" + "value" : "OpenClient" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Neuer Chat mit Text" + "value" : "OpenClient" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuova chat con testo", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nova conversa com texto", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New chat with text", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nouvelle conversation avec texte", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nieuw gesprek met tekst" + "value" : "OpenClient" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "テキストで新しいチャットを開始", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient" } }, "sv" : { "stringUnit" : { - "value" : "Ny chatt med text", - "state" : "translated" - } - } - }, - "comment" : "A description of how to open a new chat with a text message." - }, - "%.2f" : { - "localizations" : { - "en" : { - "stringUnit" : { - "value" : "%.2f", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient" } } - }, - "shouldTranslate" : false, - "comment" : "A label displaying the current value of the topP parameter." + } }, - "e.g. Coding Assistant" : { + "OpenClient connects to your LiteLLM for privacy-first access to any AI." : { + "comment" : "A description of OpenClient's privacy-first connection to LiteLLM.", "localizations" : { "de" : { "stringUnit" : { - "value" : "z. B. Coding Assistant", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient verbindet sich mit Ihrem LiteLLM für datenschutzorientierten Zugriff auf jede KI." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "p. ej. Asistente de codificación", - "state" : "translated" + "state" : "translated", + "value" : "Το OpenClient συνδέεται με το LiteLLM σας για πρόσβαση με προτεραιότητα στην ιδιωτικότητα σε οποιαδήποτε AI." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "例:コーディングアシスタント" + "value" : "OpenClient connects to your LiteLLM for privacy-first access to any AI." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "es. Assistente di Codifica" + "value" : "OpenClient se conecta a tu LiteLLM para un acceso a cualquier IA con prioridad en la privacidad." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "ex. Assistente de Programação" + "value" : "OpenClient se connecte à votre LiteLLM pour un accès à l’IA privilégiant la confidentialité." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "e.g. Coding Assistant", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient si connette al tuo LiteLLM per un accesso all’IA prioritariamente orientato alla privacy." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "bijv. Coding Assistant", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientはプライバシー重視でLiteLLMに接続し、あらゆるAIにアクセスします。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "ex. Assistant de codage", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient maakt verbinding met je LiteLLM voor privacygerichte toegang tot elke AI." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "π.χ. Βοηθός Κωδικοποίησης", - "state" : "translated" + "state" : "translated", + "value" : "O OpenClient liga-se ao seu LiteLLM para acesso prioritário à privacidade a qualquer IA." } }, "sv" : { "stringUnit" : { - "value" : "t.ex. Kodningsassistent", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient ansluter till din LiteLLM för integritetsfokuserad åtkomst till AI." } } - }, - "comment" : "A placeholder text for the title of a prompt template." + } }, - "Connection successful — ready to continue" : { + "OpenClient is free and open source" : { + "comment" : "A description of the OpenClient app.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η σύνδεση ήταν επιτυχής — έτοιμοι για συνέχεια", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient ist kostenlos und Open Source" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Conexión exitosa — listo para continuar", - "state" : "translated" + "state" : "translated", + "value" : "Το OpenClient είναι δωρεάν και ανοιχτού κώδικα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "接続に成功しました — 続行の準備ができました" + "value" : "OpenClient is free and open source" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Connessione riuscita — pronto per continuare" + "value" : "OpenClient es gratuito y de código abierto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ligação bem-sucedida — pronto para continuar", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient est gratuit et open source" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Connection successful — ready to continue", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient è gratuito e open source" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Connexion réussie — prêt à continuer", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientは無料のオープンソースです" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Verbinding geslaagd — klaar om door te gaan" + "value" : "OpenClient is gratis en open source" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Verbindung erfolgreich — bereit zum Fortfahren", - "state" : "translated" + "state" : "translated", + "value" : "O OpenClient é gratuito e de código aberto" } }, "sv" : { "stringUnit" : { - "value" : "Anslutning lyckades — redo att fortsätta", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient är gratis och öppen källkod" } } - }, - "comment" : "A message displayed when the connection to the server is successful." + } }, - "Max Tokens" : { + "OpenClient is under maintenance" : { + "comment" : "A message displayed when the app is under maintenance.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Μέγιστοι χαρακτήρες", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient wird gewartet" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Máximo de tokens", - "state" : "translated" + "state" : "translated", + "value" : "Το OpenClient βρίσκεται υπό συντήρηση" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Maximalt antal token" + "value" : "OpenClient is under maintenance" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Token massimi", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient está en mantenimiento" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Tokens Máximos" + "value" : "OpenClient est en maintenance" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Max Tokens", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient è in manutenzione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nombre maximal de jetons", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientはメンテナンス中です" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Maximaal aantal tokens" + "value" : "OpenClient wordt onderhouden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Maximale Tokenanzahl", - "state" : "translated" + "state" : "translated", + "value" : "O OpenClient está em manutenção" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "最大トークン数", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient genomgår underhållarbeiten" } } - }, - "comment" : "A slider that lets the user adjust the maximum number of tokens." + } }, - "sk-..." : { + "OpenClient may summarise or exclude older messages without removing them from your history." : { + "comment" : "A description of how OpenClient can remove older messages from the user's history.", "localizations" : { "de" : { "stringUnit" : { - "value" : "sk-...", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient kann ältere Nachrichten zusammenfassen oder ausblenden, ohne sie aus Ihrem Verlauf zu entfernen." } }, "el" : { "stringUnit" : { - "value" : "sk-...", - "state" : "translated" + "state" : "translated", + "value" : "Το OpenClient μπορεί να συνοψίζει ή να εξαιρεί παλαιότερα μηνύματα χωρίς να τα αφαιρεί από το ιστορικό σας." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "sk-..." + "value" : "OpenClient may summarize or exclude older messages without removing them from your history." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "sk-..." + "value" : "OpenClient puede resumir o excluir mensajes antiguos sin eliminarlos de tu historial." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "sk-...", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient peut résumer ou exclure les anciens messages sans les supprimer de votre historique." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "sk-...", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient può riassumere o escludere i messaggi più vecchi senza rimuoverli dalla tua cronologia." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "sk-...", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientは古いメッセージを履歴から削除せずに要約または除外することがあります。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "sk-..." + "value" : "OpenClient kan oudere berichten samenvatten of uitsluiten zonder ze uit je geschiedenis te verwijderen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "sk-...", - "state" : "translated" + "state" : "translated", + "value" : "O OpenClient pode resumir ou excluir mensagens antigas sem as remover do seu histórico." } }, "sv" : { "stringUnit" : { - "value" : "sk-...", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient kan sammanfatta eller utesluta äldre meddelanden utan att ta bort dem från din historik." } } - }, - "comment" : "A placeholder for the API key field." + } }, - "Issue" : { + "OpenClient version %@ is available. Would you like to update now?" : { + "comment" : "A message that is displayed in a notification when an update is available. The argument is the version number of the update.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Problem", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient-Version %@ ist verfügbar. Möchten Sie jetzt aktualisieren?" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Problema" + "value" : "Η έκδοση %@ του OpenClient είναι διαθέσιμη. Θέλετε να κάνετε ενημέρωση τώρα;" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "問題" + "value" : "OpenClient version %@ is available. Would you like to update now?" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Problema", - "state" : "translated" + "state" : "translated", + "value" : "La versión %@ de OpenClient está disponible. ¿Quieres actualizar ahora?" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Problema", - "state" : "translated" + "state" : "translated", + "value" : "La version %@ d’OpenClient est disponible. Voulez-vous effectuer la mise à jour maintenant ?" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Issue", - "state" : "translated" + "state" : "translated", + "value" : "È disponibile la versione %@ di OpenClient. Vuoi aggiornarla ora?" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Problème", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientバージョン %@ が利用可能です。今すぐアップデートしますか?" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Probleem" + "value" : "OpenClient-versie %@ is beschikbaar. Wil je nu bijwerken?" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πρόβλημα", - "state" : "translated" + "state" : "translated", + "value" : "A versão %@ do OpenClient está disponível. Pretende atualizar agora?" } }, "sv" : { "stringUnit" : { - "value" : "Problem", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient-version %@ är tillgänglig. Vill du uppdatera nu?" } } } }, - "Buy Me a Coffee" : { + "Opens a new conversation in OpenClient." : { + "comment" : "Description of the control center widget that opens a new conversation in OpenClient.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Kauf mir einen Kaffee", - "state" : "translated" + "state" : "translated", + "value" : "Öffnet eine neue Unterhaltung in OpenClient." } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Bjud mig på en kaffe" + "value" : "Ανοίγει μια νέα συνομιλία στο OpenClient." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Invítame a un café" + "value" : "Opens a new conversation in OpenClient" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Offrimi un caffè", - "state" : "translated" + "state" : "translated", + "value" : "Abre una nueva conversación en OpenClient." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Oferecer um Café", - "state" : "translated" + "state" : "translated", + "value" : "Ouvre une nouvelle conversation dans OpenClient." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Buy Me a Coffee", - "state" : "translated" + "state" : "translated", + "value" : "Apre una nuova conversazione in OpenClient" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Offrez-moi un café", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientで新しい会話を開始します" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Trakteer me op een koffie" + "value" : "Opent een nieuw gesprek in OpenClient." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "コーヒーをおごる", - "state" : "translated" + "state" : "translated", + "value" : "Abre uma nova conversa no OpenClient." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Κάνε μου μια δωρεά καφέ", - "state" : "translated" + "state" : "translated", + "value" : "Öppnar en ny konversation i OpenClient." } } - }, - "comment" : "A button that opens a payment interface to support the app's development." + } }, - "How does Swift concurrency work?" : { + "Opens OpenClient and starts a new conversation." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Swiftの並行処理はどう機能するのか?" + "value" : "Öffnet OpenClient und startet eine neue Unterhaltung." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¿Cómo funciona la concurrencia en Swift?", - "state" : "translated" + "state" : "translated", + "value" : "Ανοίγει το OpenClient και ξεκινά μια νέα συνομιλία." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hur fungerar Swift-konkurens?" + "value" : "Opens OpenClient and starts a new conversation." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Come funziona la concorrenza in Swift?", - "state" : "translated" + "state" : "translated", + "value" : "Abre OpenClient y comienza una nueva conversación." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Como funciona a concorrência em Swift?", - "state" : "translated" + "state" : "translated", + "value" : "Ouvre OpenClient et démarre une nouvelle conversation." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "How does Swift concurrency work?", - "state" : "translated" + "state" : "translated", + "value" : "Apre OpenClient e avvia una nuova conversazione." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Comment fonctionne la concurrence en Swift ?", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientを開き、新しい会話を開始します。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hoe werkt Swift-concurrentie?" + "value" : "Opent OpenClient en start een nieuw gesprek." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πώς λειτουργεί η ασύγχρονη εκτέλεση στο Swift;", - "state" : "translated" + "state" : "translated", + "value" : "Abre o OpenClient e inicia uma nova conversa." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Wie funktioniert Swift Concurrency?", - "state" : "translated" + "state" : "translated", + "value" : "Öppnar OpenClient och startar en ny konversation." } } - }, - "comment" : "Title of a conversation." + } }, - "Suggested anonymously" : { + "Opens OpenClient with the conversation search field active." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Προταθεί ανώνυμα" + "value" : "Öffnet OpenClient mit aktivem Suchfeld für Konversationen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sugerido de forma anónima", - "state" : "translated" + "state" : "translated", + "value" : "Ανοίγει το OpenClient με ενεργό το πεδίο αναζήτησης συνομιλίας." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Anonym vorgeschlagen" + "value" : "Opens OpenClient with the conversation search field active." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Suggerito anonimamente", - "state" : "translated" + "state" : "translated", + "value" : "Abre OpenClient con el campo de búsqueda de conversación activo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sugerido anonimamente" + "value" : "Ouvre OpenClient avec le champ de recherche de conversation actif." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Suggested anonymously", - "state" : "translated" + "state" : "translated", + "value" : "Apre OpenClient con il campo di ricerca conversazioni attivo." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Anoniem voorgesteld", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientを会話検索フィールドがアクティブな状態で開く。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Suggéré anonymement", - "state" : "translated" + "state" : "translated", + "value" : "Opent OpenClient met het zoekveld voor gesprekken actief." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "匿名で提案されました", - "state" : "translated" + "state" : "translated", + "value" : "Abre o OpenClient com o campo de pesquisa da conversa ativo." } }, "sv" : { "stringUnit" : { - "value" : "Föreslagen anonymt", - "state" : "translated" + "state" : "translated", + "value" : "Öppnar OpenClient med sökfältet för konversation aktivt." } } } }, - "Balanced" : { + "Optimised for LiteLLM. Any OpenAI-compatible server also works." : { + "comment" : "A hint that describes the benefits of using a LiteLLM server.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Ausgeglichen", - "state" : "translated" + "state" : "translated", + "value" : "Optimiert für LiteLLM. Jeder OpenAI-kompatible Server funktioniert ebenfalls." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Equilibrado", - "state" : "translated" + "state" : "translated", + "value" : "Βελτιστοποιημένο για LiteLLM. Λειτουργεί επίσης με οποιονδήποτε διακομιστή συμβατό με OpenAI." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Balanserad" + "value" : "Optimized for LiteLLM. Any OpenAI-compatible server also works." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Bilanciato", - "state" : "translated" + "state" : "translated", + "value" : "Optimizado para LiteLLM. También funciona con cualquier servidor compatible con OpenAI." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Equilibrada", - "state" : "translated" + "state" : "translated", + "value" : "Optimisé pour LiteLLM. Tout serveur compatible OpenAI fonctionne également." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Balanced", - "state" : "translated" + "state" : "translated", + "value" : "Ottimizzato per LiteLLM. Funziona anche con qualsiasi server compatibile OpenAI." } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Équilibré" + "value" : "LiteLLMに最適化。OpenAI互換のサーバーも利用可能。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gebalanceerd" + "value" : "Geoptimaliseerd voor LiteLLM. Elke OpenAI-compatibele server werkt ook." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "バランス型", - "state" : "translated" + "state" : "translated", + "value" : "Otimizado para LiteLLM. Qualquer servidor compatível com OpenAI também funciona." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Ισορροπημένη", - "state" : "translated" + "state" : "translated", + "value" : "Optimerad för LiteLLM. Fungerar även med alla OpenAI-kompatibla servrar." } } - }, - "comment" : "A description of a temperature value." + } }, - "Share Extension" : { + "Optional" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "共有エクステンション" + "value" : "Optional" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Extensión para compartir", - "state" : "translated" + "state" : "translated", + "value" : "Προαιρετικό" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Freigabeerweiterung" + "value" : "Optional" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Estensione di condivisione", - "state" : "translated" + "state" : "translated", + "value" : "Opcional" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Extensão de Partilha", - "state" : "translated" + "state" : "translated", + "value" : "Optionnel" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Share Extension", - "state" : "translated" + "state" : "translated", + "value" : "Opzionale" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Extension de partage", - "state" : "translated" + "state" : "translated", + "value" : "任意" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Deeluitbreiding" + "value" : "Optioneel" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επέκταση Κοινοποίησης", - "state" : "translated" + "state" : "translated", + "value" : "Opcional" } }, "sv" : { "stringUnit" : { - "value" : "Dela-tillägg", - "state" : "translated" + "state" : "translated", + "value" : "Valfri" } } - }, - "comment" : "A section that describes how to use the share extension to share content with the app." + } }, - "Recent" : { + "or" : { + "comment" : "Text for the \"or\" option in a list of options.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Πρόσφατα", - "state" : "translated" + "state" : "translated", + "value" : "oder" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Recientes", - "state" : "translated" + "state" : "translated", + "value" : "ή" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Neueste" + "value" : "or" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Recenti", - "state" : "translated" + "state" : "translated", + "value" : "o" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Recentes" + "value" : "ou" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Recent", - "state" : "translated" + "state" : "translated", + "value" : "o" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Recentelijk", - "state" : "translated" + "state" : "translated", + "value" : "または" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Récent" + "value" : "of" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "最近の会話", - "state" : "translated" + "state" : "translated", + "value" : "ou" } }, "sv" : { "stringUnit" : { - "value" : "Senaste", - "state" : "translated" + "state" : "translated", + "value" : "eller" } } - }, - "comment" : "A heading for the recent conversations section." + } }, - "Update available" : { + "Orange" : { + "comment" : "Name of the color orange.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "アップデートがあります" + "value" : "Orange" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Actualización disponible", - "state" : "translated" + "state" : "translated", + "value" : "Πορτοκαλί" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Διαθέσιμη ενημέρωση" + "value" : "Orange" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiornamento disponibile", - "state" : "translated" + "state" : "translated", + "value" : "Naranja" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Atualização disponível" + "value" : "Orange" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Update available", - "state" : "translated" + "state" : "translated", + "value" : "Arancione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Mise à jour disponible", - "state" : "translated" + "state" : "translated", + "value" : "オレンジ" } }, "nl" : { "stringUnit" : { - "value" : "Update beschikbaar", - "state" : "translated" + "state" : "translated", + "value" : "Oranje" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Update verfügbar", - "state" : "translated" + "state" : "translated", + "value" : "Laranja" } }, "sv" : { "stringUnit" : { - "value" : "Uppdatering tillgänglig", - "state" : "translated" + "state" : "translated", + "value" : "Orange" } } - }, - "comment" : "A title for an alert that notifies the user that an update is available." + } }, - "Open Settings" : { + "Organise your conversations" : { + "comment" : "A label displayed in the chat interface that allows the user to organise their conversations.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "設定を開く", - "state" : "translated" + "state" : "translated", + "value" : "Organisiere deine Unterhaltungen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir ajustes" + "value" : "Οργάνωσε τις συνομιλίες σου" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Öppna inställningar" + "value" : "Organize your conversations" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Apri Impostazioni" + "value" : "Organiza tus conversaciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Abrir Definições", - "state" : "translated" + "state" : "translated", + "value" : "Organisez vos conversations" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Open Settings", - "state" : "translated" + "state" : "translated", + "value" : "Organizza le tue conversazioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Open instellingen", - "state" : "translated" + "state" : "translated", + "value" : "会話を整理する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ouvrir les Réglages", - "state" : "translated" + "state" : "translated", + "value" : "Organiseer je gesprekken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Άνοιγμα ρυθμίσεων", - "state" : "translated" + "state" : "translated", + "value" : "Organize as suas conversas" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Einstellungen öffnen", - "state" : "translated" + "state" : "translated", + "value" : "Organisera dina konversationer" } } - }, - "comment" : "A button that opens the user's device settings." + } }, - "Chats" : { + "Output" : { + "comment" : "A label for the cost of output tokens.", + "shouldTranslate" : false + }, + "Output tokens" : { + "comment" : "A label for the maximum number of output tokens a model can generate.", + "shouldTranslate" : false + }, + "PDF Document" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Chats", - "state" : "translated" + "state" : "translated", + "value" : "PDF-Dokument" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Chats", - "state" : "translated" + "state" : "translated", + "value" : "Έγγραφο PDF" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Chattar" + "value" : "PDF Document" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Chat" + "value" : "Documento PDF" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Conversas", - "state" : "translated" + "state" : "translated", + "value" : "Document PDF" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Chats", - "state" : "translated" + "state" : "translated", + "value" : "Documento PDF" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Chats", - "state" : "translated" + "state" : "translated", + "value" : "PDFドキュメント" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Discussions" + "value" : "PDF-document" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "チャット", - "state" : "translated" + "state" : "translated", + "value" : "Documento PDF" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Συζητήσεις", - "state" : "translated" + "state" : "translated", + "value" : "PDF-dokument" } } } }, - "tag.text" : { + "Pending" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "Text", - "state" : "translated" + "state" : "translated", + "value" : "Ausstehend" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Text" + "value" : "Εκκρεμεί" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Text" + "value" : "Pending" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Text", - "state" : "translated" + "state" : "translated", + "value" : "Pendiente" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Text", - "state" : "translated" + "state" : "translated", + "value" : "En attente" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Text", - "state" : "translated" + "state" : "translated", + "value" : "In sospeso" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Text", - "state" : "translated" + "state" : "translated", + "value" : "保留中" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Text" + "value" : "In behandeling" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Text", - "state" : "translated" + "state" : "translated", + "value" : "Pendente" } }, "sv" : { "stringUnit" : { - "value" : "Text", - "state" : "translated" + "state" : "translated", + "value" : "Väntar" } } - }, - "comment" : "Label for a text-related capability of an LLM model." + } }, - "Feature Tips Reset" : { + "Personal context" : { + "comment" : "A category of data that can be synchronized via iCloud.", + "isCommentAutoGenerated" : true + }, + "Personal Context" : { + "comment" : "A button that opens a sheet for configuring the user's name and personal context.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Feature-Tipps zurücksetzen", - "state" : "translated" + "state" : "translated", + "value" : "Persönlicher Kontext" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Restablecer consejos de funciones", - "state" : "translated" + "state" : "translated", + "value" : "Προσωπικό Πλαίσιο" } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "Återställ tips för funktioner", - "state" : "translated" + "state" : "translated", + "value" : "Personal Context" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Suggerimenti Funzionalità Reimpostati", - "state" : "translated" + "state" : "translated", + "value" : "Contexto personal" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Repor Dicas de Funcionalidades", - "state" : "translated" + "state" : "translated", + "value" : "Contexte personnel" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Feature Tips Reset" + "value" : "Contesto personale" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Réinitialisation des astuces de fonctionnalité" + "value" : "個人情報" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Functietips resetten" + "value" : "Persoonlijke context" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επαναφορά Συμβουλών Χαρακτηριστικών", - "state" : "translated" + "state" : "translated", + "value" : "Contexto Pessoal" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "機能ヒントのリセット", - "state" : "translated" + "state" : "translated", + "value" : "Personlig kontext" } } - }, - "comment" : "A title for an alert that informs the user that the feature tips have been reset." + } }, - "Loading comments..." : { + "Personal context needs conflict resolution before it can synchronize." : { + "comment" : "Error message displayed when the user's personal context needs to be resolved before it can synchronize.", + "isCommentAutoGenerated" : true + }, + "Personalization" : { + "comment" : "A heading for the personalization settings.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Φόρτωση σχολίων...", - "state" : "translated" + "state" : "translated", + "value" : "Personalisierung" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cargando comentarios...", - "state" : "translated" + "state" : "translated", + "value" : "Εξατομίκευση" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentare werden geladen..." + "value" : "Personalization" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Caricamento commenti...", - "state" : "translated" + "state" : "translated", + "value" : "Personalización" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A carregar comentários..." + "value" : "Personnalisation" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Loading comments..." + "value" : "Personalizzazione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Reacties laden...", - "state" : "translated" + "state" : "translated", + "value" : "パーソナライズ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Chargement des commentaires...", - "state" : "translated" + "state" : "translated", + "value" : "Personalisatie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "コメントを読み込み中...", - "state" : "translated" + "state" : "translated", + "value" : "Personalização" } }, "sv" : { "stringUnit" : { - "value" : "Läser in kommentarer...", - "state" : "translated" + "state" : "translated", + "value" : "Personalisering" } } } }, - "Add tag..." : { + "Photo Library" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Lägg till tagg..." + "value" : "Fotobibliothek" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Agregar etiqueta...", - "state" : "translated" + "state" : "translated", + "value" : "Βιβλιοθήκη Φωτογραφιών" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tag hinzufügen..." + "value" : "Photo Library" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiungi tag...", - "state" : "translated" + "state" : "translated", + "value" : "Biblioteca de fotos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Adicionar etiqueta..." + "value" : "Bibliothèque de photos" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Add tag...", - "state" : "translated" + "state" : "translated", + "value" : "Libreria foto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Tag toevoegen...", - "state" : "translated" + "state" : "translated", + "value" : "写真ライブラリ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ajouter un tag...", - "state" : "translated" + "state" : "translated", + "value" : "Fotobibliotheek" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προσθήκη ετικέτας...", - "state" : "translated" + "state" : "translated", + "value" : "Biblioteca de Fotos" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "タグを追加...", - "state" : "translated" + "state" : "translated", + "value" : "Fotobibliotek" } } - }, - "comment" : "A placeholder for a text field that adds a tag to a conversation." + } }, - "Enter a new name for this conversation." : { + "Pin" : { + "comment" : "A pin icon.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Geben Sie einen neuen Namen für diese Unterhaltung ein.", - "state" : "translated" + "state" : "translated", + "value" : "Anheften" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Introduce un nuevo nombre para esta conversación.", - "state" : "translated" + "state" : "translated", + "value" : "Καρφίτσωμα" } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "この会話の新しい名前を入力してください", - "state" : "translated" + "state" : "translated", + "value" : "Pin" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inserisci un nuovo nome per questa conversazione", - "state" : "translated" + "state" : "translated", + "value" : "Fijar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Introduza um novo nome para esta conversa.", - "state" : "translated" + "state" : "translated", + "value" : "Épingler" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Enter a new name for this conversation" + "value" : "Fissa" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Entrez un nouveau nom pour cette conversation." + "value" : "ピン" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voer een nieuwe naam in voor dit gesprek." + "value" : "Vastzetten" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εισαγάγετε ένα νέο όνομα για αυτή τη συνομιλία.", - "state" : "translated" + "state" : "translated", + "value" : "Alfinete" } }, "sv" : { "stringUnit" : { - "value" : "Ange ett nytt namn för den här konversationen.", - "state" : "translated" + "state" : "translated", + "value" : "Stift" } } - }, - "comment" : "A message displayed in an alert when renaming a conversation." + } }, - "Top P" : { + "Pinned" : { + "comment" : "Title for the section of conversations that are pinned.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κορυφαίο P", - "state" : "translated" + "state" : "translated", + "value" : "Angeheftet" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Top P", - "state" : "translated" + "state" : "translated", + "value" : "Καρφιτσωμένα" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Top P", - "state" : "translated" + "state" : "translated", + "value" : "Pinned" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Top P" + "value" : "Fijado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Top P", - "state" : "translated" + "state" : "translated", + "value" : "Épinglé" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Top P", - "state" : "translated" + "state" : "translated", + "value" : "Fissate" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Top P" + "value" : "ピン留め済み" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Top P" + "value" : "Vastgezet" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "トップP", - "state" : "translated" + "state" : "translated", + "value" : "Fixadas" } }, "sv" : { "stringUnit" : { - "value" : "Topp P", - "state" : "translated" + "state" : "translated", + "value" : "Fastnålad" } } } }, - "Your comment" : { + "Pinned Conversations" : { + "comment" : "Title of the widget that shows pinned conversations.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το σχόλιό σας", - "state" : "translated" + "state" : "translated", + "value" : "Angeheftete Unterhaltungen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Tu comentario", - "state" : "translated" + "state" : "translated", + "value" : "Καρφιτσωμένες Συνομιλίες" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "あなたのコメント" + "value" : "Pinned Conversations" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il tuo commento" + "value" : "Conversaciones fijadas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O seu comentário", - "state" : "translated" + "state" : "translated", + "value" : "Conversations épinglées" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Your comment" + "value" : "Conversazioni fissate" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je opmerking", - "state" : "translated" + "state" : "translated", + "value" : "ピン留めされた会話" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Votre commentaire", - "state" : "translated" + "state" : "translated", + "value" : "Vastgezette gesprekken" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ihr Kommentar", - "state" : "translated" + "state" : "translated", + "value" : "Conversas Fixadas" } }, "sv" : { "stringUnit" : { - "value" : "Din kommentar", - "state" : "translated" + "state" : "translated", + "value" : "Fästa konversationer" } } } }, - "Notifications enabled" : { + "Plan the next project" : { + "comment" : "Title of a conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "通知が有効です" + "value" : "Das nächste Projekt planen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Notificaciones activadas", - "state" : "translated" + "state" : "translated", + "value" : "Σχεδίαση του επόμενου έργου" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Benachrichtigungen aktiviert" + "value" : "Plan the next project" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Notifiche attivate", - "state" : "translated" + "state" : "translated", + "value" : "Planificar el próximo proyecto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Notificações ativadas" + "value" : "Planifier le prochain projet" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Notifications enabled", - "state" : "translated" + "state" : "translated", + "value" : "Pianifica il prossimo progetto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Meldingen ingeschakeld", - "state" : "translated" + "state" : "translated", + "value" : "次のプロジェクトを計画する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Notifications activées", - "state" : "translated" + "state" : "translated", + "value" : "Plan het volgende project" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ειδοποιήσεις ενεργοποιημένες", - "state" : "translated" + "state" : "translated", + "value" : "Planear o próximo projeto" } }, "sv" : { "stringUnit" : { - "value" : "Aviseringar aktiverade", - "state" : "translated" + "state" : "translated", + "value" : "Planera nästa projekt" } } - }, - "comment" : "A label that indicates that notifications are enabled." + } }, - "Update required" : { + "Post" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Update erforderlich" + "value" : "Beitrag" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Actualización necesaria", - "state" : "translated" + "state" : "translated", + "value" : "Ανάρτηση" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Uppdatering krävs" + "value" : "Post" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiornamento richiesto", - "state" : "translated" + "state" : "translated", + "value" : "Publicar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Atualização necessária", - "state" : "translated" + "state" : "translated", + "value" : "Publier" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Update required", - "state" : "translated" + "state" : "translated", + "value" : "Pubblica" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Update vereist", - "state" : "translated" + "state" : "translated", + "value" : "投稿" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mise à jour requise" + "value" : "Plaatsen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アップデートが必要です", - "state" : "translated" + "state" : "translated", + "value" : "Publicar" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Απαιτείται ενημέρωση", - "state" : "translated" + "state" : "translated", + "value" : "Inlägg" } } - }, - "comment" : "A title for the update required alert." + } }, - "Pending" : { + "Prepare meeting notes" : { + "comment" : "Title of a conversation.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Väntar" + "value" : "Besprechungsnotizen vorbereiten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Pendiente", - "state" : "translated" + "state" : "translated", + "value" : "Προετοιμασία σημειώσεων συνάντησης" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εκκρεμεί" + "value" : "Prepare meeting notes" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "In sospeso", - "state" : "translated" + "state" : "translated", + "value" : "Preparar notas de la reunión" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Pendente", - "state" : "translated" + "state" : "translated", + "value" : "Préparer les notes de réunion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Pending", - "state" : "translated" + "state" : "translated", + "value" : "Prepara appunti della riunione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "In behandeling", - "state" : "translated" + "state" : "translated", + "value" : "会議メモの準備" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "En attente" + "value" : "Notulen voorbereiden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ausstehend", - "state" : "translated" + "state" : "translated", + "value" : "Preparar notas da reunião" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "保留中", - "state" : "translated" + "state" : "translated", + "value" : "Förbered mötesanteckningar" } } } }, - "Output tokens" : { - "shouldTranslate" : false, - "comment" : "A label for the maximum number of output tokens a model can generate." - }, - "Summarize a long text" : { + "Preparing image" : { + "comment" : "A label for an in-progress image preparation task.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Περίληψη μεγάλου κειμένου", - "state" : "translated" + "state" : "translated", + "value" : "Bild wird vorbereitet" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Resumir un texto largo", - "state" : "translated" + "state" : "translated", + "value" : "Προετοιμασία εικόνας" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "長文を要約する" + "value" : "Preparing image" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Riassumi un testo lungo", - "state" : "translated" + "state" : "translated", + "value" : "Preparando la imagen" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Resumir um texto longo", - "state" : "translated" + "state" : "translated", + "value" : "Préparation de l’image" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Summarize a long text" + "value" : "Preparazione dell'immagine" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vat een lange tekst samen", - "state" : "translated" + "state" : "translated", + "value" : "画像を準備中" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Résumer un long texte" + "value" : "Afbeelding voorbereiden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Einen langen Text zusammenfassen", - "state" : "translated" + "state" : "translated", + "value" : "A preparar a imagem" } }, "sv" : { "stringUnit" : { - "value" : "Sammanfatta en lång text", - "state" : "translated" + "state" : "translated", + "value" : "Förbereder bild" } } } }, - "Rename" : { + "Pricing" : { + "comment" : "A section that displays the pricing information for a model.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "名前を変更", - "state" : "translated" + "state" : "translated", + "value" : "Preise" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Renombrar", - "state" : "translated" + "state" : "translated", + "value" : "Τιμολόγηση" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Umbenennen" + "value" : "Pricing" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Rinomina", - "state" : "translated" + "state" : "translated", + "value" : "Precios" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Renomear", - "state" : "translated" + "state" : "translated", + "value" : "Tarification" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Rename" + "value" : "Prezzi" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Hernoemen", - "state" : "translated" + "state" : "translated", + "value" : "価格情報" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Renommer" + "value" : "Prijzen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Μετονομασία", - "state" : "translated" + "state" : "translated", + "value" : "Preços" } }, "sv" : { "stringUnit" : { - "value" : "Byt namn", - "state" : "translated" + "state" : "translated", + "value" : "Prissättning" } } - }, - "comment" : "A button that renames a conversation." + } }, - "Get Started" : { + "Privacy First" : { + "comment" : "A description of the privacy features of OpenClient.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "はじめる", - "state" : "translated" + "state" : "translated", + "value" : "Datenschutz zuerst" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Comenzar", - "state" : "translated" + "state" : "translated", + "value" : "Προτεραιότητα στην ιδιωτικότητα" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Ξεκινήστε", - "state" : "translated" + "state" : "translated", + "value" : "Privacy First" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inizia", - "state" : "translated" + "state" : "translated", + "value" : "Privacidad ante todo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Começar" + "value" : "Confidentialité prioritaire" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Get Started", - "state" : "translated" + "state" : "translated", + "value" : "Privacy prima di tutto" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Commencer" + "value" : "プライバシー最優先" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aan de slag" + "value" : "Privacy eerst" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Loslegen", - "state" : "translated" + "state" : "translated", + "value" : "Privacidade em Primeiro Lugar" } }, "sv" : { "stringUnit" : { - "value" : "Kom igång", - "state" : "translated" + "state" : "translated", + "value" : "Sekretess i första hand" } } } }, - "Tap + to create your first custom prompt template." : { + "Privacy Policy" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Tryck på + för att skapa din första anpassade promptmall." + "value" : "Datenschutzerklärung" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Toca + para crear tu primera plantilla de indicación personalizada.", - "state" : "translated" + "state" : "translated", + "value" : "Πολιτική Απορρήτου" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "+ をタップして最初のカスタムプロンプトテンプレートを作成してください。" + "value" : "Privacy Policy" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tocca + per creare il tuo primo modello di prompt personalizzato.", - "state" : "translated" + "state" : "translated", + "value" : "Política de privacidad" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Toque em + para criar o seu primeiro modelo de prompt personalizado.", - "state" : "translated" + "state" : "translated", + "value" : "Politique de confidentialité" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Tap + to create your first custom prompt template", - "state" : "translated" + "state" : "translated", + "value" : "Informativa sulla privacy" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Touchez + pour créer votre premier modèle d’invite personnalisé.", - "state" : "translated" + "state" : "translated", + "value" : "プライバシーポリシー" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tik op + om je eerste aangepaste promptsjabloon te maken." + "value" : "Privacybeleid" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πατήστε + για να δημιουργήσετε το πρώτο σας προσαρμοσμένο πρότυπο προτροπής.", - "state" : "translated" + "state" : "translated", + "value" : "Política de Privacidade" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Tippe auf +, um deine erste benutzerdefinierte Eingabevorlage zu erstellen.", - "state" : "translated" + "state" : "translated", + "value" : "Integritetspolicy" } } - }, - "comment" : "A description of the action to create a custom prompt template." + } }, - "tag.parallel.tools" : { + "Private Chat" : { + "comment" : "A label displayed in the empty state view.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Parallel Tools", - "state" : "translated" + "state" : "translated", + "value" : "Privater Chat" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Parallel Tools", - "state" : "translated" + "state" : "translated", + "value" : "Ιδιωτική Συνομιλία" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Parallel Tools" + "value" : "Private Chat" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Parallel Tools" + "value" : "Chat privado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Parallel Tools", - "state" : "translated" + "state" : "translated", + "value" : "Discussion privée" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Parallel Tools", - "state" : "translated" + "state" : "translated", + "value" : "Chat privata" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Parallel Tools", - "state" : "translated" + "state" : "translated", + "value" : "プライベートチャット" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Parallel Tools" + "value" : "Privéchat" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Parallel Tools", - "state" : "translated" + "state" : "translated", + "value" : "Conversa Privada" } }, "sv" : { "stringUnit" : { - "value" : "Parallel Tools", - "state" : "translated" + "state" : "translated", + "value" : "Privatchatt" } } - }, - "comment" : "Label for a capability that allows parallel function calls." + } }, - "Approximate cost of this conversation based on token usage and model pricing." : { + "Private chats are not saved or synced, and they do not read or change personal memory." : { + "comment" : "A description of private chats.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ungefähre Kosten dieses Gesprächs basierend auf Tokenverbrauch und Modellpreisen." + "value" : "Private Chats werden nicht gespeichert oder synchronisiert und lesen oder ändern das persönliche Gedächtnis nicht." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Costo aproximado de esta conversación basado en el uso de tokens y la tarifa del modelo.", - "state" : "translated" + "state" : "translated", + "value" : "Οι ιδιωτικές συνομιλίες δεν αποθηκεύονται ή συγχρονίζονται και δεν διαβάζουν ούτε αλλάζουν την προσωπική μνήμη." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "この会話の概算コスト(トークン使用量とモデル料金に基づく)" + "value" : "Private chats are not saved or synced, and they do not read or modify personal memory." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Costo approssimativo di questa conversazione basato sull’uso dei token e sul prezzo del modello.", - "state" : "translated" + "state" : "translated", + "value" : "Los chats privados no se guardan ni sincronizan, y no leen ni modifican la memoria personal." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Custo aproximado desta conversa com base no uso de tokens e preços do modelo.", - "state" : "translated" + "state" : "translated", + "value" : "Les discussions privées ne sont pas enregistrées ni synchronisées, et elles ne lisent ni ne modifient la mémoire personnelle." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Approximate cost of this conversation based on token usage and model pricing.", - "state" : "translated" + "state" : "translated", + "value" : "Le chat private non vengono salvate né sincronizzate, e non leggono né modificano la memoria personale." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geschatte kosten van dit gesprek op basis van tokengebruik en modelprijzen.", - "state" : "translated" + "state" : "translated", + "value" : "プライベートチャットは保存や同期されず、個人の記憶を読み取ったり変更したりしません。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Coût approximatif de cette conversation basé sur l’utilisation des tokens et la tarification du modèle.", - "state" : "translated" + "state" : "translated", + "value" : "Privégesprekken worden niet opgeslagen of gesynchroniseerd en lezen of wijzigen geen persoonlijke herinneringen." } }, - "el" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Προσεγγιστικό κόστος αυτής της συνομιλίας βάσει χρήσης tokens και τιμολόγησης μοντέλου." + "value" : "As conversas privadas não são guardadas nem sincronizadas, e não leem nem alteram a memória pessoal." } }, "sv" : { "stringUnit" : { - "value" : "Ungefärlig kostnad för denna konversation baserat på tokenanvändning och modellpriser.", - "state" : "translated" + "state" : "translated", + "value" : "Privata chattar sparas inte eller synkroniseras, och de läser inte eller ändrar personlig minne." } } - }, - "comment" : "A description of the cost of a conversation." + } }, - "Built-in" : { + "Processing..." : { + "comment" : "A message displayed when the user is being processed.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "組み込み" + "value" : "Verarbeitung..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Incorporado", - "state" : "translated" + "state" : "translated", + "value" : "Επεξεργασία..." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ενσωματωμένα" + "value" : "Processing..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Integrato", - "state" : "translated" + "state" : "translated", + "value" : "Procesando..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Integrado", - "state" : "translated" + "state" : "translated", + "value" : "Traitement en cours..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Built-in", - "state" : "translated" + "state" : "translated", + "value" : "Elaborazione in corso..." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Intégré", - "state" : "translated" + "state" : "translated", + "value" : "処理中..." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ingebouwd" + "value" : "Bezig met verwerken..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Eingebaut", - "state" : "translated" + "state" : "translated", + "value" : "A processar..." } }, "sv" : { "stringUnit" : { - "value" : "Inbyggd", - "state" : "translated" + "state" : "translated", + "value" : "Bearbetar..." } } - }, - "comment" : "A section title for built-in templates." + } }, - "Help us fix it by describing the issue you encountered." : { + "Prompt" : { + "comment" : "A label displayed above the prompt text field.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "発生した問題について説明して、修正にご協力ください。", - "state" : "translated" + "state" : "translated", + "value" : "Eingabeaufforderung" } }, "el" : { "stringUnit" : { - "value" : "Βοηθήστε μας να το διορθώσουμε περιγράφοντας το πρόβλημα που αντιμετωπίσατε.", - "state" : "translated" + "state" : "translated", + "value" : "Ερώτημα" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Ayúdanos a solucionarlo describiendo el problema que encontraste.", - "state" : "translated" + "state" : "translated", + "value" : "Prompt" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Aiutaci a risolverlo descrivendo il problema riscontrato." + "value" : "Prompt" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ajude-nos a corrigir descrevendo o problema que encontrou.", - "state" : "translated" + "state" : "translated", + "value" : "Invite" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Help us fix it by describing the issue you encountered." + "value" : "Prompt" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Aidez-nous à le corriger en décrivant le problème rencontré.", - "state" : "translated" + "state" : "translated", + "value" : "プロンプト" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Help ons het op te lossen door het probleem dat je bent tegengekomen te beschrijven." + "value" : "Prompt" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Hilf uns, das Problem zu beheben, indem du das aufgetretene Problem beschreibst.", - "state" : "translated" + "state" : "translated", + "value" : "Indicação" } }, "sv" : { "stringUnit" : { - "value" : "Hjälp oss att åtgärda det genom att beskriva problemet du stötte på.", - "state" : "translated" + "state" : "translated", + "value" : "Anvisning" } } } }, - "Start Chatting" : { + "Prompt Library" : { + "comment" : "A title for a screen that lists and creates custom input prompts.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Chat starten" + "value" : "Prompt-Bibliothek" } }, - "pt-PT" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Iniciar Conversa" + "value" : "Βιβλιοθήκη Ερωτημάτων" } }, "en" : { "stringUnit" : { - "value" : "Start Chatting", - "state" : "translated" + "state" : "translated", + "value" : "Prompt Library" } }, - "nl" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Begin met chatten" + "value" : "Biblioteca de prompts" } }, - "ja" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "チャットを始める" + "value" : "Bibliothèque de prompts" } }, - "fr" : { + "it" : { "stringUnit" : { - "value" : "Commencer la discussion", - "state" : "translated" + "state" : "translated", + "value" : "Libreria di Prompt" } }, - "it" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Inizia a chattare" + "value" : "プロンプトライブラリ" } }, - "sv" : { + "nl" : { "stringUnit" : { - "value" : "Börja chatta", - "state" : "translated" + "state" : "translated", + "value" : "Promptbibliotheek" } }, - "el" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Ξεκινήστε τη συνομιλία" + "value" : "Biblioteca de Prompts" } }, - "es" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Comenzar a chatear" + "value" : "Promptbibliotek" } } } }, - "Add" : { + "Prompt templates" : { + "comment" : "A prompt template.", + "isCommentAutoGenerated" : true + }, + "Provider" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Hinzufügen" + "value" : "Anbieter" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Añadir", - "state" : "translated" + "state" : "translated", + "value" : "Πάροχος" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Προσθήκη" + "value" : "Provider" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiungi", - "state" : "translated" + "state" : "translated", + "value" : "Proveedor" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Adicionar", - "state" : "translated" + "state" : "translated", + "value" : "Fournisseur" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Add" + "value" : "Fornitore" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Ajouter", - "state" : "translated" + "state" : "translated", + "value" : "プロバイダー" } }, "nl" : { "stringUnit" : { - "value" : "Toevoegen", - "state" : "translated" + "state" : "translated", + "value" : "Provider" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "追加", - "state" : "translated" + "state" : "translated", + "value" : "Fornecedor" } }, "sv" : { "stringUnit" : { - "value" : "Lägg till", - "state" : "translated" + "state" : "translated", + "value" : "Leverantör" } } - }, - "comment" : "A button that adds a tag." - }, - "~$%.4f" : { - "shouldTranslate" : false, - "comment" : "A monetary value displayed in the chat interface." + } }, - "Add a comment" : { + "Purple" : { + "comment" : "Name of a tag color.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "コメントを追加" + "value" : "Lila" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Agregar un comentario", - "state" : "translated" + "state" : "translated", + "value" : "Μωβ" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar hinzufügen" + "value" : "Purple" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiungi un commento", - "state" : "translated" + "state" : "translated", + "value" : "Púrpura" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Adicionar um comentário", - "state" : "translated" + "state" : "translated", + "value" : "Violet" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Add a comment" + "value" : "Viola" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Een opmerking toevoegen", - "state" : "translated" + "state" : "translated", + "value" : "パープル" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ajouter un commentaire", - "state" : "translated" + "state" : "translated", + "value" : "Paars" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προσθήκη σχολίου", - "state" : "translated" + "state" : "translated", + "value" : "Roxo" } }, "sv" : { "stringUnit" : { - "value" : "Lägg till en kommentar", - "state" : "translated" + "state" : "translated", + "value" : "Lila" } } } }, - "Let the model find current information and include the sources it used." : { + "Quantum entanglement is a phenomenon where..." : { + "comment" : "Text of a message preview in a conversation.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Lassen Sie das Modell aktuelle Informationen finden und die verwendeten Quellen angeben.", - "state" : "translated" + "state" : "translated", + "value" : "Quantenverschränkung ist ein Phänomen, bei dem..." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Αφήστε το μοντέλο να βρει τρέχουσες πληροφορίες και να συμπεριλάβει τις πηγές που χρησιμοποίησε." + "value" : "Η κβαντική εμπλοκή είναι ένα φαινόμενο όπου..." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Permite que el modelo busque información actual e incluya las fuentes que utilizó." + "value" : "Quantum entanglement is a phenomenon where..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Lascia che il modello trovi informazioni aggiornate e includa le fonti utilizzate.", - "state" : "translated" + "state" : "translated", + "value" : "El entrelazamiento cuántico es un fenómeno donde..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Deixe o modelo encontrar informações atuais e incluir as fontes que utilizou.", - "state" : "translated" + "state" : "translated", + "value" : "L’intrication quantique est un phénomène où..." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Allow the model to find current information and include the sources it used." + "value" : "L’entanglement quantistico è un fenomeno in cui..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Laat het model actuele informatie vinden en de gebruikte bronnen vermelden.", - "state" : "translated" + "state" : "translated", + "value" : "量子もつれは、...という現象です" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Laissez le modèle trouver des informations actuelles et inclure les sources utilisées.", - "state" : "translated" + "state" : "translated", + "value" : "Quantumverstrengeling is een fenomeen waarbij..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "モデルに最新情報を検索させ、使用した情報源を含めるようにします。", - "state" : "translated" + "state" : "translated", + "value" : "O entrelaçamento quântico é um fenómeno onde..." } }, "sv" : { "stringUnit" : { - "value" : "Låt modellen hitta aktuell information och inkludera de källor den använde.", - "state" : "translated" + "state" : "translated", + "value" : "Kvantintrassling är ett fenomen där..." } } - }, - "comment" : "A description of the Web Search feature." + } }, - "Anonymous" : { + "Quick Actions" : { + "comment" : "Widget name.", "localizations" : { - "ja" : { - "stringUnit" : { - "value" : "匿名", - "state" : "translated" - } - }, - "es" : { + "de" : { "stringUnit" : { - "value" : "Anónimo", - "state" : "translated" + "state" : "translated", + "value" : "Schnellaktionen" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Ανώνυμος" + "value" : "Γρήγορες Ενέργειες" } }, - "it" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Anonimo" + "value" : "Quick Actions" } }, - "pt-PT" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Anónimo" + "value" : "Acciones rápidas" } }, - "en" : { + "fr" : { "stringUnit" : { - "value" : "Anonymous", - "state" : "translated" + "state" : "translated", + "value" : "Actions rapides" } }, - "nl" : { + "it" : { "stringUnit" : { - "value" : "Anoniem", - "state" : "translated" + "state" : "translated", + "value" : "Azioni rapide" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Anonyme", - "state" : "translated" + "state" : "translated", + "value" : "クイックアクション" } }, - "de" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snelle acties" + } + }, + "pt-PT" : { "stringUnit" : { - "value" : "Anonym", - "state" : "translated" + "state" : "translated", + "value" : "Ações Rápidas" } }, "sv" : { "stringUnit" : { - "value" : "Anonym", - "state" : "translated" + "state" : "translated", + "value" : "Snabba åtgärder" } } } }, - "Capabilities" : { + "Rate the App" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δυνατότητες", - "state" : "translated" + "state" : "translated", + "value" : "App bewerten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Capacidades", - "state" : "translated" + "state" : "translated", + "value" : "Βαθμολογήστε την εφαρμογή" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "機能" + "value" : "Rate the App" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Capacità", - "state" : "translated" + "state" : "translated", + "value" : "Calificar la app" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Capacidades" + "value" : "Évaluer l’application" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Capabilities", - "state" : "translated" + "state" : "translated", + "value" : "Valuta l’app" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Mogelijkheden", - "state" : "translated" + "state" : "translated", + "value" : "アプリを評価する" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Capacités" + "value" : "Beoordeel de app" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Fähigkeiten", - "state" : "translated" + "state" : "translated", + "value" : "Avaliar a App" } }, "sv" : { "stringUnit" : { - "value" : "Funktioner", - "state" : "translated" + "state" : "translated", + "value" : "Betygsätt appen" } } - }, - "comment" : "A section that lists the capabilities of a model." + } }, - "Help us improve by suggesting new features or improvements." : { + "Recent" : { + "comment" : "A heading for the recent conversations section.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "新機能や改善点の提案でご協力ください。", - "state" : "translated" + "state" : "translated", + "value" : "Neueste" } }, "el" : { "stringUnit" : { - "value" : "Βοηθήστε μας να βελτιωθούμε προτείνοντας νέες λειτουργίες ή βελτιώσεις.", - "state" : "translated" + "state" : "translated", + "value" : "Πρόσφατα" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Ayúdanos a mejorar sugiriendo nuevas funciones o mejoras.", - "state" : "translated" + "state" : "translated", + "value" : "Recent" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Aiutaci a migliorare suggerendo nuove funzionalità o miglioramenti." + "value" : "Recientes" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ajude-nos a melhorar sugerindo novas funcionalidades ou melhorias.", - "state" : "translated" + "state" : "translated", + "value" : "Récent" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Help us improve by suggesting new features or improvements." + "value" : "Recenti" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Aidez-nous à améliorer en suggérant de nouvelles fonctionnalités ou améliorations.", - "state" : "translated" + "state" : "translated", + "value" : "最近の会話" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Help ons verbeteren door nieuwe functies of verbeteringen voor te stellen." + "value" : "Recentelijk" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Hilf uns, indem du neue Funktionen oder Verbesserungen vorschlägst.", - "state" : "translated" + "state" : "translated", + "value" : "Recentes" } }, "sv" : { "stringUnit" : { - "value" : "Hjälp oss förbättra genom att föreslå nya funktioner eller förbättringar.", - "state" : "translated" + "state" : "translated", + "value" : "Senaste" } } } }, - "Close" : { + "Recent Conversations" : { + "comment" : "Title of the widget.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Κλείσιμο" + "value" : "Letzte Unterhaltungen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cerrar", - "state" : "translated" + "state" : "translated", + "value" : "Πρόσφατες Συνομιλίες" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Stäng" + "value" : "Recent Conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Chiudi", - "state" : "translated" + "state" : "translated", + "value" : "Conversaciones recientes" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Fechar", - "state" : "translated" + "state" : "translated", + "value" : "Conversations récentes" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Close", - "state" : "translated" + "state" : "translated", + "value" : "Conversazioni recenti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Sluiten", - "state" : "translated" + "state" : "translated", + "value" : "最近の会話" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fermer" + "value" : "Recente gesprekken" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "閉じる", - "state" : "translated" + "state" : "translated", + "value" : "Conversas Recentes" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Schließen", - "state" : "translated" + "state" : "translated", + "value" : "Senaste konversationer" } } - }, - "comment" : "A button that dismisses the current view." + } }, - "Searching the web..." : { + "Recipe for pasta carbonara" : { + "comment" : "Title of a recipe for pasta carbonara.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Söker på webben...", - "state" : "translated" + "state" : "translated", + "value" : "Rezept für Pasta Carbonara" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Buscando en la web...", - "state" : "translated" + "state" : "translated", + "value" : "Συνταγή για καρμπονάρα ζυμαρικών" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Websuche läuft..." + "value" : "Recipe for pasta carbonara" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Ricerca sul web..." + "value" : "Receta de pasta carbonara" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A pesquisar na web...", - "state" : "translated" + "state" : "translated", + "value" : "Recette de pâtes à la carbonara" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Searching the web...", - "state" : "translated" + "state" : "translated", + "value" : "Ricetta per pasta alla carbonara" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Web aan het doorzoeken...", - "state" : "translated" + "state" : "translated", + "value" : "パスタカルボナーラのレシピ" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Recherche sur le web..." + "value" : "Recept voor pasta carbonara" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ウェブを検索中...", - "state" : "translated" + "state" : "translated", + "value" : "Receita de massa carbonara" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αναζήτηση στο διαδίκτυο...", - "state" : "translated" + "state" : "translated", + "value" : "Recept på pasta carbonara" } } - }, - "comment" : "A message displayed when the user is searching the web." + } }, - "Stop" : { + "Record Audio" : { + "comment" : "A label for the record audio button.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Stopp", - "state" : "translated" + "state" : "translated", + "value" : "Audio aufnehmen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Detener", - "state" : "translated" + "state" : "translated", + "value" : "Εγγραφή ήχου" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Stoppa" + "value" : "Record Audio" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Interrompi", - "state" : "translated" + "state" : "translated", + "value" : "Grabar audio" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Parar" + "value" : "Enregistrer l’audio" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Stop" + "value" : "Registra audio" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Arrêter", - "state" : "translated" + "state" : "translated", + "value" : "音声を録音" } }, "nl" : { "stringUnit" : { - "value" : "Stoppen", - "state" : "translated" + "state" : "translated", + "value" : "Audio opnemen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "停止", - "state" : "translated" + "state" : "translated", + "value" : "Gravar Áudio" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Διακοπή", - "state" : "translated" + "state" : "translated", + "value" : "Spela in ljud" } } } }, - "Testing..." : { + "Red" : { + "comment" : "Name of the color red.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δοκιμή...", - "state" : "translated" + "state" : "translated", + "value" : "Rot" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Probando...", - "state" : "translated" + "state" : "translated", + "value" : "Κόκκινο" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Testen..." + "value" : "Red" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Test in corso..." + "value" : "Rojo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A testar..." + "value" : "Rouge" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Testing...", - "state" : "translated" + "state" : "translated", + "value" : "Rosso" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Testen...", - "state" : "translated" + "state" : "translated", + "value" : "赤" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Test en cours...", - "state" : "translated" + "state" : "translated", + "value" : "Rood" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "テスト中...", - "state" : "translated" + "state" : "translated", + "value" : "Vermelho" } }, "sv" : { "stringUnit" : { - "value" : "Testar...", - "state" : "translated" + "state" : "translated", + "value" : "Röd" } } } }, - "comments" : { + "Refresh" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "コメント", - "state" : "translated" + "state" : "translated", + "value" : "Aktualisieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "comentarios", - "state" : "translated" + "state" : "translated", + "value" : "Ανανέωση" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentare" + "value" : "Refresh" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "commenti", - "state" : "translated" + "state" : "translated", + "value" : "Actualizar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "comentários", - "state" : "translated" + "state" : "translated", + "value" : "Actualiser" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "comments", - "state" : "translated" + "state" : "translated", + "value" : "Aggiorna" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "commentaires" + "value" : "更新" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "reacties" + "value" : "Vernieuwen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "σχόλια", - "state" : "translated" + "state" : "translated", + "value" : "Atualizar" } }, "sv" : { "stringUnit" : { - "value" : "kommentarer", - "state" : "translated" + "state" : "translated", + "value" : "Uppdatera" } } } }, - "Image Generation" : { + "Refresh Tools" : { + "comment" : "A button that refreshes the list of search tools.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "画像生成", - "state" : "translated" + "state" : "translated", + "value" : "Werkzeuge aktualisieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Generación de imágenes", - "state" : "translated" + "state" : "translated", + "value" : "Ανανέωση Εργαλείων" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bildgenerierung" + "value" : "Refresh Tools" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Generazione Immagini", - "state" : "translated" + "state" : "translated", + "value" : "Actualizar herramientas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Geração de Imagens", - "state" : "translated" + "state" : "translated", + "value" : "Actualiser les outils" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Image Generation" + "value" : "Aggiorna strumenti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Beeldgeneratie", - "state" : "translated" + "state" : "translated", + "value" : "ツールを更新" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Génération d’images" + "value" : "Vernieuw Hulpmiddelen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δημιουργία Εικόνων", - "state" : "translated" + "state" : "translated", + "value" : "Atualizar Ferramentas" } }, "sv" : { "stringUnit" : { - "value" : "Bildgenerering", - "state" : "translated" + "state" : "translated", + "value" : "Uppdatera verktyg" } } - }, - "comment" : "A name for an LLM model that generates images." + } }, - "Your name" : { + "Regenerate Response" : { + "comment" : "A button that regenerates the last response.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Ihr Name", - "state" : "translated" + "state" : "translated", + "value" : "Antwort neu generieren" } }, "el" : { "stringUnit" : { - "value" : "Το όνομά σας", - "state" : "translated" + "state" : "translated", + "value" : "Αναδημιουργία Απάντησης" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tu nombre" + "value" : "Regenerate Response" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il tuo nome", - "state" : "translated" + "state" : "translated", + "value" : "Regenerar respuesta" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O seu nome" + "value" : "Régénérer la réponse" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Your name", - "state" : "translated" + "state" : "translated", + "value" : "Rigenera risposta" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Uw naam", - "state" : "translated" + "state" : "translated", + "value" : "回答を再生成" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Votre nom" + "value" : "Antwoord opnieuw genereren" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "あなたの名前", - "state" : "translated" + "state" : "translated", + "value" : "Regenerar Resposta" } }, "sv" : { "stringUnit" : { - "value" : "Ditt namn", - "state" : "translated" + "state" : "translated", + "value" : "Generera om svar" } } - }, - "comment" : "A label that describes the user's name." + } }, - "Provider" : { + "Rejected" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Πάροχος", - "state" : "translated" + "state" : "translated", + "value" : "Abgelehnt" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Proveedor" + "value" : "Απορρίφθηκε" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Anbieter" + "value" : "Rejected" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Fornitore", - "state" : "translated" + "state" : "translated", + "value" : "Rechazado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Fornecedor" + "value" : "Rejeté" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Provider", - "state" : "translated" + "state" : "translated", + "value" : "Rifiutato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Provider", - "state" : "translated" + "state" : "translated", + "value" : "拒否されました" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Fournisseur", - "state" : "translated" + "state" : "translated", + "value" : "Geweigerd" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "プロバイダー", - "state" : "translated" + "state" : "translated", + "value" : "Rejeitado" } }, "sv" : { "stringUnit" : { - "value" : "Leverantör", - "state" : "translated" + "state" : "translated", + "value" : "Avvisad" } } } }, - "Thinking..." : { + "Remove from Favourites" : { + "comment" : "A label for removing a message from the user's favourites.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Σκέψη...", - "state" : "translated" + "state" : "translated", + "value" : "Aus Favoriten entfernen" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "考え中..." + "value" : "Αφαίρεση από Αγαπημένα" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Pensando..." + "value" : "Remove from Favorites" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sto pensando...", - "state" : "translated" + "state" : "translated", + "value" : "Quitar de Favoritos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A pensar...", - "state" : "translated" + "state" : "translated", + "value" : "Retirer des favoris" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Thinking...", - "state" : "translated" + "state" : "translated", + "value" : "Rimuovi dai Preferiti" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Réflexion en cours...", - "state" : "translated" + "state" : "translated", + "value" : "お気に入りから削除" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bezig met nadenken..." + "value" : "Verwijderen uit favorieten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Denke...", - "state" : "translated" + "state" : "translated", + "value" : "Remover dos Favoritos" } }, "sv" : { "stringUnit" : { - "value" : "Tänker...", - "state" : "translated" + "state" : "translated", + "value" : "Ta bort från favoriter" } } } }, - "Code" : { + "Rename" : { + "comment" : "A button that renames a conversation.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κωδικός", - "state" : "translated" + "state" : "translated", + "value" : "Umbenennen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Código", - "state" : "translated" + "state" : "translated", + "value" : "Μετονομασία" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "コード" + "value" : "Rename" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Codice", - "state" : "translated" + "state" : "translated", + "value" : "Renombrar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Código" + "value" : "Renommer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Code", - "state" : "translated" + "state" : "translated", + "value" : "Rinomina" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Code", - "state" : "translated" + "state" : "translated", + "value" : "名前を変更" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Code" + "value" : "Hernoemen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Code", - "state" : "translated" + "state" : "translated", + "value" : "Renomear" } }, "sv" : { "stringUnit" : { - "value" : "Kod", - "state" : "translated" + "state" : "translated", + "value" : "Byt namn" } } } }, - "Enable All Tools" : { + "Rename Conversation" : { + "comment" : "A dialog box title that appears when renaming a conversation.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Alle Werkzeuge aktivieren", - "state" : "translated" + "state" : "translated", + "value" : "Konversation umbenennen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Activar todas las herramientas", - "state" : "translated" + "state" : "translated", + "value" : "Μετονομασία Συνομιλίας" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Aktivera alla verktyg" + "value" : "Rename Conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Abilita tutti gli strumenti", - "state" : "translated" + "state" : "translated", + "value" : "Renombrar conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ativar Todas as Ferramentas", - "state" : "translated" + "state" : "translated", + "value" : "Renommer la conversation" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Enable All Tools" + "value" : "Rinomina conversazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Activer tous les outils", - "state" : "translated" + "state" : "translated", + "value" : "会話の名前を変更" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle tools inschakelen" + "value" : "Gesprek hernoemen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ενεργοποίηση όλων των εργαλείων", - "state" : "translated" + "state" : "translated", + "value" : "Renomear Conversa" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "すべてのツールを有効にする", - "state" : "translated" + "state" : "translated", + "value" : "Byt namn på konversation" } } - }, - "comment" : "A toggle that enables or disables all tools." + } }, - "Regenerate Response" : { + "Report Issue" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αναδημιουργία Απάντησης", - "state" : "translated" + "state" : "translated", + "value" : "Problem melden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Regenerar respuesta", - "state" : "translated" + "state" : "translated", + "value" : "Αναφορά προβλήματος" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Generera om svar" + "value" : "Report Issue" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Rigenera risposta", - "state" : "translated" + "state" : "translated", + "value" : "Reportar problema" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Regenerar Resposta" + "value" : "Signaler un problème" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Regenerate Response", - "state" : "translated" + "state" : "translated", + "value" : "Segnala problema" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Régénérer la réponse", - "state" : "translated" + "state" : "translated", + "value" : "問題を報告する" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Antwoord opnieuw genereren" + "value" : "Probleem melden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Antwort neu generieren", - "state" : "translated" + "state" : "translated", + "value" : "Reportar problema" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "回答を再生成", - "state" : "translated" + "state" : "translated", + "value" : "Rapportera problem" } } - }, - "comment" : "A button that regenerates the last response." + } }, - "MCP servers are configured in your LiteLLM server. Fetch to see what's available and toggle them on or off." : { + "Required iCloud data is still downloading." : { + "comment" : "Error description when required iCloud data is still downloading.", + "isCommentAutoGenerated" : true + }, + "Resend" : { + "comment" : "A button that resends a message.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "MCPサーバーはLiteLLMサーバーで設定されています。利用可能なものを取得してオンまたはオフに切り替えてください。" + "value" : "Erneut senden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Los servidores MCP están configurados en tu servidor LiteLLM. Obtén la información para ver qué está disponible y actívalos o desactívalos.", - "state" : "translated" + "state" : "translated", + "value" : "Αποστολή ξανά" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "MCP-Server sind in Ihrem LiteLLM-Server konfiguriert. Abrufen, um zu sehen, was verfügbar ist, und sie ein- oder auszuschalten." + "value" : "Resend" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "I server MCP sono configurati nel tuo server LiteLLM. Recupera per vedere cosa è disponibile e attivali o disattivali.", - "state" : "translated" + "state" : "translated", + "value" : "Reenviar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Os servidores MCP estão configurados no seu servidor LiteLLM. Atualize para ver o que está disponível e ative-os ou desative-os." + "value" : "Renvoyer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "MCP servers are configured in your LiteLLM server. Fetch to see what's available and toggle them on or off.", - "state" : "translated" + "state" : "translated", + "value" : "Reinvia" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Les serveurs MCP sont configurés dans votre serveur LiteLLM. Récupérez-les pour voir ce qui est disponible et activez-les ou désactivez-les.", - "state" : "translated" + "state" : "translated", + "value" : "再送信" } }, "nl" : { "stringUnit" : { - "value" : "MCP-servers zijn geconfigureerd in je LiteLLM-server. Ophalen om te zien wat beschikbaar is en ze aan- of uitzetten.", - "state" : "translated" + "state" : "translated", + "value" : "Opnieuw verzenden" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Οι διακομιστές MCP έχουν ρυθμιστεί στον διακομιστή LiteLLM σας. Φέρτε τα για να δείτε τι είναι διαθέσιμο και ενεργοποιήστε ή απενεργοποιήστε τα.", - "state" : "translated" + "state" : "translated", + "value" : "Reenviar" } }, "sv" : { "stringUnit" : { - "value" : "MCP-servrar är konfigurerade i din LiteLLM-server. Hämta för att se vad som finns tillgängligt och slå på eller av dem.", - "state" : "translated" + "state" : "translated", + "value" : "Skicka igen" } } - }, - "comment" : "A description of MCP servers." + } }, - "Disable Web Search" : { + "Reset" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Inaktivera webbsökning" + "value" : "Zurücksetzen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Desactivar búsqueda web", - "state" : "translated" + "state" : "translated", + "value" : "Επαναφορά" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Websuche deaktivieren" + "value" : "Reset" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Disattiva ricerca web", - "state" : "translated" + "state" : "translated", + "value" : "Restablecer" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Desativar Pesquisa Web", - "state" : "translated" + "state" : "translated", + "value" : "Réinitialiser" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Disable Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Reimposta" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Désactiver la recherche Web", - "state" : "translated" + "state" : "translated", + "value" : "リセット" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Webzoekfunctie uitschakelen" + "value" : "Resetten" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ウェブ検索を無効にする", - "state" : "translated" + "state" : "translated", + "value" : "Repor" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Απενεργοποίηση Αναζήτησης Ιστού", - "state" : "translated" + "state" : "translated", + "value" : "Återställ" } } - }, - "comment" : "A button that disables the web search feature." + } }, - "Memory Content" : { + "Reset App Data" : { + "comment" : "A confirmation alert that lets the user reset all app data.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "メモリ内容", - "state" : "translated" + "state" : "translated", + "value" : "App-Daten zurücksetzen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Contenido de la memoria", - "state" : "translated" + "state" : "translated", + "value" : "Επαναφορά δεδομένων εφαρμογής" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Περιεχόμενο μνήμης", - "state" : "translated" + "state" : "translated", + "value" : "Reset App Data" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Contenuto della memoria", - "state" : "translated" + "state" : "translated", + "value" : "Restablecer datos de la aplicación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Conteúdo da Memória" + "value" : "Réinitialiser les données de l’application" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Memory Content" + "value" : "Reimposta dati app" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Contenu de la mémoire", - "state" : "translated" + "state" : "translated", + "value" : "アプリデータをリセット" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geheugeninhoud" + "value" : "Appgegevens resetten" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Speicherinhalt", - "state" : "translated" + "state" : "translated", + "value" : "Repor Dados da App" } }, "sv" : { "stringUnit" : { - "value" : "Minnesinnehåll", - "state" : "translated" + "state" : "translated", + "value" : "Återställ appdata" } } - }, - "comment" : "A label displayed above the text field for the memory content." + } }, - "Details" : { - "comment" : "A section that provides more details about a model.", + "Response interrupted" : { + "comment" : "Text displayed in a notification when the response to a prompt was cut short.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Details", - "state" : "translated" + "state" : "translated", + "value" : "Antwort unterbrochen" } }, - "pt-PT" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Detalhes" + "value" : "Η απάντηση διακόπηκε" } }, "en" : { "stringUnit" : { - "value" : "Details", - "state" : "translated" + "state" : "translated", + "value" : "Response interrupted" } }, - "ja" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "詳細" + "value" : "Respuesta interrumpida" } }, - "nl" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Details" + "value" : "Réponse interrompue" } }, - "fr" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Détails" + "value" : "Risposta interrotta" } }, - "it" : { + "ja" : { "stringUnit" : { - "value" : "Dettagli", - "state" : "translated" + "state" : "translated", + "value" : "応答が中断されました" } }, - "sv" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Detaljer" + "value" : "Reactie onderbroken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Λεπτομέρειες" + "value" : "Resposta interrompida" } }, - "es" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Detalles" + "value" : "Svar avbrutet" } } } }, - "Help me with my code" : { + "Response ready" : { + "comment" : "Title of a notification when a response is ready.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Hilf mir bei meinem Code", - "state" : "translated" + "state" : "translated", + "value" : "Antwort bereit" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Ayúdame con mi código" + "value" : "Η απάντηση είναι έτοιμη" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hjälp mig med min kod" + "value" : "Response ready" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aiutami con il mio codice", - "state" : "translated" + "state" : "translated", + "value" : "Respuesta lista" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ajuda-me com o meu código" + "value" : "Réponse prête" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Help me with my code", - "state" : "translated" + "state" : "translated", + "value" : "Risposta pronta" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Help me met mijn code", - "state" : "translated" + "state" : "translated", + "value" : "応答準備完了" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aide-moi avec mon code", - "state" : "translated" + "state" : "translated", + "value" : "Antwoord klaar" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Βοήθησέ με με τον κώδικά μου", - "state" : "translated" + "state" : "translated", + "value" : "Resposta pronta" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "コードの助けをしてください", - "state" : "translated" + "state" : "translated", + "value" : "Svar klart" } } } }, - "Answer a tricky question" : { + "Results" : { + "comment" : "A label displayed in the footer of a settings section.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Beantworte eine knifflige Frage", - "state" : "translated" + "state" : "translated", + "value" : "Ergebnisse" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Responder una pregunta difícil", - "state" : "translated" + "state" : "translated", + "value" : "Αποτελέσματα" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Απάντησε σε μια δύσκολη ερώτηση" + "value" : "Results" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Rispondi a una domanda difficile", - "state" : "translated" + "state" : "translated", + "value" : "Resultados" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Responder a uma pergunta difícil" + "value" : "Résultats" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Answer a tricky question", - "state" : "translated" + "state" : "translated", + "value" : "Risultati" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Répondre à une question délicate", - "state" : "translated" + "state" : "translated", + "value" : "結果" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Beantwoord een lastige vraag" + "value" : "Resultaten" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "難しい質問に答える", - "state" : "translated" + "state" : "translated", + "value" : "Resultados" } }, "sv" : { "stringUnit" : { - "value" : "Svara på en klurig fråga", - "state" : "translated" + "state" : "translated", + "value" : "Resultat" } } } }, - "Could not establish a secure connection to the server." : { + "Retry" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δεν ήταν δυνατή η δημιουργία ασφαλούς σύνδεσης με τον διακομιστή.", - "state" : "translated" + "state" : "translated", + "value" : "Erneut versuchen" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーへの安全な接続を確立できませんでした。" + "value" : "Επανάληψη προσπάθειας" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "No se pudo establecer una conexión segura con el servidor." + "value" : "Retry" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile stabilire una connessione sicura con il server.", - "state" : "translated" + "state" : "translated", + "value" : "Reintentar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Não foi possível estabelecer uma ligação segura ao servidor.", - "state" : "translated" + "state" : "translated", + "value" : "Réessayer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Could not establish a secure connection to the server.", - "state" : "translated" + "state" : "translated", + "value" : "Riprova" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Er kon geen beveiligde verbinding met de server worden gemaakt.", - "state" : "translated" + "state" : "translated", + "value" : "再試行" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Impossible d’établir une connexion sécurisée avec le serveur." + "value" : "Opnieuw proberen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Es konnte keine sichere Verbindung zum Server hergestellt werden.", - "state" : "translated" + "state" : "translated", + "value" : "Tentar novamente" } }, "sv" : { "stringUnit" : { - "value" : "Kunde inte upprätta en säker anslutning till servern.", - "state" : "translated" + "state" : "translated", + "value" : "Försök igen" } } } }, - "Export" : { + "Review and improve my writing" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "エクスポート" + "value" : "Überprüfen und verbessern Sie meinen Text" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Exportar", - "state" : "translated" + "state" : "translated", + "value" : "Αναθεώρηση και βελτίωση της γραφής μου" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εξαγωγή" + "value" : "Review and improve my writing" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Esporta", - "state" : "translated" + "state" : "translated", + "value" : "Revisa y mejora mi redacción" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Exportar", - "state" : "translated" + "state" : "translated", + "value" : "Relisez et améliorez mon texte" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Export", - "state" : "translated" + "state" : "translated", + "value" : "Rivedi e migliora il mio testo" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Exporter", - "state" : "translated" + "state" : "translated", + "value" : "私の文章を見直して改善する" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Exporteren" + "value" : "Beoordeel en verbeter mijn tekst" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Exportieren", - "state" : "translated" + "state" : "translated", + "value" : "Rever e melhorar a minha escrita" } }, "sv" : { "stringUnit" : { - "value" : "Exportera", - "state" : "translated" + "state" : "translated", + "value" : "Granska och förbättra min text" } } - }, - "comment" : "A label for exporting a conversation." + } }, - "No speech-to-text model available. Configure a Whisper model in LiteLLM." : { + "Review, edit, disable, or delete the memories used in future conversations." : { + "comment" : "A description of the memory management feature.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Kein Speech-to-Text-Modell verfügbar. Konfigurieren Sie ein Whisper-Modell in LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Überprüfen, bearbeiten, deaktivieren oder löschen Sie die Erinnerungen, die in zukünftigen Gesprächen verwendet werden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No hay modelo de reconocimiento de voz disponible. Configure un modelo Whisper en LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Αναθεώρηση, επεξεργασία, απενεργοποίηση ή διαγραφή των αναμνήσεων που χρησιμοποιούνται σε μελλοντικές συνομιλίες." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν υπάρχει διαθέσιμο μοντέλο ομιλίας σε κείμενο. Διαμορφώστε ένα μοντέλο Whisper στο LiteLLM." + "value" : "Review, edit, disable, or delete the memories used in future conversations" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nessun modello di riconoscimento vocale disponibile. Configura un modello Whisper in LiteLLM." + "value" : "Revisa, edita, desactiva o elimina los recuerdos usados en futuras conversaciones." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nenhum modelo de reconhecimento de voz disponível. Configure um modelo Whisper no LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Révisez, modifiez, désactivez ou supprimez les souvenirs utilisés dans les conversations futures." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No speech-to-text model available. Configure a Whisper model in LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Rivedi, modifica, disabilita o elimina i ricordi utilizzati nelle conversazioni future." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Aucun modèle de reconnaissance vocale disponible. Configurez un modèle Whisper dans LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "今後の会話で使用される記憶を確認、編集、無効化、または削除します。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geen spraak-naar-tekstmodel beschikbaar. Stel een Whisper-model in LiteLLM in." + "value" : "Beoordeel, bewerk, schakel uit of verwijder de herinneringen die in toekomstige gesprekken worden gebruikt." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "音声認識モデルが利用できません。LiteLLMでWhisperモデルを設定してください。", - "state" : "translated" + "state" : "translated", + "value" : "Revise, edite, desative ou elimine as memórias usadas em conversas futuras." } }, "sv" : { "stringUnit" : { - "value" : "Ingen tal-till-text-modell tillgänglig. Konfigurera en Whisper-modell i LiteLLM.", - "state" : "translated" + "state" : "translated", + "value" : "Granska, redigera, inaktivera eller ta bort minnen som används i framtida konversationer." } } - }, - "comment" : "Error message displayed when no speech-to-text model is configured." + } }, - "Tips appear only when their related features are available." : { + "Right-click a conversation to pin, rename, or add tags." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ヒントは関連機能が利用可能な場合にのみ表示されます。", - "state" : "translated" + "state" : "translated", + "value" : "Klicken Sie mit der rechten Maustaste auf eine Unterhaltung, um sie anzuheften, umzubenennen oder Tags hinzuzufügen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Los consejos aparecen solo cuando sus funciones relacionadas están disponibles.", - "state" : "translated" + "state" : "translated", + "value" : "Κάντε δεξί κλικ σε μια συνομιλία για καρφίτσωμα, μετονομασία ή προσθήκη ετικετών." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tips visas endast när deras relaterade funktioner är tillgängliga." + "value" : "Right-click a conversation to pin, rename, or add tags." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "I suggerimenti appaiono solo quando le relative funzionalità sono disponibili.", - "state" : "translated" + "state" : "translated", + "value" : "Haz clic derecho en una conversación para anclar, renombrar o agregar etiquetas." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "As dicas aparecem apenas quando as funcionalidades relacionadas estão disponíveis." + "value" : "Cliquez avec le bouton droit sur une conversation pour l’épingler, la renommer ou ajouter des tags." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Tips appear only when their related features are available.", - "state" : "translated" + "state" : "translated", + "value" : "Fai clic con il tasto destro su una conversazione per fissarla, rinominarla o aggiungere tag." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Les astuces apparaissent uniquement lorsque leurs fonctionnalités associées sont disponibles.", - "state" : "translated" + "state" : "translated", + "value" : "会話を右クリックしてピン留め、名前変更、タグ追加を行います。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tips verschijnen alleen wanneer de bijbehorende functies beschikbaar zijn." + "value" : "Klik met de rechtermuisknop op een gesprek om vast te zetten, hernoemen of tags toe te voegen." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Οι συμβουλές εμφανίζονται μόνο όταν είναι διαθέσιμες οι σχετικές λειτουργίες.", - "state" : "translated" + "state" : "translated", + "value" : "Clique com o botão direito numa conversa para fixar, renomear ou adicionar etiquetas." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Tipps erscheinen nur, wenn die zugehörigen Funktionen verfügbar sind.", - "state" : "translated" + "state" : "translated", + "value" : "Högerklicka på en konversation för att fästa, byta namn eller lägga till taggar." } } - }, - "comment" : "A description of the feature tips section." + } }, - "Prompt" : { + "Right-click a message to edit, regenerate, branch, or save it as a favourite." : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Eingabeaufforderung", - "state" : "translated" + "state" : "translated", + "value" : "Klicken Sie mit der rechten Maustaste auf eine Nachricht, um sie zu bearbeiten, neu zu generieren, zu verzweigen oder als Favorit zu speichern." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Prompt" + "value" : "Κάντε δεξί κλικ σε ένα μήνυμα για να το επεξεργαστείτε, αναγεννήσετε, διακλαδώσετε ή αποθηκεύσετε ως αγαπημένο." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "プロンプト" + "value" : "Right-click a message to edit, regenerate, branch, or save it as a favorite." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Prompt", - "state" : "translated" + "state" : "translated", + "value" : "Haz clic derecho en un mensaje para editarlo, regenerarlo, ramificarlo o guardarlo como favorito." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Indicação", - "state" : "translated" + "state" : "translated", + "value" : "Cliquez droit sur un message pour le modifier, régénérer, créer une branche ou l’enregistrer en favori." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Prompt" + "value" : "Fai clic con il tasto destro su un messaggio per modificarlo, rigenerarlo, creare un ramo o salvarlo tra i preferiti." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Prompt", - "state" : "translated" + "state" : "translated", + "value" : "メッセージを右クリックして編集、再生成、分岐、またはお気に入りに保存します。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Invite", - "state" : "translated" + "state" : "translated", + "value" : "Klik met de rechtermuisknop op een bericht om het te bewerken, opnieuw te genereren, vertakken of als favoriet op te slaan." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ερώτημα", - "state" : "translated" + "state" : "translated", + "value" : "Clique com o botão direito numa mensagem para editar, regenerar, ramificar ou guardar como favorito." } }, "sv" : { "stringUnit" : { - "value" : "Anvisning", - "state" : "translated" + "state" : "translated", + "value" : "Högerklicka på ett meddelande för att redigera, generera om, skapa en gren eller spara det som favorit." } } - }, - "comment" : "A label displayed above the prompt text field." + } }, - "Chat Message" : { + "Save" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Μήνυμα συνομιλίας" + "value" : "Speichern" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Mensaje de chat" + "value" : "Αποθήκευση" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Chattmeddelande" + "value" : "Save" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Messaggio chat", - "state" : "translated" + "state" : "translated", + "value" : "Guardar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Mensagem de Chat", - "state" : "translated" + "state" : "translated", + "value" : "Enregistrer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Chat Message", - "state" : "translated" + "state" : "translated", + "value" : "Salva" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Chatbericht", - "state" : "translated" + "state" : "translated", + "value" : "保存" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Message de chat", - "state" : "translated" + "state" : "translated", + "value" : "Opslaan" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "チャットメッセージ", - "state" : "translated" + "state" : "translated", + "value" : "Guardar" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Chatnachricht", - "state" : "translated" + "state" : "translated", + "value" : "Spara" } } } }, - "tag.vision" : { + "Save to Downloads" : { + "comment" : "A label for saving an image to the user's Downloads folder.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "Vision", - "state" : "translated" + "state" : "translated", + "value" : "In Downloads speichern" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Vision", - "state" : "translated" + "state" : "translated", + "value" : "Αποθήκευση στους Λήψεις" } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Vision", - "state" : "translated" + "state" : "translated", + "value" : "Save to Downloads" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Vision" + "value" : "Guardar en Descargas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Vision" + "value" : "Enregistrer dans Téléchargements" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Vision" + "value" : "Salva in Download" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vision", - "state" : "translated" + "state" : "translated", + "value" : "ダウンロードに保存" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Vision", - "state" : "translated" + "state" : "translated", + "value" : "Opslaan in Downloads" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Vision", - "state" : "translated" + "state" : "translated", + "value" : "Guardar em Transferências" } }, "sv" : { "stringUnit" : { - "value" : "Vision", - "state" : "translated" + "state" : "translated", + "value" : "Spara till Hämtade filer" } } - }, - "comment" : "Label for the \"Vision\" capability." + } }, - "Search the web" : { + "Save to Photos" : { + "comment" : "A label for a context menu item that saves an image to the user's photo library.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ウェブを検索する", - "state" : "translated" + "state" : "translated", + "value" : "In Fotos speichern" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Buscar en la web", - "state" : "translated" + "state" : "translated", + "value" : "Αποθήκευση στις Φωτογραφίες" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Im Web suchen" + "value" : "Save to Photos" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Cerca sul web", - "state" : "translated" + "state" : "translated", + "value" : "Guardar en Fotos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Pesquisar na web" + "value" : "Enregistrer dans Photos" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Search the web", - "state" : "translated" + "state" : "translated", + "value" : "Salva in Foto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Zoek op het web", - "state" : "translated" + "state" : "translated", + "value" : "写真に保存" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rechercher sur le web" + "value" : "Opslaan in Foto's" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αναζήτηση στο διαδίκτυο", - "state" : "translated" + "state" : "translated", + "value" : "Guardar nas Fotografias" } }, "sv" : { "stringUnit" : { - "value" : "Sök på webben", - "state" : "translated" + "state" : "translated", + "value" : "Spara till Foton" } } - }, - "comment" : "A description of the feature that lets the model search the web." + } }, - "OpenClient is under maintenance" : { + "Saved to memory: %@" : { + "comment" : "A message that is displayed when a piece of information is successfully saved to the user's memory. The argument is the content that was saved.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Το OpenClient βρίσκεται υπό συντήρηση" + "value" : "In den Speicher gespeichert: %@" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient está en mantenimiento" + "value" : "Αποθηκεύτηκε στη μνήμη: %@" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient wird gewartet" + "value" : "Saved to memory: %@" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "OpenClient è in manutenzione", - "state" : "translated" + "state" : "translated", + "value" : "Guardado en la memoria: %@" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O OpenClient está em manutenção", - "state" : "translated" + "state" : "translated", + "value" : "Enregistré en mémoire : %@" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "OpenClient is under maintenance", - "state" : "translated" + "state" : "translated", + "value" : "Salvato nella memoria: %@" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "OpenClient est en maintenance", - "state" : "translated" + "state" : "translated", + "value" : "メモリに保存されました: %@" } }, "nl" : { "stringUnit" : { - "value" : "OpenClient wordt onderhouden", - "state" : "translated" + "state" : "translated", + "value" : "Opgeslagen in geheugen: %@" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenClientはメンテナンス中です", - "state" : "translated" + "state" : "translated", + "value" : "Guardado na memória: %@" } }, "sv" : { "stringUnit" : { - "value" : "OpenClient genomgår underhållarbeiten", - "state" : "translated" + "state" : "translated", + "value" : "Sparat i minnet: %@" } } - }, - "comment" : "A message displayed when the app is under maintenance." + } }, - "Organise your conversations" : { + "Scroll the share sheet and tap **OpenClient**." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Organisiere deine Unterhaltungen" + "value" : "Blättern Sie im Freigabeblatt und tippen Sie auf **OpenClient**." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Organiza tus conversaciones", - "state" : "translated" + "state" : "translated", + "value" : "Κύλιση στο φύλλο κοινής χρήσης και πατήστε **OpenClient**." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Organisera dina konversationer" + "value" : "Scroll the share sheet and tap **OpenClient**." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Organizza le tue conversazioni", - "state" : "translated" + "state" : "translated", + "value" : "Desplaza la hoja para compartir y toca **OpenClient**." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Organize as suas conversas" + "value" : "Faites défiler la feuille de partage et appuyez sur **OpenClient**." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Organize your conversations", - "state" : "translated" + "state" : "translated", + "value" : "Scorri il foglio di condivisione e tocca **OpenClient**." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Organiseer je gesprekken", - "state" : "translated" + "state" : "translated", + "value" : "共有シートをスクロールして**OpenClient**をタップしてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Organisez vos conversations", - "state" : "translated" + "state" : "translated", + "value" : "Scroll door het deelvenster en tik op **OpenClient**." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話を整理する", - "state" : "translated" + "state" : "translated", + "value" : "Desloque a folha de partilha e toque em **OpenClient**." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Οργάνωσε τις συνομιλίες σου", - "state" : "translated" + "state" : "translated", + "value" : "Bläddra i delningsmenyn och tryck på **OpenClient**." } } - }, - "comment" : "A label displayed in the chat interface that allows the user to organise their conversations." + } }, - "Translator" : { + "Search" : { + "comment" : "A title for a screen that searches for conversations.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Översättare" + "value" : "Suche" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Traductor", - "state" : "translated" + "state" : "translated", + "value" : "Αναζήτηση" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Μεταφραστής" + "value" : "Search" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Traduttore", - "state" : "translated" + "state" : "translated", + "value" : "Buscar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tradutor", - "state" : "translated" + "state" : "translated", + "value" : "Recherche" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Translator" + "value" : "Cerca" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Vertaler", - "state" : "translated" + "state" : "translated", + "value" : "検索" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Traducteur", - "state" : "translated" + "state" : "translated", + "value" : "Zoeken" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "翻訳者", - "state" : "translated" + "state" : "translated", + "value" : "Pesquisar" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Übersetzer", - "state" : "translated" + "state" : "translated", + "value" : "Sökning" } } - }, - "comment" : "Name of the prompt template for translating text." + } }, - "Save" : { + "Search Chats" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "保存", - "state" : "translated" + "state" : "translated", + "value" : "Chats durchsuchen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Guardar", - "state" : "translated" + "state" : "translated", + "value" : "Αναζήτηση συνομιλιών" } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Speichern", - "state" : "translated" + "state" : "translated", + "value" : "Search Chats" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Salva", - "state" : "translated" + "state" : "translated", + "value" : "Buscar chats" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Guardar" + "value" : "Rechercher dans les discussions" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Save" + "value" : "Cerca chat" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Enregistrer", - "state" : "translated" + "state" : "translated", + "value" : "チャットを検索" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Opslaan" + "value" : "Zoek chats" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αποθήκευση", - "state" : "translated" + "state" : "translated", + "value" : "Pesquisar Conversas" } }, "sv" : { "stringUnit" : { - "value" : "Spara", - "state" : "translated" + "state" : "translated", + "value" : "Sök chattar" } } } }, - "The request timed out. The server may be slow or unreachable." : { + "Search Conversations" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "リクエストがタイムアウトしました。サーバーが遅いか、接続できません。" + "value" : "Konversationen suchen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "La solicitud agotó el tiempo de espera. El servidor puede estar lento o inaccesible." + "value" : "Αναζήτηση συνομιλιών" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Förfrågan tog för lång tid. Servern kan vara långsam eller otillgänglig." + "value" : "Search Conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "La richiesta è scaduta. Il server potrebbe essere lento o non raggiungibile.", - "state" : "translated" + "state" : "translated", + "value" : "Buscar conversaciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O pedido expirou. O servidor pode estar lento ou inacessível.", - "state" : "translated" + "state" : "translated", + "value" : "Rechercher des conversations" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The request timed out. The server may be slow or unreachable.", - "state" : "translated" + "state" : "translated", + "value" : "Cerca conversazioni" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "La requête a expiré. Le serveur peut être lent ou inaccessible.", - "state" : "translated" + "state" : "translated", + "value" : "会話を検索" } }, "nl" : { "stringUnit" : { - "value" : "De aanvraag is verlopen. De server is mogelijk traag of niet bereikbaar.", - "state" : "translated" + "state" : "translated", + "value" : "Gesprekken zoeken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Η αίτηση έληξε λόγω χρόνου αναμονής. Ο διακομιστής μπορεί να είναι αργός ή μη προσβάσιμος.", - "state" : "translated" + "state" : "translated", + "value" : "Pesquisar Conversas" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Die Anfrage hat ein Zeitlimit überschritten. Der Server ist möglicherweise langsam oder nicht erreichbar.", - "state" : "translated" + "state" : "translated", + "value" : "Sök konversationer" } } } }, - "Your local personal context differs from iCloud. Which version would you like to keep?" : { + "Search conversations..." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το τοπικό προσωπικό σας περιεχόμενο διαφέρει από το iCloud. Ποια έκδοση θέλετε να κρατήσετε;", - "state" : "translated" + "state" : "translated", + "value" : "Konversationen durchsuchen..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Tu contexto personal local difiere del de iCloud. ¿Qué versión deseas conservar?", - "state" : "translated" + "state" : "translated", + "value" : "Αναζήτηση συνομιλιών..." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ihr lokaler persönlicher Kontext unterscheidet sich von iCloud. Welche Version möchten Sie behalten?" + "value" : "Search conversations..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il tuo contesto personale locale differisce da quello di iCloud. Quale versione desideri mantenere?", - "state" : "translated" + "state" : "translated", + "value" : "Buscar conversaciones..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O seu contexto pessoal local difere do iCloud. Qual versão pretende manter?", - "state" : "translated" + "state" : "translated", + "value" : "Rechercher des conversations..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Your local personal context differs from iCloud. Which version would you like to keep?", - "state" : "translated" + "state" : "translated", + "value" : "Cerca conversazioni..." } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Je lokale persoonlijke context verschilt van iCloud. Welke versie wil je behouden?" + "value" : "会話を検索..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Votre contexte personnel local diffère de celui d’iCloud. Quelle version souhaitez-vous conserver ?" + "value" : "Gesprekken zoeken..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ローカルの個人情報がiCloudと異なります。どちらのバージョンを保持しますか?", - "state" : "translated" + "state" : "translated", + "value" : "Procurar conversas..." } }, "sv" : { "stringUnit" : { - "value" : "Din lokala personliga kontext skiljer sig från iCloud. Vilken version vill du behålla?", - "state" : "translated" + "state" : "translated", + "value" : "Sök konversationer..." } } - }, - "comment" : "A message displayed when a user has a conflict between their local and iCloud data." + } }, - "%lld tool(s) available" : { + "Search the web" : { + "comment" : "A description of the feature that lets the model search the web.", "localizations" : { "de" : { "stringUnit" : { - "value" : "%lld Werkzeug(e) verfügbar", - "state" : "translated" + "state" : "translated", + "value" : "Im Web suchen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%lld herramienta(s) disponible(s)", - "state" : "translated" + "state" : "translated", + "value" : "Αναζήτηση στο διαδίκτυο" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%lld 個のツールが利用可能" + "value" : "Search the web" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "%lld strumento(i) disponibile(i)" + "value" : "Buscar en la web" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "%lld ferramenta(s) disponível(is)", - "state" : "translated" + "state" : "translated", + "value" : "Rechercher sur le web" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%lld tool(s) available", - "state" : "translated" + "state" : "translated", + "value" : "Cerca sul web" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "%lld outil(s) disponible(s)", - "state" : "translated" + "state" : "translated", + "value" : "ウェブを検索する" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "%lld gereedschap(en) beschikbaar" + "value" : "Zoek op het web" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "%lld διαθέσιμο(α) εργαλείο(α)", - "state" : "translated" + "state" : "translated", + "value" : "Pesquisar na web" } }, "sv" : { "stringUnit" : { - "value" : "%lld verktyg tillgängliga", - "state" : "translated" + "state" : "translated", + "value" : "Sök på webben" } } - }, - "comment" : "A label that shows the number of MCP tools available." + } }, - "OpenClient is free and open source" : { + "Search Tool" : { + "comment" : "A label for the search tool picker.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "OpenClientは無料のオープンソースです", - "state" : "translated" + "state" : "translated", + "value" : "Suchwerkzeug" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient är gratis och öppen källkod" + "value" : "Εργαλείο αναζήτησης" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient es gratuito y de código abierto" + "value" : "Search Tool" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "OpenClient è gratuito e open source", - "state" : "translated" + "state" : "translated", + "value" : "Herramienta de búsqueda" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O OpenClient é gratuito e de código aberto", - "state" : "translated" + "state" : "translated", + "value" : "Outil de recherche" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient is free and open source" + "value" : "Strumento di ricerca" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "OpenClient is gratis en open source", - "state" : "translated" + "state" : "translated", + "value" : "検索ツール" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "OpenClient est gratuit et open source", - "state" : "translated" + "state" : "translated", + "value" : "Zoekhulpmiddel" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Το OpenClient είναι δωρεάν και ανοιχτού κώδικα", - "state" : "translated" + "state" : "translated", + "value" : "Ferramenta de Pesquisa" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "OpenClient ist kostenlos und Open Source", - "state" : "translated" + "state" : "translated", + "value" : "Sökverktyg" } } - }, - "comment" : "A description of the OpenClient app." + } }, - "No Media or Files" : { + "Searching the web..." : { + "comment" : "A message displayed when the user is searching the web.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Keine Medien oder Dateien", - "state" : "translated" + "state" : "translated", + "value" : "Websuche läuft..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sin medios ni archivos", - "state" : "translated" + "state" : "translated", + "value" : "Αναζήτηση στο διαδίκτυο..." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "メディアやファイルがありません" + "value" : "Searching the web..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessun media o file", - "state" : "translated" + "state" : "translated", + "value" : "Buscando en la web..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sem Média ou Ficheiros" + "value" : "Recherche sur le web..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No Media or Files", - "state" : "translated" + "state" : "translated", + "value" : "Ricerca sul web..." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Aucun média ni fichier", - "state" : "translated" + "state" : "translated", + "value" : "ウェブを検索中..." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geen media of bestanden" + "value" : "Web aan het doorzoeken..." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δεν υπάρχουν μέσα ή αρχεία", - "state" : "translated" + "state" : "translated", + "value" : "A pesquisar na web..." } }, "sv" : { "stringUnit" : { - "value" : "Inga medier eller filer", - "state" : "translated" + "state" : "translated", + "value" : "Söker på webben..." } } - }, - "comment" : "A description of the state displayed when the user has no media or files." + } }, - "The MCP tool arguments do not match the tool schema." : { + "See conversations for a selected tag." : { + "comment" : "Description of the widget that shows conversations assigned to a tag selected in the widget configuration.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die Argumente des MCP-Tools stimmen nicht mit dem Toolschema überein." + "value" : "Siehe Unterhaltungen für ein ausgewähltes Tag." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Los argumentos de la herramienta MCP no coinciden con el esquema de la herramienta.", - "state" : "translated" + "state" : "translated", + "value" : "Δείτε συνομιλίες για μια επιλεγμένη ετικέτα." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "MCPツールの引数がツールスキーマと一致しません。" + "value" : "See conversations for the selected tag" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Gli argomenti dello strumento MCP non corrispondono allo schema dello strumento." + "value" : "Ver conversaciones para una etiqueta seleccionada" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Os argumentos da ferramenta MCP não correspondem ao esquema da ferramenta.", - "state" : "translated" + "state" : "translated", + "value" : "Voir les conversations pour un tag sélectionné" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The MCP tool arguments do not match the tool schema.", - "state" : "translated" + "state" : "translated", + "value" : "Visualizza le conversazioni per un tag selezionato" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De argumenten van de MCP-tool komen niet overeen met het toolschema.", - "state" : "translated" + "state" : "translated", + "value" : "選択したタグの会話を表示します" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Les arguments de l’outil MCP ne correspondent pas au schéma de l’outil.", - "state" : "translated" + "state" : "translated", + "value" : "Bekijk gesprekken voor een geselecteerd label." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Τα επιχειρήματα του εργαλείου MCP δεν ταιριάζουν με το σχήμα του εργαλείου.", - "state" : "translated" + "state" : "translated", + "value" : "Ver conversas para uma etiqueta selecionada" } }, "sv" : { "stringUnit" : { - "value" : "Argumenten för MCP-verktyget stämmer inte överens med verktygsschemat.", - "state" : "translated" + "state" : "translated", + "value" : "Se konversationer för en vald tagg." } } - }, - "comment" : "Error description when the MCP tool arguments do not match the tool schema." + } }, - "Show API Key" : { + "See your latest conversations and jump back in." : { + "comment" : "Widget description.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "APIキーを表示", - "state" : "translated" + "state" : "translated", + "value" : "Sieh dir deine neuesten Unterhaltungen an und steige wieder ein." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mostrar clave API", - "state" : "translated" + "state" : "translated", + "value" : "Δες τις πιο πρόσφατες συνομιλίες σου και συνέχισε από εκεί." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "API-Schlüssel anzeigen" + "value" : "See your latest conversations and jump back in" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Mostra chiave API", - "state" : "translated" + "state" : "translated", + "value" : "Consulta tus últimas conversaciones y vuelve a ellas." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mostrar chave API" + "value" : "Voir vos dernières conversations et y revenir." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Show API Key" + "value" : "Visualizza le tue ultime conversazioni e riprendi da dove avevi interrotto." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "API-sleutel tonen", - "state" : "translated" + "state" : "translated", + "value" : "最新の会話を確認してすぐに再開できます" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Afficher la clé API", - "state" : "translated" + "state" : "translated", + "value" : "Bekijk je laatste gesprekken en ga er direct mee verder." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εμφάνιση κλειδιού API", - "state" : "translated" + "state" : "translated", + "value" : "Veja as suas conversas mais recentes e volte a elas." } }, "sv" : { "stringUnit" : { - "value" : "Visa API-nyckel", - "state" : "translated" + "state" : "translated", + "value" : "Se dina senaste konversationer och hoppa tillbaka in." } } } }, - "Warning" : { + "Select a model to start chatting" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Προειδοποίηση", - "state" : "translated" + "state" : "translated", + "value" : "Wähle ein Modell, um das Gespräch zu beginnen" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Warnung", - "state" : "translated" + "state" : "translated", + "value" : "Επιλέξτε ένα μοντέλο για να ξεκινήσετε τη συνομιλία" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Advertencia" + "value" : "Select a model to start chatting" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Avviso", - "state" : "translated" + "state" : "translated", + "value" : "Selecciona un modelo para empezar a chatear" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Aviso", - "state" : "translated" + "state" : "translated", + "value" : "Sélectionnez un modèle pour commencer la conversation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Warning", - "state" : "translated" + "state" : "translated", + "value" : "Seleziona un modello per iniziare a chattare" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Waarschuwing" + "value" : "チャットを始めるモデルを選択してください" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Avertissement" + "value" : "Selecteer een model om te beginnen met chatten" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "警告", - "state" : "translated" + "state" : "translated", + "value" : "Selecione um modelo para começar a conversar" } }, "sv" : { "stringUnit" : { - "value" : "Varning", - "state" : "translated" + "state" : "translated", + "value" : "Välj en modell för att börja chatta" } } } }, - "Model" : { + "Send" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Μοντέλο", - "state" : "translated" + "state" : "translated", + "value" : "Senden" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Modelo" + "value" : "Αποστολή" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Modell" + "value" : "Send" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Modello", - "state" : "translated" + "state" : "translated", + "value" : "Enviar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Modelo" + "value" : "Envoyer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Model", - "state" : "translated" + "state" : "translated", + "value" : "Invia" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Model", - "state" : "translated" + "state" : "translated", + "value" : "送信" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Modèle", - "state" : "translated" + "state" : "translated", + "value" : "Verzenden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Modell", - "state" : "translated" + "state" : "translated", + "value" : "Enviar" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "モデル", - "state" : "translated" + "state" : "translated", + "value" : "Skicka" } } - }, - "comment" : "A label for a memory item that was generated by the model." + } }, - "OK" : { + "Send File to Chat" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Datei an Chat senden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Αποστολή αρχείου στη συνομιλία" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Send File to Chat" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Enviar archivo al chat" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Envoyer le fichier au chat" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Invia file alla chat" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "ファイルをチャットに送信" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Bestand naar chat verzenden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Enviar ficheiro para o chat" } }, "sv" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Skicka fil till chatt" } } } }, - "New Conversation" : { + "Sends an image or PDF to a new OpenClient conversation." : { + "comment" : "Description of the intent that sends an image or PDF to a new OpenClient conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "新しい会話" + "value" : "Sendet ein Bild oder PDF an eine neue OpenClient-Konversation." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nueva conversación", - "state" : "translated" + "state" : "translated", + "value" : "Στέλνει μια εικόνα ή PDF σε μια νέα συνομιλία OpenClient." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Neues Gespräch" + "value" : "Sends an image or PDF to a new OpenClient conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuova conversazione", - "state" : "translated" + "state" : "translated", + "value" : "Envía una imagen o PDF a una nueva conversación de OpenClient." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nova Conversa", - "state" : "translated" + "state" : "translated", + "value" : "Envoie une image ou un PDF dans une nouvelle conversation OpenClient." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New Conversation", - "state" : "translated" + "state" : "translated", + "value" : "Invia un'immagine o un PDF a una nuova conversazione OpenClient." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nieuw gesprek", - "state" : "translated" + "state" : "translated", + "value" : "画像またはPDFを新しいOpenClientの会話に送信します。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Nouvelle conversation", - "state" : "translated" + "state" : "translated", + "value" : "Verzendt een afbeelding of PDF naar een nieuw OpenClient-gesprek." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Νέα Συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Envia uma imagem ou PDF para uma nova conversa OpenClient." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Ny konversation" + "value" : "Skickar en bild eller PDF till en ny OpenClient-konversation." } } } }, - "%lld messages excluded from this request" : { + "Sent when a response finishes while the app is in the background." : { + "comment" : "A description of the notification that is sent when a response finishes while the app is in the background.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "%lld μηνύματα εξαιρέθηκαν από αυτό το αίτημα" + "value" : "Gesendet, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund läuft." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "%lld mensajes excluidos de esta solicitud" + "value" : "Αποστέλλεται όταν ολοκληρώνεται μια απάντηση ενώ η εφαρμογή είναι στο παρασκήνιο." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "このリクエストから %lld 件のメッセージが除外されました" + "value" : "Sent when a response completes while the app is in the background." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "%lld messaggi esclusi da questa richiesta", - "state" : "translated" + "state" : "translated", + "value" : "Enviado cuando una respuesta termina mientras la aplicación está en segundo plano." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "%lld mensagens excluídas deste pedido", - "state" : "translated" + "state" : "translated", + "value" : "Envoyé lorsqu’une réponse se termine alors que l’application est en arrière-plan." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "%lld messages excluded from this request", - "state" : "translated" + "state" : "translated", + "value" : "Inviato quando una risposta termina mentre l’app è in background." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%lld berichten uitgesloten van dit verzoek", - "state" : "translated" + "state" : "translated", + "value" : "アプリがバックグラウンドにある間に応答が完了したときに送信されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "%lld messages exclus de cette requête", - "state" : "translated" + "state" : "translated", + "value" : "Verzonden wanneer een reactie is voltooid terwijl de app op de achtergrond draait." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "%lld Nachrichten von dieser Anfrage ausgeschlossen", - "state" : "translated" + "state" : "translated", + "value" : "Enviado quando uma resposta termina enquanto a aplicação está em segundo plano." } }, "sv" : { "stringUnit" : { - "value" : "%lld meddelanden uteslutna från denna förfrågan", - "state" : "translated" + "state" : "translated", + "value" : "Skickas när ett svar slutförs medan appen är i bakgrunden." } } - }, - "comment" : "A message indicating that a certain number of messages have been excluded from a request. The argument is the number of messages that have been excluded." + } }, - "No search tools loaded. Tap \"Load Available Tools\" to fetch them from your server." : { + "Server" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "検索ツールが読み込まれていません。「利用可能なツールを読み込む」をタップしてサーバーから取得してください。", - "state" : "translated" + "state" : "translated", + "value" : "Server" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν έχουν φορτωθεί εργαλεία αναζήτησης. Πατήστε «Φόρτωση Διαθέσιμων Εργαλείων» για να τα κατεβάσετε από τον διακομιστή σας." + "value" : "Διακομιστής" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "No se cargaron herramientas de búsqueda. Toca \"Cargar herramientas disponibles\" para obtenerlas desde tu servidor." + "value" : "Server" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessuno strumento di ricerca caricato. Tocca \"Carica strumenti disponibili\" per recuperarli dal server.", - "state" : "translated" + "state" : "translated", + "value" : "Servidor" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nenhuma ferramenta de pesquisa carregada. Toque em \"Carregar Ferramentas Disponíveis\" para as obter do seu servidor.", - "state" : "translated" + "state" : "translated", + "value" : "Serveur" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No search tools loaded. Tap \"Load Available Tools\" to fetch them from your server.", - "state" : "translated" + "state" : "translated", + "value" : "Server" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Aucun outil de recherche chargé. Touchez « Charger les outils disponibles » pour les récupérer depuis votre serveur.", - "state" : "translated" + "state" : "translated", + "value" : "サーバー" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geen zoekhulpmiddelen geladen. Tik op \"Beschikbare hulpmiddelen laden\" om ze van uw server op te halen." + "value" : "Server" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Keine Suchwerkzeuge geladen. Tippen Sie auf „Verfügbare Werkzeuge laden“, um sie von Ihrem Server abzurufen.", - "state" : "translated" + "state" : "translated", + "value" : "Servidor" } }, "sv" : { "stringUnit" : { - "value" : "Inga sökverktyg laddade. Tryck på \"Ladda tillgängliga verktyg\" för att hämta dem från din server.", - "state" : "translated" + "state" : "translated", + "value" : "Server" } } - }, - "comment" : "A label that appears when there are no search tools available." + } }, - "Sent when a response finishes while the app is in the background." : { + "Server error (code %lld)." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Αποστέλλεται όταν ολοκληρώνεται μια απάντηση ενώ η εφαρμογή είναι στο παρασκήνιο." + "value" : "Serverfehler (Code %lld)." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Enviado cuando una respuesta termina mientras la aplicación está en segundo plano.", - "state" : "translated" + "state" : "translated", + "value" : "Σφάλμα διακομιστή (κωδικός %lld)." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Skickas när ett svar slutförs medan appen är i bakgrunden." + "value" : "Server error (code %lld)." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inviato quando una risposta termina mentre l’app è in background.", - "state" : "translated" + "state" : "translated", + "value" : "Error del servidor (código %lld)." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Enviado quando uma resposta termina enquanto a aplicação está em segundo plano." + "value" : "Erreur serveur (code %lld)." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Sent when a response completes while the app is in the background.", - "state" : "translated" + "state" : "translated", + "value" : "Errore del server (codice %lld)." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Verzonden wanneer een reactie is voltooid terwijl de app op de achtergrond draait.", - "state" : "translated" + "state" : "translated", + "value" : "サーバーエラー(コード %lld)" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Envoyé lorsqu’une réponse se termine alors que l’application est en arrière-plan.", - "state" : "translated" + "state" : "translated", + "value" : "Serverfout (code %lld)." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Gesendet, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund läuft.", - "state" : "translated" + "state" : "translated", + "value" : "Erro do servidor (código %lld)." } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "アプリがバックグラウンドにある間に応答が完了したときに送信されます。", - "state" : "translated" + "state" : "translated", + "value" : "Serverfel (kod %lld)." } } - }, - "comment" : "A description of the notification that is sent when a response finishes while the app is in the background." + } }, - "No results found for: %@" : { + "Server URL" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "%@ の結果は見つかりませんでした", - "state" : "translated" + "state" : "translated", + "value" : "Server-URL" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν βρέθηκαν αποτελέσματα για: %@" + "value" : "Διεύθυνση URL διακομιστή" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "No se encontraron resultados para: %@" + "value" : "Server URL" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessun risultato trovato per: %@", - "state" : "translated" + "state" : "translated", + "value" : "URL del servidor" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nenhum resultado encontrado para: %@" + "value" : "URL du serveur" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No results found for: %@", - "state" : "translated" + "state" : "translated", + "value" : "URL del server" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen resultaten gevonden voor: %@", - "state" : "translated" + "state" : "translated", + "value" : "サーバーURL" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucun résultat trouvé pour : %@", - "state" : "translated" + "state" : "translated", + "value" : "Server-URL" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Keine Ergebnisse gefunden für: %@", - "state" : "translated" + "state" : "translated", + "value" : "URL do servidor" } }, "sv" : { "stringUnit" : { - "value" : "Inga resultat hittades för: %@", - "state" : "translated" + "state" : "translated", + "value" : "Server-URL" } } - }, - "comment" : "A message to display when no search results are found. The argument is the search query." + } }, - "Enter a URL such as `openclient:\/\/chat?text=Summarise this`." : { + "Set instructions for the assistant's behavior in this conversation." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Geben Sie eine URL ein, z. B. `openclient:\/\/chat?text=Summarise this`." + "value" : "Anweisungen für das Verhalten des Assistenten in diesem Gespräch festlegen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Introduce una URL como `openclient:\/\/chat?text=Summarise this`.", - "state" : "translated" + "state" : "translated", + "value" : "Ορίστε οδηγίες για τη συμπεριφορά του βοηθού σε αυτή τη συνομιλία." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Εισαγάγετε μια διεύθυνση URL όπως `openclient:\/\/chat?text=Summarise this`" + "value" : "Set instructions for the assistant's behavior in this conversation." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inserisci un URL come `openclient:\/\/chat?text=Summarise this`", - "state" : "translated" + "state" : "translated", + "value" : "Establecer instrucciones para el comportamiento del asistente en esta conversación." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Introduza um URL como `openclient:\/\/chat?text=Summarise this`" + "value" : "Définir les instructions pour le comportement de l’assistant dans cette conversation." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Enter a URL such as `openclient:\/\/chat?text=Summarise this`", - "state" : "translated" + "state" : "translated", + "value" : "Imposta le istruzioni per il comportamento dell'assistente in questa conversazione." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voer een URL in zoals `openclient:\/\/chat?text=Summarise this`.", - "state" : "translated" + "state" : "translated", + "value" : "この会話におけるアシスタントの動作指示を設定してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Entrez une URL telle que `openclient:\/\/chat?text=Summarise this`.", - "state" : "translated" + "state" : "translated", + "value" : "Stel instructies in voor het gedrag van de assistent in dit gesprek." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "`openclient:\/\/chat?text=Summarise this` のようなURLを入力してください", - "state" : "translated" + "state" : "translated", + "value" : "Defina as instruções para o comportamento do assistente nesta conversa." } }, "sv" : { "stringUnit" : { - "value" : "Ange en URL som `openclient:\/\/chat?text=Summarise this`", - "state" : "translated" + "state" : "translated", + "value" : "Ange instruktioner för assistentens beteende i denna konversation." } } - }, - "comment" : "Step 3 in the process of creating a shortcut to open the OpenClient app with a specific URL." + } }, - "Leave empty to submit anonymously" : { + "Settings" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Leer lassen, um anonym zu senden", - "state" : "translated" + "state" : "translated", + "value" : "Einstellungen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Dejar vacío para enviar de forma anónima" + "value" : "Ρυθμίσεις" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Αφήστε κενό για ανώνυμη υποβολή" + "value" : "Settings" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Lascia vuoto per inviare in modo anonimo", - "state" : "translated" + "state" : "translated", + "value" : "Configuración" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Deixe vazio para enviar anonimamente", - "state" : "translated" + "state" : "translated", + "value" : "Paramètres" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Leave empty to submit anonymously", - "state" : "translated" + "state" : "translated", + "value" : "Impostazioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Laat leeg om anoniem te verzenden", - "state" : "translated" + "state" : "translated", + "value" : "設定" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Laisser vide pour soumettre anonymement" + "value" : "Instellingen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "匿名で送信するには空欄のままにしてください", - "state" : "translated" + "state" : "translated", + "value" : "Definições" } }, "sv" : { "stringUnit" : { - "value" : "Lämna tomt för att skicka anonymt", - "state" : "translated" + "state" : "translated", + "value" : "Inställningar" } } } }, - "Enable Notifications" : { + "Share" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ενεργοποίηση ειδοποιήσεων", - "state" : "translated" + "state" : "translated", + "value" : "Teilen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Activar notificaciones", - "state" : "translated" + "state" : "translated", + "value" : "Κοινή χρήση" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Benachrichtigungen aktivieren" + "value" : "Share" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Abilita notifiche" + "value" : "Compartir" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ativar notificações" + "value" : "Partager" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Enable Notifications", - "state" : "translated" + "state" : "translated", + "value" : "Condividi" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Meldingen inschakelen", - "state" : "translated" + "state" : "translated", + "value" : "共有" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Activer les notifications", - "state" : "translated" + "state" : "translated", + "value" : "Delen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "通知を有効にする", - "state" : "translated" + "state" : "translated", + "value" : "Partilhar" } }, "sv" : { "stringUnit" : { - "value" : "Aktivera aviseringar", - "state" : "translated" + "state" : "translated", + "value" : "Dela" } } - }, - "comment" : "A button that enables notifications." + } }, - "Comments" : { + "Share Extension" : { + "comment" : "A section that describes how to use the share extension to share content with the app.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "コメント", - "state" : "translated" + "state" : "translated", + "value" : "Freigabeerweiterung" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Comentarios", - "state" : "translated" + "state" : "translated", + "value" : "Επέκταση Κοινοποίησης" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Σχόλια", - "state" : "translated" + "state" : "translated", + "value" : "Share Extension" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Commenti" + "value" : "Extensión para compartir" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Comentários" + "value" : "Extension de partage" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Comments" + "value" : "Estensione di condivisione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Reacties", - "state" : "translated" + "state" : "translated", + "value" : "共有エクステンション" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Commentaires", - "state" : "translated" + "state" : "translated", + "value" : "Deeluitbreiding" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Kommentare", - "state" : "translated" + "state" : "translated", + "value" : "Extensão de Partilha" } }, "sv" : { "stringUnit" : { - "value" : "Kommentarer", - "state" : "translated" + "state" : "translated", + "value" : "Dela-tillägg" } } } }, - "Review, edit, disable, or delete the memories used in future conversations." : { + "Share text, links, images, or PDFs from any app into OpenClient." : { + "comment" : "A description of how to use the share extension.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Granska, redigera, inaktivera eller ta bort minnen som används i framtida konversationer.", - "state" : "translated" + "state" : "translated", + "value" : "Teile Text, Links, Bilder oder PDFs aus jeder App mit OpenClient." } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Überprüfen, bearbeiten, deaktivieren oder löschen Sie die Erinnerungen, die in zukünftigen Gesprächen verwendet werden." + "value" : "Μοιραστείτε κείμενο, συνδέσμους, εικόνες ή αρχεία PDF από οποιαδήποτε εφαρμογή στο OpenClient." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Revisa, edita, desactiva o elimina los recuerdos usados en futuras conversaciones." + "value" : "Share text, links, images, or PDFs from any app to OpenClient." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Rivedi, modifica, disabilita o elimina i ricordi utilizzati nelle conversazioni future.", - "state" : "translated" + "state" : "translated", + "value" : "Comparte texto, enlaces, imágenes o PDFs desde cualquier aplicación en OpenClient." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Revise, edite, desative ou elimine as memórias usadas em conversas futuras.", - "state" : "translated" + "state" : "translated", + "value" : "Partagez du texte, des liens, des images ou des PDF depuis n’importe quelle application vers OpenClient." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Review, edit, disable, or delete the memories used in future conversations", - "state" : "translated" + "state" : "translated", + "value" : "Condividi testo, link, immagini o PDF da qualsiasi app in OpenClient." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Beoordeel, bewerk, schakel uit of verwijder de herinneringen die in toekomstige gesprekken worden gebruikt.", - "state" : "translated" + "state" : "translated", + "value" : "任意のアプリからテキスト、リンク、画像、PDFをOpenClientに共有する" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Révisez, modifiez, désactivez ou supprimez les souvenirs utilisés dans les conversations futures." + "value" : "Deel tekst, links, afbeeldingen of PDF's vanuit elke app met OpenClient." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "今後の会話で使用される記憶を確認、編集、無効化、または削除します。", - "state" : "translated" + "state" : "translated", + "value" : "Partilhe texto, links, imagens ou PDFs de qualquer aplicação para o OpenClient." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αναθεώρηση, επεξεργασία, απενεργοποίηση ή διαγραφή των αναμνήσεων που χρησιμοποιούνται σε μελλοντικές συνομιλίες.", - "state" : "translated" + "state" : "translated", + "value" : "Dela text, länkar, bilder eller PDF-filer från vilken app som helst till OpenClient." } } - }, - "comment" : "A description of the memory management feature." + } }, - "Preparing image" : { + "Share your idea" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Προετοιμασία εικόνας", - "state" : "translated" + "state" : "translated", + "value" : "Teile deine Idee" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Förbereder bild" + "value" : "Μοιραστείτε την ιδέα σας" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Preparando la imagen" + "value" : "Share your idea" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Preparazione dell'immagine", - "state" : "translated" + "state" : "translated", + "value" : "Comparte tu idea" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A preparar a imagem", - "state" : "translated" + "state" : "translated", + "value" : "Partagez votre idée" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Preparing image" + "value" : "Condividi la tua idea" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeelding voorbereiden", - "state" : "translated" + "state" : "translated", + "value" : "アイデアを共有する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Préparation de l’image", - "state" : "translated" + "state" : "translated", + "value" : "Deel je idee" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Bild wird vorbereitet", - "state" : "translated" + "state" : "translated", + "value" : "Partilhe a sua ideia" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "画像を準備中", - "state" : "translated" + "state" : "translated", + "value" : "Dela din idé" } } - }, - "comment" : "A label for an in-progress image preparation task." + } }, - "Focused" : { + "Share your thoughts..." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "フォーカス済み", - "state" : "translated" + "state" : "translated", + "value" : "Teile deine Gedanken..." } }, "el" : { "stringUnit" : { - "value" : "Εστιασμένο", - "state" : "translated" + "state" : "translated", + "value" : "Μοιραστείτε τις σκέψεις σας..." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enfocado" + "value" : "Share your thoughts..." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Concentrato" + "value" : "Comparte tus pensamientos..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Focado", - "state" : "translated" + "state" : "translated", + "value" : "Partagez vos pensées..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Focused", - "state" : "translated" + "state" : "translated", + "value" : "Condividi i tuoi pensieri..." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Concentré", - "state" : "translated" + "state" : "translated", + "value" : "あなたの考えを共有してください..." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gefocust" + "value" : "Deel je gedachten..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Fokussiert", - "state" : "translated" + "state" : "translated", + "value" : "Partilhe as suas ideias..." } }, "sv" : { "stringUnit" : { - "value" : "Fokuserad", - "state" : "translated" + "state" : "translated", + "value" : "Dela dina tankar..." } } } }, - "Connect external tools" : { + "Show Actions" : { + "comment" : "A label for a button that shows additional actions.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Externe Werkzeuge verbinden" + "value" : "Aktionen anzeigen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Conectar herramientas externas", - "state" : "translated" + "state" : "translated", + "value" : "Εμφάνιση ενεργειών" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Anslut externa verktyg" + "value" : "Show Actions" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Collega strumenti esterni", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar acciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ligar ferramentas externas", - "state" : "translated" + "state" : "translated", + "value" : "Afficher les actions" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Connect external tools" + "value" : "Mostra azioni" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Externe tools verbinden", - "state" : "translated" + "state" : "translated", + "value" : "アクションを表示" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Connecter des outils externes", - "state" : "translated" + "state" : "translated", + "value" : "Acties tonen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "外部ツールを接続する", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar Ações" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Σύνδεση εξωτερικών εργαλείων", - "state" : "translated" + "state" : "translated", + "value" : "Visa åtgärder" } } - }, - "comment" : "A tip that explains how to connect external tools to the model." + } }, - "Message" : { + "Show API Key" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "メッセージ", - "state" : "translated" + "state" : "translated", + "value" : "API-Schlüssel anzeigen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mensaje", - "state" : "translated" + "state" : "translated", + "value" : "Εμφάνιση κλειδιού API" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Μήνυμα" + "value" : "Show API Key" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Messaggio", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar clave API" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mensagem" + "value" : "Afficher la clé API" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Message" + "value" : "Mostra chiave API" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Message", - "state" : "translated" + "state" : "translated", + "value" : "APIキーを表示" } }, "nl" : { "stringUnit" : { - "value" : "Bericht", - "state" : "translated" + "state" : "translated", + "value" : "API-sleutel tonen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Nachricht", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar chave API" } }, "sv" : { "stringUnit" : { - "value" : "Meddelande", - "state" : "translated" + "state" : "translated", + "value" : "Visa API-nyckel" } } } }, - "The server is not reachable." : { + "Show Feature Tips Again" : { + "comment" : "A button that shows the feature tips again.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Servern är inte nåbar." + "value" : "Funktionstipps erneut anzeigen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El servidor no es accesible.", - "state" : "translated" + "state" : "translated", + "value" : "Εμφάνιση συμβουλών λειτουργίας ξανά" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ο διακομιστής δεν είναι προσβάσιμος." + "value" : "Show Feature Tips Again" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il server non è raggiungibile.", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar consejos de funciones nuevamente" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O servidor não está acessível.", - "state" : "translated" + "state" : "translated", + "value" : "Afficher à nouveau les astuces de fonctionnalité" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The server is not reachable.", - "state" : "translated" + "state" : "translated", + "value" : "Mostra di nuovo i suggerimenti sulle funzionalità" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De server is niet bereikbaar.", - "state" : "translated" + "state" : "translated", + "value" : "機能のヒントを再表示する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Le serveur est inaccessible.", - "state" : "translated" + "state" : "translated", + "value" : "Toon functietips opnieuw" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバーに接続できません。", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar Dicas de Funcionalidades Novamente" } }, - "de" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Der Server ist nicht erreichbar." + "value" : "Visa tips om funktioner igen" } } } }, - "Add images and documents" : { + "Show Token Usage" : { + "comment" : "A toggle that shows the number of tokens remaining in the current token.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Bilder und Dokumente hinzufügen", - "state" : "translated" + "state" : "translated", + "value" : "Tokenverbrauch anzeigen" } }, - "ja" : { + "el" : { "stringUnit" : { - "value" : "画像とドキュメントを追加", - "state" : "translated" + "state" : "translated", + "value" : "Εμφάνιση χρήσης διακριτικού" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar imágenes y documentos" + "value" : "Show Token Usage" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiungi immagini e documenti", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar uso de tokens" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Adicionar imagens e documentos", - "state" : "translated" + "state" : "translated", + "value" : "Afficher l’utilisation des jetons" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Add images and documents" + "value" : "Mostra utilizzo token" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeeldingen en documenten toevoegen", - "state" : "translated" + "state" : "translated", + "value" : "トークン使用量を表示" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ajouter des images et des documents" + "value" : "Tokengebruik weergeven" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προσθήκη εικόνων και εγγράφων", - "state" : "translated" + "state" : "translated", + "value" : "Mostrar Utilização de Token" } }, "sv" : { "stringUnit" : { - "value" : "Lägg till bilder och dokument", - "state" : "translated" + "state" : "translated", + "value" : "Visa tokenanvändning" } } - }, - "comment" : "A description of how to add images and documents to a conversation." + } }, - "More actions for messages" : { + "Sign in to iCloud to enable sync" : { + "comment" : "A message that instructs the user to sign in to iCloud to enable iCloud sync.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Περισσότερες ενέργειες για μηνύματα", - "state" : "translated" + "state" : "translated", + "value" : "Melden Sie sich bei iCloud an, um die Synchronisierung zu aktivieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Más acciones para mensajes", - "state" : "translated" + "state" : "translated", + "value" : "Συνδεθείτε στο iCloud για να ενεργοποιήσετε το συγχρονισμό" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "メッセージの追加操作" + "value" : "Sign in to iCloud to enable sync" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Altre azioni per i messaggi", - "state" : "translated" + "state" : "translated", + "value" : "Inicia sesión en iCloud para activar la sincronización" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Mais ações para mensagens", - "state" : "translated" + "state" : "translated", + "value" : "Connectez-vous à iCloud pour activer la synchronisation" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "More actions for messages" + "value" : "Accedi a iCloud per abilitare la sincronizzazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Plus d’actions pour les messages", - "state" : "translated" + "state" : "translated", + "value" : "iCloudにサインインして同期を有効にする" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Meer acties voor berichten" + "value" : "Meld u aan bij iCloud om synchronisatie in te schakelen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Weitere Aktionen für Nachrichten", - "state" : "translated" + "state" : "translated", + "value" : "Inicie sessão no iCloud para ativar a sincronização" } }, "sv" : { "stringUnit" : { - "value" : "Fler åtgärder för meddelanden", - "state" : "translated" + "state" : "translated", + "value" : "Logga in på iCloud för att aktivera synkronisering" } } - }, - "comment" : "A tip that shows when the user has enabled the message actions." + } }, - "The MCP server reported a tool error." : { + "sk-..." : { + "comment" : "A placeholder for the API key field.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ο διακομιστής MCP ανέφερε σφάλμα εργαλείου.", - "state" : "translated" + "state" : "translated", + "value" : "sk-..." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El servidor MCP informó un error de herramienta.", - "state" : "translated" + "state" : "translated", + "value" : "sk-..." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Der MCP-Server meldete einen Werkzeugfehler." + "value" : "sk-..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il server MCP ha segnalato un errore dello strumento.", - "state" : "translated" + "state" : "translated", + "value" : "sk-..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O servidor MCP reportou um erro na ferramenta." + "value" : "sk-..." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The MCP server reported a tool error." + "value" : "sk-..." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Le serveur MCP a signalé une erreur d’outil.", - "state" : "translated" + "state" : "translated", + "value" : "sk-..." } }, "nl" : { "stringUnit" : { - "value" : "De MCP-server meldde een toolfout.", - "state" : "translated" + "state" : "translated", + "value" : "sk-..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "MCPサーバーがツールエラーを報告しました。", - "state" : "translated" + "state" : "translated", + "value" : "sk-..." } }, "sv" : { "stringUnit" : { - "value" : "MCP-servern rapporterade ett verktygsfel.", - "state" : "translated" + "state" : "translated", + "value" : "sk-..." } } - }, - "comment" : "Error message when an MCP tool call fails." + } }, - "Results" : { + "Skip" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Ergebnisse", - "state" : "translated" - } - }, - "es" : { - "stringUnit" : { - "value" : "Resultados", - "state" : "translated" + "state" : "translated", + "value" : "Überspringen" } }, "el" : { "stringUnit" : { - "value" : "Αποτελέσματα", - "state" : "translated" + "state" : "translated", + "value" : "Παράλειψη" } }, - "it" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Risultati" + "value" : "Skip" } }, - "pt-PT" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Resultados" + "value" : "Omitir" } }, - "en" : { + "fr" : { "stringUnit" : { - "value" : "Results", - "state" : "translated" + "state" : "translated", + "value" : "Passer" } }, - "fr" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salta" + } + }, + "ja" : { "stringUnit" : { - "value" : "Résultats", - "state" : "translated" + "state" : "translated", + "value" : "スキップ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Resultaten" + "value" : "Overslaan" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "結果", - "state" : "translated" + "state" : "translated", + "value" : "Ignorar" } }, "sv" : { "stringUnit" : { - "value" : "Resultat", - "state" : "translated" + "state" : "translated", + "value" : "Hoppa över" } } - }, - "comment" : "A label displayed in the footer of a settings section." + } }, - "Delete" : { + "Some data could not be synchronized: %@. Local changes are retained." : { + "comment" : "A description of an error that might occur when synchronizing data.", + "isCommentAutoGenerated" : true + }, + "Some local data could not be reset. No remaining data was discarded." : { + "comment" : "A description of the error that occurs when the user tries to reset the app's data.", + "isCommentAutoGenerated" : true + }, + "Some MCP servers could not be loaded: %@." : { + "comment" : "A message that describes which MCP servers failed to load.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Διαγραφή", - "state" : "translated" + "state" : "translated", + "value" : "Einige MCP-Server konnten nicht geladen werden: %@." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eliminar", - "state" : "translated" + "state" : "translated", + "value" : "Ορισμένοι διακομιστές MCP δεν μπόρεσαν να φορτωθούν: %@." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Löschen" + "value" : "Some MCP servers could not be loaded: %@." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Elimina", - "state" : "translated" + "state" : "translated", + "value" : "No se pudieron cargar algunos servidores MCP: %@." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Eliminar", - "state" : "translated" + "state" : "translated", + "value" : "Certains serveurs MCP n'ont pas pu être chargés : %@." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Delete" + "value" : "Alcuni server MCP non sono stati caricati: %@." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Verwijderen", - "state" : "translated" + "state" : "translated", + "value" : "一部のMCPサーバーを読み込めませんでした: %@" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Supprimer" + "value" : "Sommige MCP-servers konden niet worden geladen: %@." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "削除", - "state" : "translated" + "state" : "translated", + "value" : "Alguns servidores MCP não puderam ser carregados: %@." } }, "sv" : { "stringUnit" : { - "value" : "Radera", - "state" : "translated" + "state" : "translated", + "value" : "Vissa MCP-servrar kunde inte laddas: %@." } } } }, - "No Memory Items" : { + "Something went wrong. Please try again." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δεν υπάρχουν στοιχεία μνήμης", - "state" : "translated" + "state" : "translated", + "value" : "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No hay elementos de memoria", - "state" : "translated" + "state" : "translated", + "value" : "Κάτι πήγε στραβά. Παρακαλώ δοκιμάστε ξανά." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keine Speicherobjekte" + "value" : "Something went wrong. Please try again." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nessun elemento di memoria" + "value" : "Algo salió mal. Por favor, inténtalo de nuevo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sem itens de memória" + "value" : "Une erreur est survenue. Veuillez réessayer." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No Memory Items", - "state" : "translated" + "state" : "translated", + "value" : "Qualcosa è andato storto. Riprova." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geen geheugenitems", - "state" : "translated" + "state" : "translated", + "value" : "問題が発生しました。もう一度お試しください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucun élément mémorisé", - "state" : "translated" + "state" : "translated", + "value" : "Er is iets misgegaan. Probeer het opnieuw." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メモリ項目なし", - "state" : "translated" + "state" : "translated", + "value" : "Algo correu mal. Por favor, tente novamente." } }, "sv" : { "stringUnit" : { - "value" : "Inga minnesobjekt", - "state" : "translated" + "state" : "translated", + "value" : "Något gick fel. Försök igen." } } - }, - "comment" : "A message displayed when the user has no memory items." + } }, - "Images" : { + "Speech recognition is not available on this device." : { + "comment" : "Error message when the speech recognition is not available on the device.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bilder" + "value" : "Spracherkennung ist auf diesem Gerät nicht verfügbar." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Imágenes", - "state" : "translated" + "state" : "translated", + "value" : "Η αναγνώριση ομιλίας δεν είναι διαθέσιμη σε αυτή τη συσκευή." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "画像" + "value" : "Speech recognition is not available on this device." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Immagini", - "state" : "translated" + "state" : "translated", + "value" : "El reconocimiento de voz no está disponible en este dispositivo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Imagens", - "state" : "translated" + "state" : "translated", + "value" : "La reconnaissance vocale n’est pas disponible sur cet appareil." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Images" + "value" : "Il riconoscimento vocale non è disponibile su questo dispositivo." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeeldingen", - "state" : "translated" + "state" : "translated", + "value" : "このデバイスでは音声認識が利用できません。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Images", - "state" : "translated" + "state" : "translated", + "value" : "Spraakherkenning is niet beschikbaar op dit apparaat." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Εικόνες", - "state" : "translated" + "state" : "translated", + "value" : "O reconhecimento de voz não está disponível neste dispositivo." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Bilder", - "state" : "translated" + "state" : "translated", + "value" : "Taligenkänning är inte tillgänglig på den här enheten." } } - }, - "comment" : "A section header for a list of images." + } }, - "Your name (optional)" : { + "Speech recognition permission was not granted." : { + "comment" : "Error message when speech recognition permission is not granted.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Ihr Name (optional)", - "state" : "translated" + "state" : "translated", + "value" : "Die Erlaubnis zur Spracherkennung wurde nicht erteilt." } }, "el" : { "stringUnit" : { - "value" : "Το όνομά σας (προαιρετικό)", - "state" : "translated" + "state" : "translated", + "value" : "Η άδεια αναγνώρισης ομιλίας δεν δόθηκε." } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Tu nombre (opcional)", - "state" : "translated" + "state" : "translated", + "value" : "Speech recognition permission was not granted." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il tuo nome (opzionale)" + "value" : "No se concedió permiso para el reconocimiento de voz." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O seu nome (opcional)" + "value" : "La permission de reconnaissance vocale n’a pas été accordée." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Your name (optional)", - "state" : "translated" + "state" : "translated", + "value" : "Il permesso per il riconoscimento vocale non è stato concesso." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je naam (optioneel)", - "state" : "translated" + "state" : "translated", + "value" : "音声認識の許可が付与されていません。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Votre nom (optionnel)" + "value" : "Toestemming voor spraakherkenning is niet verleend." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "あなたの名前(任意)", - "state" : "translated" + "state" : "translated", + "value" : "A permissão para reconhecimento de voz não foi concedida." } }, "sv" : { "stringUnit" : { - "value" : "Ditt namn (valfritt)", - "state" : "translated" + "state" : "translated", + "value" : "Tillstånd för taligenkänning beviljades inte." } } } }, - "Description" : { + "Speech to Text" : { + "comment" : "A section title for speech-to-text models.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "説明", - "state" : "translated" + "state" : "translated", + "value" : "Sprache zu Text" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Descripción", - "state" : "translated" + "state" : "translated", + "value" : "Ομιλία σε κείμενο" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Περιγραφή", - "state" : "translated" + "state" : "translated", + "value" : "Speech to Text" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Descrizione", - "state" : "translated" + "state" : "translated", + "value" : "Voz a texto" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Descrição" + "value" : "Parole en texte" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Description", - "state" : "translated" + "state" : "translated", + "value" : "Da voce a testo" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Beschrijving" + "value" : "音声認識" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Description" + "value" : "Spraak naar tekst" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Beschreibung", - "state" : "translated" + "state" : "translated", + "value" : "Fala para Texto" } }, "sv" : { "stringUnit" : { - "value" : "Beskrivning", - "state" : "translated" + "state" : "translated", + "value" : "Tal till text" } } - }, - "comment" : "A label displayed above the user's profile description." + } }, - "Start a new chat or search your conversations." : { + "Start a conversation" : { + "comment" : "Subtitle for the \"New Chat\" action button in the Quick Actions widget.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "新しいチャットを開始するか、会話を検索してください。" + "value" : "Konversation starten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Inicia un nuevo chat o busca en tus conversaciones.", - "state" : "translated" + "state" : "translated", + "value" : "Ξεκινήστε μια συνομιλία" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Beginnen Sie einen neuen Chat oder durchsuchen Sie Ihre Unterhaltungen." + "value" : "Start a conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Avvia una nuova chat o cerca nelle tue conversazioni.", - "state" : "translated" + "state" : "translated", + "value" : "Iniciar una conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Inicie uma nova conversa ou pesquise nas suas conversas.", - "state" : "translated" + "state" : "translated", + "value" : "Démarrer une conversation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Start a new chat or search your conversations", - "state" : "translated" + "state" : "translated", + "value" : "Inizia una conversazione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Begin een nieuw gesprek of doorzoek je gesprekken.", - "state" : "translated" + "state" : "translated", + "value" : "会話を始める" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Commencez une nouvelle conversation ou recherchez dans vos discussions.", - "state" : "translated" + "state" : "translated", + "value" : "Begin een gesprek" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ξεκινήστε μια νέα συνομιλία ή αναζητήστε τις συνομιλίες σας.", - "state" : "translated" + "state" : "translated", + "value" : "Iniciar uma conversa" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Starta en ny chatt eller sök i dina konversationer." + "value" : "Starta en konversation" } } - }, - "comment" : "Widget description." + } }, - "Image generation requires a text prompt without attachments." : { + "Start a new chat or search your conversations." : { + "comment" : "Widget description.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η δημιουργία εικόνας απαιτεί μια περιγραφή κειμένου χωρίς συνημμένα.", - "state" : "translated" + "state" : "translated", + "value" : "Beginnen Sie einen neuen Chat oder durchsuchen Sie Ihre Unterhaltungen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La generación de imágenes requiere un texto descriptivo sin archivos adjuntos.", - "state" : "translated" + "state" : "translated", + "value" : "Ξεκινήστε μια νέα συνομιλία ή αναζητήστε τις συνομιλίες σας." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bildgenerering kräver en textprompt utan bilagor." + "value" : "Start a new chat or search your conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "La generazione dell'immagine richiede un prompt testuale senza allegati.", - "state" : "translated" + "state" : "translated", + "value" : "Inicia un nuevo chat o busca en tus conversaciones." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A geração de imagens requer um prompt de texto sem anexos." + "value" : "Commencez une nouvelle conversation ou recherchez dans vos discussions." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Image generation requires a text prompt without attachments." + "value" : "Avvia una nuova chat o cerca nelle tue conversazioni." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voor het genereren van een afbeelding is een tekstprompt zonder bijlagen vereist.", - "state" : "translated" + "state" : "translated", + "value" : "新しいチャットを開始するか、会話を検索してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "La génération d’images nécessite une invite textuelle sans pièces jointes.", - "state" : "translated" + "state" : "translated", + "value" : "Begin een nieuw gesprek of doorzoek je gesprekken." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "画像を生成するには、添付ファイルなしでテキストプロンプトを入力してください。", - "state" : "translated" + "state" : "translated", + "value" : "Inicie uma nova conversa ou pesquise nas suas conversas." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Für die Bildgenerierung ist eine Texteingabe ohne Anhänge erforderlich.", - "state" : "translated" - } + "state" : "translated", + "value" : "Starta en ny chatt eller sök i dina konversationer." + } } - }, - "comment" : "Error message displayed when trying to generate an image without providing a text prompt." + } }, - "No suggestions yet." : { + "Start a new conversation" : { + "comment" : "Shortcut action to start a new chat.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δεν υπάρχουν προτάσεις ακόμα.", - "state" : "translated" + "state" : "translated", + "value" : "Neue Unterhaltung starten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Aún no hay sugerencias.", - "state" : "translated" + "state" : "translated", + "value" : "Ξεκινήστε μια νέα συνομιλία" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "まだ提案はありません。" + "value" : "Start a new conversation" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessun suggerimento ancora.", - "state" : "translated" + "state" : "translated", + "value" : "Iniciar una nueva conversación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sem sugestões ainda." + "value" : "Commencer une nouvelle conversation" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "No suggestions yet." + "value" : "Inizia una nuova conversazione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nog geen suggesties.", - "state" : "translated" + "state" : "translated", + "value" : "新しい会話を始める" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Pas encore de suggestions.", - "state" : "translated" + "state" : "translated", + "value" : "Begin een nieuw gesprek" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Noch keine Vorschläge.", - "state" : "translated" + "state" : "translated", + "value" : "Iniciar nova conversa" } }, "sv" : { "stringUnit" : { - "value" : "Inga förslag än så länge.", - "state" : "translated" + "state" : "translated", + "value" : "Starta en ny konversation" } } } }, - "%lld sources" : { + "Start a new conversation to begin chatting" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "%lld Quellen", - "state" : "translated" + "state" : "translated", + "value" : "Beginnen Sie eine neue Unterhaltung, um zu chatten" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "%lld fuentes", - "state" : "translated" + "state" : "translated", + "value" : "Ξεκινήστε μια νέα συνομιλία για να αρχίσετε να συνομιλείτε" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "%lld πηγές" + "value" : "Start a new conversation to begin chatting" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "%lld fonti", - "state" : "translated" + "state" : "translated", + "value" : "Inicia una nueva conversación para comenzar a chatear" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%lld fontes" + "value" : "Commencez une nouvelle conversation pour commencer à discuter" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "%lld sources" + "value" : "Inizia una nuova conversazione per iniziare a chattare" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "%lld sources", - "state" : "translated" + "state" : "translated", + "value" : "新しい会話を始めてチャットを開始してください" } }, "nl" : { "stringUnit" : { - "value" : "%lld bronnen", - "state" : "translated" + "state" : "translated", + "value" : "Begin een nieuw gesprek om te chatten" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "%lld 件のソース", - "state" : "translated" + "state" : "translated", + "value" : "Inicie uma nova conversa para começar a conversar" } }, "sv" : { "stringUnit" : { - "value" : "%lld källor", - "state" : "translated" + "state" : "translated", + "value" : "Starta en ny konversation för att börja chatta" } } - }, - "comment" : "A label that displays the number of sources found in a search result. The argument is the number of sources." + } }, - "Suggest Features" : { + "Start a private chat" : { + "comment" : "A description of the private chat feature.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Προτείνετε λειτουργίες", - "state" : "translated" + "state" : "translated", + "value" : "Privaten Chat starten" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Sugerir funciones" + "value" : "Ξεκινήστε μια ιδιωτική συνομιλία" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Föreslå funktioner" + "value" : "Start a private chat" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Suggerisci funzionalità", - "state" : "translated" + "state" : "translated", + "value" : "Iniciar un chat privado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sugerir Funcionalidades", - "state" : "translated" + "state" : "translated", + "value" : "Démarrer une conversation privée" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Suggest Features" + "value" : "Avvia una chat privata" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Functies voorstellen", - "state" : "translated" + "state" : "translated", + "value" : "プライベートチャットを開始" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Suggérer des fonctionnalités", - "state" : "translated" + "state" : "translated", + "value" : "Begin een privégesprek" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "機能提案", - "state" : "translated" + "state" : "translated", + "value" : "Iniciar uma conversa privada" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Funktionen vorschlagen", - "state" : "translated" + "state" : "translated", + "value" : "Starta en privat chatt" } } } }, - "Edit Tags" : { + "Start Chatting" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Tags bearbeiten", - "state" : "translated" + "state" : "translated", + "value" : "Chat starten" } }, "el" : { "stringUnit" : { - "value" : "Επεξεργασία ετικετών", - "state" : "translated" + "state" : "translated", + "value" : "Ξεκινήστε τη συνομιλία" } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Editar etiquetas", - "state" : "translated" + "state" : "translated", + "value" : "Start Chatting" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Modifica tag", - "state" : "translated" + "state" : "translated", + "value" : "Comenzar a chatear" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Editar Etiquetas", - "state" : "translated" + "state" : "translated", + "value" : "Commencer la discussion" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Edit Tags" + "value" : "Inizia a chattare" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Modifier les tags" + "value" : "チャットを始める" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tags bewerken" + "value" : "Begin met chatten" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "タグを編集", - "state" : "translated" + "state" : "translated", + "value" : "Iniciar Conversa" } }, "sv" : { "stringUnit" : { - "value" : "Redigera taggar", - "state" : "translated" + "state" : "translated", + "value" : "Börja chatta" } } - }, - "comment" : "A button that opens a sheet for editing a conversation's tags." + } }, - "Not Now" : { + "Stop" : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Inte nu" + "value" : "Stopp" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Ahora no", - "state" : "translated" + "state" : "translated", + "value" : "Διακοπή" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "今はしない" + "value" : "Stop" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Non ora", - "state" : "translated" + "state" : "translated", + "value" : "Detener" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Agora não", - "state" : "translated" + "state" : "translated", + "value" : "Arrêter" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Not Now" + "value" : "Interrompi" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Niet nu", - "state" : "translated" + "state" : "translated", + "value" : "停止" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Pas maintenant", - "state" : "translated" + "state" : "translated", + "value" : "Stoppen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Όχι τώρα", - "state" : "translated" + "state" : "translated", + "value" : "Parar" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Nicht jetzt", - "state" : "translated" + "state" : "translated", + "value" : "Stoppa" } } - }, - "comment" : "A button that dismisses an alert." + } }, - "Message..." : { + "Stop Recording" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Nachricht...", - "state" : "translated" + "state" : "translated", + "value" : "Aufnahme stoppen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mensaje...", - "state" : "translated" + "state" : "translated", + "value" : "Διακοπή εγγραφής" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Meddelande..." + "value" : "Stop Recording" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Messaggio...", - "state" : "translated" + "state" : "translated", + "value" : "Detener grabación" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Mensagem...", - "state" : "translated" + "state" : "translated", + "value" : "Arrêter l’enregistrement" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Message..." + "value" : "Interrompi registrazione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bericht...", - "state" : "translated" + "state" : "translated", + "value" : "録音停止" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Message..." + "value" : "Opname stoppen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メッセージ...", - "state" : "translated" + "state" : "translated", + "value" : "Parar Gravação" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Μήνυμα...", - "state" : "translated" + "state" : "translated", + "value" : "Stoppa inspelning" } } } }, - "Success" : { + "Submit" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Επιτυχία" + "value" : "Senden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Éxito", - "state" : "translated" + "state" : "translated", + "value" : "Υποβολή" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "成功" + "value" : "Submit" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Successo", - "state" : "translated" + "state" : "translated", + "value" : "Enviar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sucesso", - "state" : "translated" + "state" : "translated", + "value" : "Envoyer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Success", - "state" : "translated" + "state" : "translated", + "value" : "Invia" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Succes", - "state" : "translated" + "state" : "translated", + "value" : "送信" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Succès" + "value" : "Verzenden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Erfolg", - "state" : "translated" + "state" : "translated", + "value" : "Enviar" } }, "sv" : { "stringUnit" : { - "value" : "Framgång", - "state" : "translated" + "state" : "translated", + "value" : "Skicka" } } } }, - "Your AI, Your Way" : { + "Success" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η Τεχνητή Νοημοσύνη Σας, Με Τον Τρόπο Σας", - "state" : "translated" + "state" : "translated", + "value" : "Erfolg" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Tu IA, a tu manera" + "value" : "Επιτυχία" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "あなたのAI、あなたのスタイル" + "value" : "Success" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "La tua IA, a modo tuo", - "state" : "translated" + "state" : "translated", + "value" : "Éxito" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A sua IA, à sua maneira", - "state" : "translated" + "state" : "translated", + "value" : "Succès" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Your AI, Your Way" + "value" : "Successo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Jouw AI, Jouw Manier", - "state" : "translated" + "state" : "translated", + "value" : "成功" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Votre IA, à votre façon", - "state" : "translated" + "state" : "translated", + "value" : "Succes" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Deine KI, Dein Weg", - "state" : "translated" + "state" : "translated", + "value" : "Sucesso" } }, "sv" : { "stringUnit" : { - "value" : "Din AI, på ditt sätt", - "state" : "translated" + "state" : "translated", + "value" : "Framgång" } } - }, - "comment" : "The title of the onboarding screen." + } }, - "Sync" : { + "Suggest Features" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Συγχρονισμός", - "state" : "translated" + "state" : "translated", + "value" : "Funktionen vorschlagen" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Synchronisation", - "state" : "translated" + "state" : "translated", + "value" : "Προτείνετε λειτουργίες" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sincronización" + "value" : "Suggest Features" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Sincronizzazione" + "value" : "Sugerir funciones" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sincronizar", - "state" : "translated" + "state" : "translated", + "value" : "Suggérer des fonctionnalités" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Sync" + "value" : "Suggerisci funzionalità" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Synchronisation", - "state" : "translated" + "state" : "translated", + "value" : "機能提案" } }, "nl" : { "stringUnit" : { - "value" : "Synchroniseren", - "state" : "translated" + "state" : "translated", + "value" : "Functies voorstellen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "同期", - "state" : "translated" + "state" : "translated", + "value" : "Sugerir Funcionalidades" } }, "sv" : { "stringUnit" : { - "value" : "Synkronisering", - "state" : "translated" + "state" : "translated", + "value" : "Föreslå funktioner" } } - }, - "comment" : "A heading for the sync settings." + } }, - "Copied" : { + "Suggested anonymously" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αντιγράφηκε", - "state" : "translated" + "state" : "translated", + "value" : "Anonym vorgeschlagen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Copiado", - "state" : "translated" + "state" : "translated", + "value" : "Προταθεί ανώνυμα" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kopiert" + "value" : "Suggested anonymously" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Copiato" + "value" : "Sugerido de forma anónima" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Copiado", - "state" : "translated" + "state" : "translated", + "value" : "Suggéré anonymement" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Copied", - "state" : "translated" + "state" : "translated", + "value" : "Suggerito anonimamente" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gekopieerd", - "state" : "translated" + "state" : "translated", + "value" : "匿名で提案されました" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Copié" + "value" : "Anoniem voorgesteld" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "コピー済み", - "state" : "translated" + "state" : "translated", + "value" : "Sugerido anonimamente" } }, "sv" : { "stringUnit" : { - "value" : "Kopierad", - "state" : "translated" + "state" : "translated", + "value" : "Föreslagen anonymt" } } } }, - "You are an expert software engineer. Help with code, explain concepts clearly, suggest best practices, and provide working code examples. Always prefer readable and maintainable solutions." : { + "Suggested by" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Sie sind ein erfahrener Softwareingenieur. Helfen Sie bei Code, erklären Sie Konzepte klar, schlagen Sie Best Practices vor und liefern Sie funktionierende Codebeispiele. Bevorzugen Sie stets lesbare und wartbare Lösungen.", - "state" : "translated" + "state" : "translated", + "value" : "Vorgeschlagen von" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "あなたは熟練のソフトウェアエンジニアです。コードの支援、概念の明確な説明、ベストプラクティスの提案、動作するコード例の提供を行います。常に読みやすく保守しやすい解決策を優先してください。" + "value" : "Προτεινόμενο από" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Eres un ingeniero de software experto. Ayuda con el código, explica conceptos claramente, sugiere las mejores prácticas y proporciona ejemplos de código funcionales. Siempre prefiere soluciones legibles y mantenibles." + "value" : "Suggested by" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sei un esperto ingegnere del software. Aiuta con il codice, spiega i concetti chiaramente, suggerisci le migliori pratiche e fornisci esempi di codice funzionanti. Preferisci sempre soluzioni leggibili e manutenibili.", - "state" : "translated" + "state" : "translated", + "value" : "Sugerido por" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "És um engenheiro de software especialista. Ajuda com código, explica conceitos claramente, sugere as melhores práticas e fornece exemplos de código funcionais. Prefere sempre soluções legíveis e fáceis de manter.", - "state" : "translated" + "state" : "translated", + "value" : "Suggéré par" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "You are an expert software engineer. Help with code, explain concepts clearly, suggest best practices, and provide working code examples. Always prefer readable and maintainable solutions.", - "state" : "translated" + "state" : "translated", + "value" : "Suggerito da" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je bent een expert software-engineer. Help met code, leg concepten duidelijk uit, stel best practices voor en geef werkende codevoorbeelden. Geef altijd de voorkeur aan leesbare en onderhoudbare oplossingen.", - "state" : "translated" + "state" : "translated", + "value" : "からの提案" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vous êtes un ingénieur logiciel expert. Aidez avec le code, expliquez clairement les concepts, suggérez les meilleures pratiques et fournissez des exemples de code fonctionnels. Privilégiez toujours des solutions lisibles et maintenables." + "value" : "Voorgesteld door" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Είστε έμπειρος μηχανικός λογισμικού. Βοηθήστε με κώδικα, εξηγήστε έννοιες με σαφήνεια, προτείνετε βέλτιστες πρακτικές και παρέχετε λειτουργικά παραδείγματα κώδικα. Προτιμήστε πάντα λύσεις που είναι ευανάγνωστες και εύκολες στη συντήρηση.", - "state" : "translated" + "state" : "translated", + "value" : "Sugerido por" } }, "sv" : { "stringUnit" : { - "value" : "Du är en expertprogrammerare. Hjälp till med kod, förklara koncept tydligt, föreslå bästa praxis och ge fungerande kodexempel. Föredra alltid läsbara och underhållbara lösningar.", - "state" : "translated" + "state" : "translated", + "value" : "Föreslagen av" } } - }, - "comment" : "Prompt template content for each role type" + } }, - "Hide API Key" : { + "Suggestion" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "API-Schlüssel verbergen", - "state" : "translated" + "state" : "translated", + "value" : "Vorschlag" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Ocultar clave API" + "value" : "Πρόταση" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Dölj API-nyckel" + "value" : "Suggestion" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nascondi chiave API" + "value" : "Sugerencia" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ocultar chave API", - "state" : "translated" + "state" : "translated", + "value" : "Suggestion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Hide API Key", - "state" : "translated" + "state" : "translated", + "value" : "Suggerimento" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "API-sleutel verbergen", - "state" : "translated" + "state" : "translated", + "value" : "提案" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Masquer la clé API", - "state" : "translated" + "state" : "translated", + "value" : "Suggestie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "APIキーを隠す", - "state" : "translated" + "state" : "translated", + "value" : "Sugestão" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Απόκρυψη κλειδιού API", - "state" : "translated" + "state" : "translated", + "value" : "Förslag" } } } }, - "The backup contains duplicate identifiers." : { + "Suggestions" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "バックアップに重複した識別子が含まれています。" + "value" : "Vorschläge" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "La copia de seguridad contiene identificadores duplicados.", - "state" : "translated" + "state" : "translated", + "value" : "Προτάσεις" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Die Sicherung enthält doppelte Bezeichner." + "value" : "Suggestions" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il backup contiene identificatori duplicati." + "value" : "Sugerencias" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O backup contém identificadores duplicados.", - "state" : "translated" + "state" : "translated", + "value" : "Suggestions" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The backup contains duplicate identifiers.", - "state" : "translated" + "state" : "translated", + "value" : "Suggerimenti" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "La sauvegarde contient des identifiants en double.", - "state" : "translated" + "state" : "translated", + "value" : "提案" } }, "nl" : { "stringUnit" : { - "value" : "De back-up bevat dubbele identificaties.", - "state" : "translated" + "state" : "translated", + "value" : "Suggesties" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Η δημιουργία αντιγράφου περιέχει διπλότυπους αναγνωριστικούς κωδικούς.", - "state" : "translated" + "state" : "translated", + "value" : "Sugestões" } }, "sv" : { "stringUnit" : { - "value" : "Säkerhetskopian innehåller dubblettidentifierare.", - "state" : "translated" + "state" : "translated", + "value" : "Förslag" } } } }, - "Enable tools from MCP servers like GitHub, databases, and more to let the model work with external services." : { + "Summarize a long text" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ενεργοποιήστε εργαλεία από διακομιστές MCP όπως το GitHub, βάσεις δεδομένων και άλλα για να επιτρέψετε στο μοντέλο να συνεργάζεται με εξωτερικές υπηρεσίες." + "value" : "Einen langen Text zusammenfassen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Habilita herramientas de servidores MCP como GitHub, bases de datos y más para que el modelo trabaje con servicios externos.", - "state" : "translated" + "state" : "translated", + "value" : "Περίληψη μεγάλου κειμένου" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Aktivieren Sie Werkzeuge von MCP-Servern wie GitHub, Datenbanken und mehr, damit das Modell mit externen Diensten arbeiten kann." + "value" : "Summarize a long text" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Abilita strumenti dai server MCP come GitHub, database e altro per permettere al modello di lavorare con servizi esterni.", - "state" : "translated" + "state" : "translated", + "value" : "Resumir un texto largo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ative ferramentas dos servidores MCP como GitHub, bases de dados e mais para permitir que o modelo trabalhe com serviços externos.", - "state" : "translated" + "state" : "translated", + "value" : "Résumer un long texte" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Enable tools from MCP servers like GitHub, databases, and more to allow the model to work with external services." + "value" : "Riassumi un testo lungo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Schakel tools van MCP-servers in zoals GitHub, databases en meer om het model met externe diensten te laten werken.", - "state" : "translated" + "state" : "translated", + "value" : "長文を要約する" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Activez les outils des serveurs MCP comme GitHub, les bases de données et plus encore pour permettre au modèle de travailler avec des services externes.", - "state" : "translated" + "state" : "translated", + "value" : "Vat een lange tekst samen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "GitHubやデータベースなどのMCPサーバーのツールを有効にして、モデルが外部サービスと連携できるようにします。", - "state" : "translated" + "state" : "translated", + "value" : "Resumir um texto longo" } }, "sv" : { "stringUnit" : { - "value" : "Aktivera verktyg från MCP-servrar som GitHub, databaser med mera för att låta modellen arbeta med externa tjänster.", - "state" : "translated" + "state" : "translated", + "value" : "Sammanfatta en lång text" } } - }, - "comment" : "A description of a feature that allows the model to connect to external tools." + } }, - "%lld\/%lld" : { + "Summarizer" : { + "comment" : "Name of a prompt template that summarizes text.", "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "value" : "%1$lld\/%2$lld", - "state" : "new" + "state" : "translated", + "value" : "Zusammenfasser" } - } - }, - "shouldTranslate" : false, - "comment" : "A label showing the current character count and the maximum allowed." - }, - "Earlier" : { - "localizations" : { - "ja" : { + }, + "el" : { "stringUnit" : { - "value" : "以前", - "state" : "translated" + "state" : "translated", + "value" : "Περίληψη" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Προηγούμενα", - "state" : "translated" + "state" : "translated", + "value" : "Summarizer" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Anteriormente" + "value" : "Resumidor" } }, - "it" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Più vecchio" + "value" : "Résumé" } }, - "pt-PT" : { + "it" : { "stringUnit" : { - "value" : "Mais antigo", - "state" : "translated" + "state" : "translated", + "value" : "Sintetizzatore" } }, - "en" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Earlier" + "value" : "要約ツール" } }, "nl" : { "stringUnit" : { - "value" : "Eerder", - "state" : "translated" + "state" : "translated", + "value" : "Samenvatter" } }, - "fr" : { + "pt-PT" : { "stringUnit" : { - "value" : "Plus tôt", - "state" : "translated" - } - }, - "de" : { - "stringUnit" : { - "value" : "Früher", - "state" : "translated" + "state" : "translated", + "value" : "Sumarizador" } }, "sv" : { "stringUnit" : { - "value" : "Tidigare", - "state" : "translated" + "state" : "translated", + "value" : "Sammanfattare" } } - }, - "comment" : "Title for a section of conversation data that includes conversations older than a week." + } }, - "Creative Writer" : { + "Support" : { + "comment" : "A heading for the support options in the settings.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δημιουργικός Συγγραφέας", - "state" : "translated" + "state" : "translated", + "value" : "Support" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Escritor Creativo", - "state" : "translated" + "state" : "translated", + "value" : "Υποστήριξη" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "クリエイティブライター" + "value" : "Support" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Scrittore Creativo", - "state" : "translated" + "state" : "translated", + "value" : "Soporte" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Escritor Criativo" + "value" : "Assistance" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Creative Writer" + "value" : "Supporto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Creatief Schrijver", - "state" : "translated" + "state" : "translated", + "value" : "サポート" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Écrivain créatif", - "state" : "translated" + "state" : "translated", + "value" : "Ondersteuning" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Kreativautor", - "state" : "translated" + "state" : "translated", + "value" : "Suporte" } }, "sv" : { "stringUnit" : { - "value" : "Kreativ författare", - "state" : "translated" + "state" : "translated", + "value" : "Support" } } - }, - "comment" : "Name of the creative writing assistant prompt template." + } }, - "All" : { + "Swift uses structured concurrency with async/await..." : { + "comment" : "Text of a message preview in a conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "すべて", - "state" : "translated" + "state" : "translated", + "value" : "Swift verwendet strukturierte Nebenläufigkeit mit async/await..." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Todos" + "value" : "Η Swift χρησιμοποιεί δομημένη ασύγχρονη εκτέλεση με async/await..." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Όλα" + "value" : "Swift uses structured concurrency with async/await..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tutti", - "state" : "translated" + "state" : "translated", + "value" : "Swift usa concurrencia estructurada con async/await..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tudo", - "state" : "translated" + "state" : "translated", + "value" : "Swift utilise la concurrence structurée avec async/await..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "All", - "state" : "translated" + "state" : "translated", + "value" : "Swift utilizza la concorrenza strutturata con async/await..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Alles", - "state" : "translated" + "state" : "translated", + "value" : "Swiftはasync/awaitを使った構造化並行処理を採用しています..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tout" + "value" : "Swift gebruikt gestructureerde gelijktijdigheid met async/await..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Alle", - "state" : "translated" + "state" : "translated", + "value" : "Swift usa concorrência estruturada com async/await..." } }, "sv" : { "stringUnit" : { - "value" : "Alla", - "state" : "translated" + "state" : "translated", + "value" : "Swift använder strukturerad samtidighet med async/await..." } } } }, - "Available Servers" : { + "Swipe left to remove a tag." : { + "comment" : "A footer displayed under the list of tags.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Verfügbare Server", - "state" : "translated" + "state" : "translated", + "value" : "Nach links wischen, um ein Tag zu entfernen." } }, "el" : { "stringUnit" : { - "value" : "Διαθέσιμοι Διακομιστές", - "state" : "translated" + "state" : "translated", + "value" : "Σύρετε αριστερά για να αφαιρέσετε μια ετικέτα." } }, - "es" : { + "en" : { "stringUnit" : { - "value" : "Servidores disponibles", - "state" : "translated" + "state" : "translated", + "value" : "Swipe left to remove a tag" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Server disponibili", - "state" : "translated" + "state" : "translated", + "value" : "Desliza a la izquierda para eliminar una etiqueta" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Servidores Disponíveis" + "value" : "Faites glisser vers la gauche pour supprimer une étiquette." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Available Servers" + "value" : "Scorri a sinistra per rimuovere un tag." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Beschikbare servers", - "state" : "translated" + "state" : "translated", + "value" : "タグを削除するには左にスワイプしてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Serveurs disponibles" + "value" : "Veeg naar links om een tag te verwijderen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "利用可能なサーバー", - "state" : "translated" + "state" : "translated", + "value" : "Deslize para a esquerda para remover uma etiqueta." } }, "sv" : { "stringUnit" : { - "value" : "Tillgängliga servrar", - "state" : "translated" + "state" : "translated", + "value" : "Svep åt vänster för att ta bort en tagg." } } - }, - "comment" : "A section title for the list of MCP servers available to the user." + } }, - "Touch and hold a conversation to pin, rename, or add tags." : { + "Sync" : { + "comment" : "A heading for the sync settings.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Tippen und halten Sie eine Unterhaltung, um sie anzuheften, umzubenennen oder Tags hinzuzufügen.", - "state" : "translated" + "state" : "translated", + "value" : "Synchronisation" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Πατήστε παρατεταμένα μια συνομιλία για καρφίτσωμα, μετονομασία ή προσθήκη ετικετών." + "value" : "Συγχρονισμός" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Mantén pulsada una conversación para anclar, renombrar o agregar etiquetas." + "value" : "Sync" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tocca e tieni premuta una conversazione per fissarla, rinominarla o aggiungere tag." + "value" : "Sincronización" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Toque e mantenha uma conversa para fixar, renomear ou adicionar etiquetas.", - "state" : "translated" + "state" : "translated", + "value" : "Synchronisation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Touch and hold a conversation to pin, rename, or add tags", - "state" : "translated" + "state" : "translated", + "value" : "Sincronizzazione" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Touchez et maintenez une conversation pour l’épingler, la renommer ou ajouter des tags.", - "state" : "translated" + "state" : "translated", + "value" : "同期" } }, "nl" : { "stringUnit" : { - "value" : "Houd een gesprek ingedrukt om vast te zetten, hernoemen of tags toe te voegen.", - "state" : "translated" + "state" : "translated", + "value" : "Synchroniseren" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話を長押しして、ピン留め、名前変更、またはタグの追加を行います。", - "state" : "translated" + "state" : "translated", + "value" : "Sincronizar" } }, "sv" : { "stringUnit" : { - "value" : "Tryck och håll på en konversation för att fästa, byta namn eller lägga till taggar.", - "state" : "translated" + "state" : "translated", + "value" : "Synkronisering" } } - }, - "comment" : "A description of the action to pin, rename, or add tags to a conversation." + } }, - "Save to Downloads" : { + "Sync conversations across your devices via iCloud." : { + "comment" : "A description of the iCloud sync feature.", + "extractionState" : "stale", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Spara till Hämtade filer" + "value" : "Synchronisiere Unterhaltungen über deine Geräte hinweg via iCloud." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Guardar en Descargas", - "state" : "translated" + "state" : "translated", + "value" : "Συγχρονίστε τις συνομιλίες σας σε όλες τις συσκευές μέσω iCloud." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "In Downloads speichern" + "value" : "Sync conversations across your devices via iCloud." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Salva in Download", - "state" : "translated" + "state" : "translated", + "value" : "Sincroniza conversaciones entre tus dispositivos mediante iCloud." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Guardar em Transferências", - "state" : "translated" + "state" : "translated", + "value" : "Synchronisez les conversations sur vos appareils via iCloud." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Save to Downloads", - "state" : "translated" + "state" : "translated", + "value" : "Sincronizza le conversazioni tra i tuoi dispositivi tramite iCloud." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Opslaan in Downloads", - "state" : "translated" + "state" : "translated", + "value" : "iCloudを使ってデバイス間で会話を同期します。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Enregistrer dans Téléchargements" + "value" : "Synchroniseer gesprekken op al je apparaten via iCloud." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ダウンロードに保存", - "state" : "translated" + "state" : "translated", + "value" : "Sincronize conversas entre os seus dispositivos via iCloud." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αποθήκευση στους Λήψεις", - "state" : "translated" + "state" : "translated", + "value" : "Synkronisera konversationer mellan dina enheter via iCloud." } } - }, - "comment" : "A label for saving an image to the user's Downloads folder." + } }, - "Rate the App" : { + "Sync conversations, personal context, memory, and prompt templates across your devices via iCloud." : { + "comment" : "A description of the iCloud sync feature.", + "isCommentAutoGenerated" : true + }, + "Sync could not finish. Your changes remain safely stored on this device." : { + "comment" : "A description of a failed iCloud sync.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { - "value" : "App bewerten", - "state" : "translated" + "state" : "translated", + "value" : "Die Synchronisierung konnte nicht abgeschlossen werden. Ihre Änderungen sind sicher auf diesem Gerät gespeichert." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Calificar la app", - "state" : "translated" + "state" : "translated", + "value" : "Ο συγχρονισμός δεν ολοκληρώθηκε. Οι αλλαγές σας παραμένουν αποθηκευμένες με ασφάλεια σε αυτή τη συσκευή." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "アプリを評価する" + "value" : "Sync could not complete. Your changes are safely stored on this device." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Valuta l’app" + "value" : "La sincronización no pudo completarse. Tus cambios permanecen guardados de forma segura en este dispositivo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Avaliar a App", - "state" : "translated" + "state" : "translated", + "value" : "La synchronisation n’a pas pu se terminer. Vos modifications restent en sécurité sur cet appareil." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Rate the App", - "state" : "translated" + "state" : "translated", + "value" : "La sincronizzazione non è riuscita. Le tue modifiche sono comunque salvate in modo sicuro su questo dispositivo." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Évaluer l’application", - "state" : "translated" + "state" : "translated", + "value" : "同期を完了できませんでした。変更内容はこのデバイスに安全に保存されています。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Beoordeel de app" + "value" : "Synchronisatie kon niet worden voltooid. Je wijzigingen zijn veilig opgeslagen op dit apparaat." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Βαθμολογήστε την εφαρμογή", - "state" : "translated" + "state" : "translated", + "value" : "A sincronização não pôde ser concluída. As suas alterações permanecem guardadas com segurança neste dispositivo." } }, "sv" : { "stringUnit" : { - "value" : "Betygsätt appen", - "state" : "translated" + "state" : "translated", + "value" : "Synkroniseringen kunde inte slutföras. Dina ändringar är säkert sparade på den här enheten." } } } }, - "tag.web.search" : { + "Sync Now" : { + "comment" : "A button that triggers a sync of conversations.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Jetzt synchronisieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Συγχρονισμός τώρα" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Web Search" + "value" : "Sync Now" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Web Search" + "value" : "Sincronizar ahora" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Synchroniser maintenant" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Sincronizza ora" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "今すぐ同期" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Web Search" + "value" : "Nu synchroniseren" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Sincronizar Agora" } }, "sv" : { "stringUnit" : { - "value" : "Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Synkronisera nu" } } - }, - "comment" : "Label for a capability that allows the model to perform web searches." + } }, - "Cancel" : { + "Synchronizing..." : { + "comment" : "A message displayed when synchronizing.", + "isCommentAutoGenerated" : true + }, + "System Prompt" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Abbrechen", - "state" : "translated" + "state" : "translated", + "value" : "Systemaufforderung" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cancelar", - "state" : "translated" + "state" : "translated", + "value" : "Προτροπή συστήματος" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Avbryt" + "value" : "System Prompt" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Annulla", - "state" : "translated" + "state" : "translated", + "value" : "Mensaje del sistema" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Cancelar" + "value" : "Invite système" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Cancel", - "state" : "translated" + "state" : "translated", + "value" : "Prompt di sistema" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Annuler", - "state" : "translated" - } + "state" : "translated", + "value" : "システムプロンプト" + } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Annuleren" + "value" : "Systeemprompt" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ακύρωση", - "state" : "translated" + "state" : "translated", + "value" : "Prompt do Sistema" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "キャンセル", - "state" : "translated" + "state" : "translated", + "value" : "Systemprompt" } } } }, - "Copy URL" : { + "Tag" : { + "comment" : "Label for the tag selection in the conversations widget.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Αντιγραφή URL" + "value" : "Tag" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Copiar URL" + "value" : "Ετικέτα" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "URL kopieren" + "value" : "Tag" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Copia URL", - "state" : "translated" + "state" : "translated", + "value" : "Etiqueta" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Copiar URL", - "state" : "translated" + "state" : "translated", + "value" : "Étiquette" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Copy URL", - "state" : "translated" + "state" : "translated", + "value" : "Tag" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Copier l’URL", - "state" : "translated" + "state" : "translated", + "value" : "タグ" } }, "nl" : { "stringUnit" : { - "value" : "URL kopiëren", - "state" : "translated" + "state" : "translated", + "value" : "Label" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "URLをコピー", - "state" : "translated" + "state" : "translated", + "value" : "Etiqueta" } }, "sv" : { "stringUnit" : { - "value" : "Kopiera URL", - "state" : "translated" + "state" : "translated", + "value" : "Tagg" } } } }, - "Accepted" : { + "tag.audio" : { + "comment" : "Label for the audio input capability.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "承認済み", - "state" : "translated" + "state" : "translated", + "value" : "Audio" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Aceptado" + "value" : "Audio" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Akzeptiert" + "value" : "Audio" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Accettato", - "state" : "translated" + "state" : "translated", + "value" : "Audio" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Aceite", - "state" : "translated" + "state" : "translated", + "value" : "Audio" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Accepted", - "state" : "translated" + "state" : "translated", + "value" : "Audio" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Accepté", - "state" : "translated" + "state" : "translated", + "value" : "Audio" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geaccepteerd" + "value" : "Audio" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αποδεκτό", - "state" : "translated" + "state" : "translated", + "value" : "Audio" } }, "sv" : { "stringUnit" : { - "value" : "Accepterad", - "state" : "translated" + "state" : "translated", + "value" : "Audio" } } } }, - "Document" : { + "tag.image.generation" : { + "comment" : "Label for the image generation capability.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ドキュメント", - "state" : "translated" + "state" : "translated", + "value" : "Image" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Documento", - "state" : "translated" + "state" : "translated", + "value" : "Image" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Έγγραφο" + "value" : "Image" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Documento", - "state" : "translated" + "state" : "translated", + "value" : "Image" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Documento", - "state" : "translated" + "state" : "translated", + "value" : "Image" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Document" + "value" : "Image" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Document", - "state" : "translated" + "state" : "translated", + "value" : "Image" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Document" + "value" : "Image" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Dokument", - "state" : "translated" + "state" : "translated", + "value" : "Image" } }, "sv" : { "stringUnit" : { - "value" : "Dokument", - "state" : "translated" + "state" : "translated", + "value" : "Image" } } } }, - "Tag" : { - "comment" : "Label for the tag selection in the conversations widget.", + "tag.JSON.mode" : { + "comment" : "Label for a capability that uses JSON schemas.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Tag" + "value" : "JSON Mode" } }, - "pt-PT" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Etiqueta" + "value" : "JSON Mode" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tag" + "value" : "JSON Mode" } }, - "nl" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Label" + "value" : "JSON Mode" } }, - "ja" : { + "fr" : { "stringUnit" : { - "value" : "タグ", - "state" : "translated" + "state" : "translated", + "value" : "JSON Mode" } }, - "fr" : { + "it" : { "stringUnit" : { - "value" : "Étiquette", - "state" : "translated" + "state" : "translated", + "value" : "JSON Mode" } }, - "it" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Tag" + "value" : "JSON Mode" } }, - "sv" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Tagg" + "value" : "JSON Mode" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ετικέτα", - "state" : "translated" + "state" : "translated", + "value" : "JSON Mode" } }, - "es" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Etiqueta" + "value" : "JSON Mode" } } } }, - "Support" : { + "tag.parallel.tools" : { + "comment" : "Label for a capability that allows parallel function calls.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Υποστήριξη", - "state" : "translated" + "state" : "translated", + "value" : "Parallel Tools" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Soporte", - "state" : "translated" + "state" : "translated", + "value" : "Parallel Tools" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "サポート" + "value" : "Parallel Tools" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Supporto" + "value" : "Parallel Tools" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Suporte" + "value" : "Parallel Tools" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Support", - "state" : "translated" + "state" : "translated", + "value" : "Parallel Tools" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Ondersteuning", - "state" : "translated" + "state" : "translated", + "value" : "Parallel Tools" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Assistance", - "state" : "translated" + "state" : "translated", + "value" : "Parallel Tools" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Support", - "state" : "translated" + "state" : "translated", + "value" : "Parallel Tools" } }, "sv" : { "stringUnit" : { - "value" : "Support", - "state" : "translated" + "state" : "translated", + "value" : "Parallel Tools" } } - }, - "comment" : "A heading for the support options in the settings." + } }, - "New Tag" : { + "tag.text" : { + "comment" : "Label for a text-related capability of an LLM model.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ny tagg" + "value" : "Text" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nueva etiqueta", - "state" : "translated" + "state" : "translated", + "value" : "Text" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "新しいタグ" + "value" : "Text" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuovo tag", - "state" : "translated" + "state" : "translated", + "value" : "Text" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nova Etiqueta", - "state" : "translated" + "state" : "translated", + "value" : "Text" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "New Tag" + "value" : "Text" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nieuwe tag", - "state" : "translated" + "state" : "translated", + "value" : "Text" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Nouveau tag", - "state" : "translated" + "state" : "translated", + "value" : "Text" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Neues Tag", - "state" : "translated" + "state" : "translated", + "value" : "Text" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Νέα ετικέτα", - "state" : "translated" + "state" : "translated", + "value" : "Text" } } - }, - "comment" : "A label displayed above a text field to add a new tag." + } }, - "Search Chats" : { + "tag.thinking" : { + "comment" : "Label for a capability that allows the LLM to think and generate complex responses.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αναζήτηση συνομιλιών", - "state" : "translated" + "state" : "translated", + "value" : "Thinking" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Buscar chats", - "state" : "translated" + "state" : "translated", + "value" : "Thinking" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sök chattar" + "value" : "Thinking" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Cerca chat" + "value" : "Thinking" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Pesquisar Conversas", - "state" : "translated" + "state" : "translated", + "value" : "Thinking" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Search Chats", - "state" : "translated" + "state" : "translated", + "value" : "Thinking" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Rechercher dans les discussions", - "state" : "translated" + "state" : "translated", + "value" : "Thinking" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Zoek chats" + "value" : "Thinking" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Chats durchsuchen", - "state" : "translated" + "state" : "translated", + "value" : "Thinking" } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "チャットを検索", - "state" : "translated" + "state" : "translated", + "value" : "Thinking" } } } }, - "Sync conversations across your devices via iCloud." : { + "tag.tools" : { + "comment" : "Label for a capability that allows calling functions in other tools.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Synchronisiere Unterhaltungen über deine Geräte hinweg via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Tools" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sincroniza conversaciones entre tus dispositivos mediante iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Tools" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Συγχρονίστε τις συνομιλίες σας σε όλες τις συσκευές μέσω iCloud." + "value" : "Tools" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Sincronizza le conversazioni tra i tuoi dispositivi tramite iCloud." + "value" : "Tools" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sincronize conversas entre os seus dispositivos via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Tools" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Sync conversations across your devices via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Tools" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Synchronisez les conversations sur vos appareils via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Tools" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Synchroniseer gesprekken op al je apparaten via iCloud." + "value" : "Tools" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "iCloudを使ってデバイス間で会話を同期します。", - "state" : "translated" + "state" : "translated", + "value" : "Tools" } }, "sv" : { "stringUnit" : { - "value" : "Synkronisera konversationer mellan dina enheter via iCloud.", - "state" : "translated" + "state" : "translated", + "value" : "Tools" } } - }, - "comment" : "A description of the iCloud sync feature." + } }, - "Sync Now" : { + "tag.vision" : { + "comment" : "Label for the \"Vision\" capability.", "localizations" : { - "ja" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vision" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vision" + } + }, + "en" : { "stringUnit" : { "state" : "translated", - "value" : "今すぐ同期" + "value" : "Vision" } }, "es" : { "stringUnit" : { - "value" : "Sincronizar ahora", - "state" : "translated" + "state" : "translated", + "value" : "Vision" } }, - "sv" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Synkronisera nu" + "value" : "Vision" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Sincronizza ora" - } - }, - "pt-PT" : { - "stringUnit" : { - "value" : "Sincronizar Agora", - "state" : "translated" + "value" : "Vision" } }, - "en" : { + "ja" : { "stringUnit" : { - "value" : "Sync Now", - "state" : "translated" + "state" : "translated", + "value" : "Vision" } }, "nl" : { "stringUnit" : { - "value" : "Nu synchroniseren", - "state" : "translated" - } - }, - "fr" : { - "stringUnit" : { - "value" : "Synchroniser maintenant", - "state" : "translated" + "state" : "translated", + "value" : "Vision" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Συγχρονισμός τώρα", - "state" : "translated" + "state" : "translated", + "value" : "Vision" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Jetzt synchronisieren", - "state" : "translated" + "state" : "translated", + "value" : "Vision" } } - }, - "comment" : "A button that triggers a sync of conversations." + } }, - "New Template" : { + "tag.web.search" : { + "comment" : "Label for a capability that allows the model to perform web searches.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "新しいテンプレート" + "value" : "Web Search" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nueva plantilla", - "state" : "translated" + "state" : "translated", + "value" : "Web Search" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Νέο Πρότυπο" + "value" : "Web Search" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuovo Modello", - "state" : "translated" + "state" : "translated", + "value" : "Web Search" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Novo Modelo", - "state" : "translated" + "state" : "translated", + "value" : "Web Search" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New Template", - "state" : "translated" + "state" : "translated", + "value" : "Web Search" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nouveau modèle", - "state" : "translated" + "state" : "translated", + "value" : "Web Search" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nieuwe sjabloon" + "value" : "Web Search" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Neue Vorlage", - "state" : "translated" + "state" : "translated", + "value" : "Web Search" } }, "sv" : { "stringUnit" : { - "value" : "Ny mall", - "state" : "translated" + "state" : "translated", + "value" : "Web Search" } } - }, - "comment" : "A title for a view that creates or edits a prompt template." + } }, - "No conversations yet" : { + "Tagged Conversations" : { + "comment" : "Title of the widget configuration intent.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Inga konversationer än så länge" + "value" : "Markierte Unterhaltungen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No hay conversaciones aún", - "state" : "translated" + "state" : "translated", + "value" : "Επισημασμένες Συνομιλίες" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Noch keine Unterhaltungen vorhanden" + "value" : "Tagged Conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessuna conversazione ancora", - "state" : "translated" + "state" : "translated", + "value" : "Conversaciones Etiquetadas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ainda sem conversas", - "state" : "translated" + "state" : "translated", + "value" : "Conversations étiquetées" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No conversations yet", - "state" : "translated" + "state" : "translated", + "value" : "Conversazioni taggate" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Aucune conversation pour le moment", - "state" : "translated" + "state" : "translated", + "value" : "タグ付き会話" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nog geen gesprekken" + "value" : "Gemerkt Gesprekken" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "まだ会話はありません", - "state" : "translated" + "state" : "translated", + "value" : "Conversas Marcadas" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Δεν υπάρχουν συνομιλίες ακόμα", - "state" : "translated" + "state" : "translated", + "value" : "Taggade konversationer" } } - }, - "comment" : "A message displayed when the user has no conversations." + } }, - "Scroll the share sheet and tap **OpenClient**." : { + "Tags" : { + "comment" : "A heading displayed above the user's tags.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Bläddra i delningsmenyn och tryck på **OpenClient**.", - "state" : "translated" + "state" : "translated", + "value" : "Tags" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Desplaza la hoja para compartir y toca **OpenClient**.", - "state" : "translated" + "state" : "translated", + "value" : "Ετικέτες" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "共有シートをスクロールして**OpenClient**をタップしてください。" + "value" : "Tags" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Scorri il foglio di condivisione e tocca **OpenClient**.", - "state" : "translated" + "state" : "translated", + "value" : "Etiquetas" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Desloque a folha de partilha e toque em **OpenClient**." + "value" : "Étiquettes" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Scroll the share sheet and tap **OpenClient**." + "value" : "Tag" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Scroll door het deelvenster en tik op **OpenClient**.", - "state" : "translated" + "state" : "translated", + "value" : "タグ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Faites défiler la feuille de partage et appuyez sur **OpenClient**.", - "state" : "translated" + "state" : "translated", + "value" : "Tags" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Κύλιση στο φύλλο κοινής χρήσης και πατήστε **OpenClient**.", - "state" : "translated" + "state" : "translated", + "value" : "Etiquetas" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Blättern Sie im Freigabeblatt und tippen Sie auf **OpenClient**.", - "state" : "translated" + "state" : "translated", + "value" : "Taggar" } } } }, - "Summarizer" : { + "Tap + to create your first custom prompt template." : { + "comment" : "A description of the action to create a custom prompt template.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Περίληψη" + "value" : "Tippe auf +, um deine erste benutzerdefinierte Eingabevorlage zu erstellen." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Resumidor" + "value" : "Πατήστε + για να δημιουργήσετε το πρώτο σας προσαρμοσμένο πρότυπο προτροπής." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "要約ツール" + "value" : "Tap + to create your first custom prompt template" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sintetizzatore", - "state" : "translated" + "state" : "translated", + "value" : "Toca + para crear tu primera plantilla de indicación personalizada." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sumarizador", - "state" : "translated" + "state" : "translated", + "value" : "Touchez + pour créer votre premier modèle d’invite personnalisé." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Summarizer", - "state" : "translated" + "state" : "translated", + "value" : "Tocca + per creare il tuo primo modello di prompt personalizzato." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Samenvatter", - "state" : "translated" + "state" : "translated", + "value" : "+ をタップして最初のカスタムプロンプトテンプレートを作成してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Résumé", - "state" : "translated" + "state" : "translated", + "value" : "Tik op + om je eerste aangepaste promptsjabloon te maken." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Zusammenfasser", - "state" : "translated" + "state" : "translated", + "value" : "Toque em + para criar o seu primeiro modelo de prompt personalizado." } }, "sv" : { "stringUnit" : { - "value" : "Sammanfattare", - "state" : "translated" + "state" : "translated", + "value" : "Tryck på + för att skapa din första anpassade promptmall." } } - }, - "comment" : "Name of a prompt template that summarizes text." + } }, - "Browse Library" : { + "Tap + to get started" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ライブラリを参照", - "state" : "translated" + "state" : "translated", + "value" : "Tippe auf +, um zu beginnen" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Explorar biblioteca" + "value" : "Πατήστε + για να ξεκινήσετε" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bläddra i biblioteket" + "value" : "Tap + to get started" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sfoglia Libreria", - "state" : "translated" + "state" : "translated", + "value" : "Toca + para comenzar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Explorar Biblioteca", - "state" : "translated" + "state" : "translated", + "value" : "Appuyez sur + pour commencer" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Browse Library", - "state" : "translated" + "state" : "translated", + "value" : "Tocca + per iniziare" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bibliotheek bladeren", - "state" : "translated" + "state" : "translated", + "value" : "開始するには+をタップしてください" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Parcourir la bibliothèque" + "value" : "Tik op + om te beginnen" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Bibliothek durchsuchen", - "state" : "translated" + "state" : "translated", + "value" : "Toque + para começar" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Περιήγηση στη Βιβλιοθήκη", - "state" : "translated" + "state" : "translated", + "value" : "Tryck på + för att börja" } } - }, - "comment" : "A button that opens a library of pre-made system prompts." + } }, - "No comments yet. Be the first to comment!" : { + "Tap the Share button in any app." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Inga kommentarer än. Var den första att kommentera!" + "value" : "Tippen Sie in einer beliebigen App auf die Teilen-Taste." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Aún no hay comentarios. ¡Sé el primero en comentar!", - "state" : "translated" + "state" : "translated", + "value" : "Πατήστε το κουμπί Κοινή χρήση σε οποιαδήποτε εφαρμογή." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "まだコメントはありません。最初のコメントを投稿しましょう!" + "value" : "Tap the Share button in any app." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nessun commento ancora. Sii il primo a commentare!", - "state" : "translated" + "state" : "translated", + "value" : "Toca el botón Compartir en cualquier app." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ainda sem comentários. Seja o primeiro a comentar!", - "state" : "translated" + "state" : "translated", + "value" : "Appuyez sur le bouton Partager dans n’importe quelle application." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "No comments yet. Be the first to comment!" + "value" : "Tocca il pulsante Condividi in qualsiasi app." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nog geen reacties. Wees de eerste die reageert!", - "state" : "translated" + "state" : "translated", + "value" : "任意のアプリで共有ボタンをタップしてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Pas encore de commentaires. Soyez le premier à commenter !", - "state" : "translated" + "state" : "translated", + "value" : "Tik op de Deel-knop in een app." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Noch keine Kommentare. Sei der Erste, der kommentiert!", - "state" : "translated" + "state" : "translated", + "value" : "Toque no botão Partilhar em qualquer aplicação." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Δεν υπάρχουν σχόλια ακόμα. Γίνε ο πρώτος που θα σχολιάσει!", - "state" : "translated" + "state" : "translated", + "value" : "Tryck på dela-knappen i valfri app." } } } }, - "%lld." : { + "Tap to return to your conversation." : { + "comment" : "Text displayed in a conversation card when there is no conversation to show.", "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "value" : "%lld.", - "state" : "translated" + "state" : "translated", + "value" : "Tippen, um zu Ihrer Unterhaltung zurückzukehren." } - } - }, - "shouldTranslate" : false, - "comment" : "A label that shows the index of a search result. The argument is the index of the search result." - }, - "Edit & Resend" : { - "localizations" : { + }, "el" : { "stringUnit" : { - "value" : "Επεξεργασία & Αποστολή ξανά", - "state" : "translated" + "state" : "translated", + "value" : "Πατήστε για να επιστρέψετε στη συνομιλία σας." } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "編集して再送信", - "state" : "translated" + "state" : "translated", + "value" : "Tap to return to your conversation" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Editar y reenviar" + "value" : "Toca para volver a tu conversación." } }, - "it" : { + "fr" : { "stringUnit" : { - "value" : "Modifica e rinvia", - "state" : "translated" + "state" : "translated", + "value" : "Touchez pour revenir à votre conversation." } }, - "pt-PT" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Editar e Reenviar" + "value" : "Tocca per tornare alla tua conversazione." } }, - "en" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Edit & Resend" + "value" : "会話に戻るにはタップしてください" } }, "nl" : { "stringUnit" : { - "value" : "Bewerken & Opnieuw verzenden", - "state" : "translated" - } - }, - "fr" : { - "stringUnit" : { - "value" : "Modifier et renvoyer", - "state" : "translated" + "state" : "translated", + "value" : "Tik om terug te keren naar je gesprek." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Bearbeiten & erneut senden", - "state" : "translated" + "state" : "translated", + "value" : "Toque para voltar à sua conversa." } }, "sv" : { "stringUnit" : { - "value" : "Redigera och skicka igen", - "state" : "translated" + "state" : "translated", + "value" : "Tryck för att återvända till din konversation." } } - }, - "comment" : "A label for editing and resending a chat message." + } }, - "Open Source" : { + "Teal" : { + "comment" : "Name of the color teal.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Open Source", - "state" : "translated" + "state" : "translated", + "value" : "Blaugrün" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Código abierto" + "value" : "Τιρκουάζ" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ανοιχτού Κώδικα" + "value" : "Teal" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Open Source" + "value" : "Verde azulado" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Código Aberto", - "state" : "translated" + "state" : "translated", + "value" : "Sarcelle" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Open Source", - "state" : "translated" + "state" : "translated", + "value" : "Turchese" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Open source", - "state" : "translated" + "state" : "translated", + "value" : "ティール" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Open source", - "state" : "translated" + "state" : "translated", + "value" : "Blauwgroen" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "オープンソース", - "state" : "translated" + "state" : "translated", + "value" : "Verde-azulado" } }, "sv" : { "stringUnit" : { - "value" : "Öppen källkod", - "state" : "translated" + "state" : "translated", + "value" : "Blågrön" } } - }, - "comment" : "A feature of the onboarding view." + } }, - "Name" : { + "Temperature" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Όνομα", - "state" : "translated" + "state" : "translated", + "value" : "Temperatur" } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "名前" + "value" : "Θερμοκρασία" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nombre" + "value" : "Temperature" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nome" + "value" : "Temperatura" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nome", - "state" : "translated" + "state" : "translated", + "value" : "Température" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Name", - "state" : "translated" + "state" : "translated", + "value" : "Temperatura" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Naam", - "state" : "translated" + "state" : "translated", + "value" : "温度" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Nom", - "state" : "translated" + "state" : "translated", + "value" : "Temperatuur" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Name", - "state" : "translated" + "state" : "translated", + "value" : "Temperatura" } }, "sv" : { "stringUnit" : { - "value" : "Namn", - "state" : "translated" + "state" : "translated", + "value" : "Temperatur" } } - }, - "comment" : "A label displayed above the user's name." + } }, - "You are a creative writing assistant. Help craft engaging stories, characters, dialogue, and descriptions. Offer imaginative ideas, vivid imagery, and compelling narrative structure tailored to the user's style and genre." : { + "Terms of Use" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Είστε βοηθός δημιουργικής γραφής. Βοηθήστε στη δημιουργία συναρπαστικών ιστοριών, χαρακτήρων, διαλόγων και περιγραφών. Προσφέρετε φανταστικές ιδέες, ζωντανές εικόνες και ελκυστική δομή αφήγησης προσαρμοσμένη στο ύφος και το είδος του χρήστη.", - "state" : "translated" + "state" : "translated", + "value" : "Nutzungsbedingungen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eres un asistente de escritura creativa. Ayuda a crear historias, personajes, diálogos y descripciones atractivas. Ofrece ideas imaginativas, imágenes vívidas y una estructura narrativa convincente adaptada al estilo y género del usuario.", - "state" : "translated" + "state" : "translated", + "value" : "Όροι Χρήσης" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "あなたはクリエイティブライティングアシスタントです。魅力的な物語、キャラクター、対話、描写の作成を支援します。ユーザーのスタイルやジャンルに合わせて、想像力豊かなアイデア、生き生きとしたイメージ、説得力のある物語構成を提供します。" + "value" : "Terms of Use" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sei un assistente di scrittura creativa. Aiuta a creare storie coinvolgenti, personaggi, dialoghi e descrizioni. Offri idee immaginative, immagini vivide e una struttura narrativa avvincente, adattata allo stile e al genere dell’utente.", - "state" : "translated" + "state" : "translated", + "value" : "Términos de uso" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "És um assistente de escrita criativa. Ajuda a criar histórias envolventes, personagens, diálogos e descrições. Oferece ideias imaginativas, imagens vívidas e uma estrutura narrativa cativante adaptada ao estilo e género do utilizador." + "value" : "Conditions d’utilisation" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "You are a creative writing assistant. Help craft engaging stories, characters, dialogue, and descriptions. Offer imaginative ideas, vivid imagery, and compelling narrative structure tailored to the user's style and genre.", - "state" : "translated" + "state" : "translated", + "value" : "Termini di utilizzo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Je bent een assistent voor creatief schrijven. Help bij het bedenken van boeiende verhalen, personages, dialogen en beschrijvingen. Bied fantasierijke ideeën, levendige beelden en een meeslepende verhaallijn die aansluit bij de stijl en het genre van de gebruiker.", - "state" : "translated" + "state" : "translated", + "value" : "利用規約" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vous êtes un assistant d’écriture créative. Aidez à concevoir des histoires captivantes, des personnages, des dialogues et des descriptions. Proposez des idées imaginatives, des images vivantes et une structure narrative convaincante adaptée au style et au genre de l’utilisateur." + "value" : "Gebruiksvoorwaarden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Du bist ein kreativer Schreibassistent. Hilf dabei, fesselnde Geschichten, Charaktere, Dialoge und Beschreibungen zu gestalten. Biete einfallsreiche Ideen, lebendige Bilder und eine überzeugende Erzählstruktur, die auf den Stil und das Genre des Nutzers zugeschnitten sind.", - "state" : "translated" + "state" : "translated", + "value" : "Termos de Utilização" } }, "sv" : { "stringUnit" : { - "value" : "Du är en kreativ skrivassistent. Hjälp till att skapa engagerande berättelser, karaktärer, dialoger och beskrivningar. Erbjud fantasifulla idéer, levande bilder och en fängslande berättarstruktur anpassad efter användarens stil och genre.", - "state" : "translated" + "state" : "translated", + "value" : "Användarvillkor" } } - }, - "comment" : "Description of the creative writing assistant role." + } }, - "Privacy First" : { + "Test Connection" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "プライバシー最優先", - "state" : "translated" + "state" : "translated", + "value" : "Verbindung testen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Privacidad ante todo", - "state" : "translated" + "state" : "translated", + "value" : "Δοκιμή σύνδεσης" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Datenschutz zuerst" + "value" : "Test Connection" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Privacy prima di tutto", - "state" : "translated" + "state" : "translated", + "value" : "Probar conexión" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Privacidade em Primeiro Lugar" + "value" : "Tester la connexion" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Privacy First", - "state" : "translated" + "state" : "translated", + "value" : "Testa connessione" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Privacy eerst", - "state" : "translated" + "state" : "translated", + "value" : "接続をテスト" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Confidentialité prioritaire" + "value" : "Verbinding testen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προτεραιότητα στην ιδιωτικότητα", - "state" : "translated" + "state" : "translated", + "value" : "Testar ligação" } }, "sv" : { "stringUnit" : { - "value" : "Sekretess i första hand", - "state" : "translated" + "state" : "translated", + "value" : "Testa anslutning" } } - }, - "comment" : "A description of the privacy features of OpenClient." + } }, - "Notifications not authorized" : { + "Testing..." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Οι ειδοποιήσεις δεν έχουν εξουσιοδοτηθεί", - "state" : "translated" + "state" : "translated", + "value" : "Testen..." } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Benachrichtigungen nicht erlaubt" + "value" : "Δοκιμή..." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notificaciones no autorizadas" + "value" : "Testing..." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Notifiche non autorizzate" + "value" : "Probando..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Notificações não autorizadas", - "state" : "translated" + "state" : "translated", + "value" : "Test en cours..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Notifications not authorized", - "state" : "translated" + "state" : "translated", + "value" : "Test in corso..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Meldingen niet toegestaan", - "state" : "translated" + "state" : "translated", + "value" : "テスト中..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Notifications non autorisées", - "state" : "translated" + "state" : "translated", + "value" : "Testen..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "通知が許可されていません", - "state" : "translated" + "state" : "translated", + "value" : "A testar..." } }, "sv" : { "stringUnit" : { - "value" : "Aviseringar inte godkända", - "state" : "translated" + "state" : "translated", + "value" : "Testar..." } } - }, - "comment" : "A label that indicates that the app has not yet been authorized to send notifications." + } }, - "Export Backup" : { + "Text to Speech" : { + "comment" : "A section title for a list of text-to-speech models.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εξαγωγή αντιγράφου ασφαλείας", - "state" : "translated" + "state" : "translated", + "value" : "Text-zu-Sprache" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Exportar copia de seguridad", - "state" : "translated" + "state" : "translated", + "value" : "Κείμενο σε Ομιλία" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Backup exportieren" + "value" : "Text to Speech" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Esporta backup", - "state" : "translated" + "state" : "translated", + "value" : "Texto a voz" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Exportar Cópia de Segurança" + "value" : "Synthèse vocale" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Export Backup", - "state" : "translated" + "state" : "translated", + "value" : "Sintesi vocale" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Back-up exporteren", - "state" : "translated" + "state" : "translated", + "value" : "テキスト読み上げ" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Exporter la sauvegarde" + "value" : "Tekst-naar-spraak" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "バックアップをエクスポート", - "state" : "translated" + "state" : "translated", + "value" : "Texto para Fala" } }, "sv" : { "stringUnit" : { - "value" : "Exportera säkerhetskopia", - "state" : "translated" + "state" : "translated", + "value" : "Text-till-tal" } } } }, - "Enter a positive whole number of input tokens." : { + "Thank you! ☕" : { + "comment" : "A title for a system alert that appears after a user purchases a tip.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ange ett positivt heltal för inmatningstoken." + "value" : "Danke! ☕" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Introduce un número entero positivo de tokens de entrada.", - "state" : "translated" + "state" : "translated", + "value" : "Ευχαριστούμε! ☕" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Geben Sie eine positive ganze Zahl der Eingabetoken ein." + "value" : "Thank you! ☕" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Inserisci un numero intero positivo di token di input.", - "state" : "translated" + "state" : "translated", + "value" : "¡Gracias! ☕" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Introduza um número inteiro positivo de tokens de entrada.", - "state" : "translated" + "state" : "translated", + "value" : "Merci ! ☕" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Enter a positive integer number of input tokens", - "state" : "translated" + "state" : "translated", + "value" : "Grazie! ☕" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Entrez un nombre entier positif de jetons d’entrée.", - "state" : "translated" + "state" : "translated", + "value" : "ありがとうございます!☕" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Voer een positief geheel aantal invoertokens in." + "value" : "Bedankt! ☕" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "正の整数の入力トークン数を入力してください。", - "state" : "translated" + "state" : "translated", + "value" : "Obrigado! ☕" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Εισάγετε έναν θετικό ακέραιο αριθμό εισόδων.", - "state" : "translated" + "state" : "translated", + "value" : "Tack! ☕" } } - }, - "comment" : "A description of the input tokens field." + } }, - "Set instructions for the assistant's behavior in this conversation." : { + "The agent reached its maximum number of steps." : { + "comment" : "Error message displayed when the agent has reached its maximum number of steps.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ορίστε οδηγίες για τη συμπεριφορά του βοηθού σε αυτή τη συνομιλία.", - "state" : "translated" + "state" : "translated", + "value" : "Der Agent hat die maximale Anzahl an Schritten erreicht." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Establecer instrucciones para el comportamiento del asistente en esta conversación." + "value" : "Ο πράκτορας έφτασε στον μέγιστο αριθμό βημάτων." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Anweisungen für das Verhalten des Assistenten in diesem Gespräch festlegen." + "value" : "The agent has reached its maximum number of steps." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Imposta le istruzioni per il comportamento dell'assistente in questa conversazione.", - "state" : "translated" + "state" : "translated", + "value" : "El agente alcanzó su número máximo de pasos." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Defina as instruções para o comportamento do assistente nesta conversa." + "value" : "L'agent a atteint son nombre maximal d'étapes." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Set instructions for the assistant's behavior in this conversation.", - "state" : "translated" + "state" : "translated", + "value" : "L'agente ha raggiunto il numero massimo di passi." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Définir les instructions pour le comportement de l’assistant dans cette conversation.", - "state" : "translated" + "state" : "translated", + "value" : "エージェントは最大ステップ数に達しました。" } }, "nl" : { "stringUnit" : { - "value" : "Stel instructies in voor het gedrag van de assistent in dit gesprek.", - "state" : "translated" + "state" : "translated", + "value" : "De agent heeft het maximale aantal stappen bereikt." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "この会話におけるアシスタントの動作指示を設定してください。", - "state" : "translated" + "state" : "translated", + "value" : "O agente atingiu o número máximo de passos." } }, "sv" : { "stringUnit" : { - "value" : "Ange instruktioner för assistentens beteende i denna konversation.", - "state" : "translated" + "state" : "translated", + "value" : "Agenten har nått sitt maximala antal steg." } } } }, - "Share your idea" : { + "The agent timed out before completing the response." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Dela din idé" + "value" : "Der Agent hat die Antwort nicht rechtzeitig abgeschlossen." } }, - "ja" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "アイデアを共有する" + "value" : "Ο πράκτορας διέκοψε τη σύνδεση πριν ολοκληρώσει την απάντηση." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Comparte tu idea" + "value" : "The agent timed out before completing the response." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Condividi la tua idea", - "state" : "translated" + "state" : "translated", + "value" : "El agente agotó el tiempo antes de completar la respuesta." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Partilhe a sua ideia", - "state" : "translated" + "state" : "translated", + "value" : "Le délai de réponse de l’agent a expiré avant la fin." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Share your idea", - "state" : "translated" + "state" : "translated", + "value" : "L'agente ha superato il tempo limite prima di completare la risposta." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Deel je idee", - "state" : "translated" + "state" : "translated", + "value" : "エージェントが応答を完了する前にタイムアウトしました。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Partagez votre idée", - "state" : "translated" + "state" : "translated", + "value" : "De agent heeft te lang gewacht om de reactie te voltooien." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Teile deine Idee", - "state" : "translated" + "state" : "translated", + "value" : "O agente expirou antes de concluir a resposta." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Μοιραστείτε την ιδέα σας", - "state" : "translated" + "state" : "translated", + "value" : "Agenten tog för lång tid på sig att slutföra svaret." } } } }, - "Response ready" : { + "The app opens with a new conversation pre-filled with your content." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η απάντηση είναι έτοιμη", - "state" : "translated" + "state" : "translated", + "value" : "Die App öffnet sich mit einer neuen Unterhaltung, die mit Ihrem Inhalt vorausgefüllt ist." } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Antwort bereit" + "value" : "Η εφαρμογή ανοίγει με μια νέα συνομιλία προγεμισμένη με το περιεχόμενό σας." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Respuesta lista" + "value" : "The app opens with a new conversation pre-filled with your content." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Risposta pronta" + "value" : "La app se abre con una nueva conversación prellenada con tu contenido." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Resposta pronta", - "state" : "translated" + "state" : "translated", + "value" : "L’application s’ouvre avec une nouvelle conversation préremplie avec votre contenu." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Response ready", - "state" : "translated" + "state" : "translated", + "value" : "L’app si apre con una nuova conversazione precompilata con i tuoi contenuti." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Antwoord klaar", - "state" : "translated" + "state" : "translated", + "value" : "アプリはあなたの内容が事前入力された新しい会話で開きます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Réponse prête", - "state" : "translated" + "state" : "translated", + "value" : "De app opent met een nieuw gesprek vooraf ingevuld met jouw inhoud." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "応答準備完了", - "state" : "translated" + "state" : "translated", + "value" : "A app abre com uma nova conversa preenchida com o seu conteúdo." } }, "sv" : { "stringUnit" : { - "value" : "Svar klart", - "state" : "translated" + "state" : "translated", + "value" : "Appen öppnas med en ny konversation förifylld med ditt innehåll." } } - }, - "comment" : "Title of a notification when a response is ready." + } }, - "Memory" : { + "The attachment file could not be found." : { + "comment" : "Error message when the attachment file is not found.", + "isCommentAutoGenerated" : true + }, + "The attachment file path is invalid." : { + "comment" : "Error message when the attachment file path is invalid.", + "isCommentAutoGenerated" : true + }, + "The backup contains an invalid attachment reference." : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Notizen", - "state" : "translated" + "state" : "translated", + "value" : "Die Sicherung enthält eine ungültige Anlagenreferenz." } }, "el" : { "stringUnit" : { - "value" : "Μνήμη", - "state" : "translated" + "state" : "translated", + "value" : "Η δημιουργία αντιγράφου περιέχει μη έγκυρη αναφορά συνημμένου." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Memoria" + "value" : "The backup contains an invalid attachment reference." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Memoria", - "state" : "translated" + "state" : "translated", + "value" : "La copia de seguridad contiene una referencia de archivo adjunto no válida." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Memórias", - "state" : "translated" + "state" : "translated", + "value" : "La sauvegarde contient une référence de pièce jointe invalide." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Notes" + "value" : "Il backup contiene un riferimento a un allegato non valido." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Geheugen", - "state" : "translated" + "state" : "translated", + "value" : "バックアップに無効な添付ファイル参照が含まれています。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Mémoire" + "value" : "De back-up bevat een ongeldige bijlageverwijzing." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "メモリー", - "state" : "translated" + "state" : "translated", + "value" : "A cópia de segurança contém uma referência de anexo inválida." } }, "sv" : { "stringUnit" : { - "value" : "Anteckningar", - "state" : "translated" + "state" : "translated", + "value" : "Säkerhetskopian innehåller en ogiltig bilagereferens." } } - }, - "comment" : "A title for a screen that lists and manages user-created notes." + } }, - "Enable Web Search" : { + "The backup contains duplicate identifiers." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ενεργοποίηση Αναζήτησης Ιστού", - "state" : "translated" + "state" : "translated", + "value" : "Die Sicherung enthält doppelte Bezeichner." } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Websuche aktivieren" + "value" : "Η δημιουργία αντιγράφου περιέχει διπλότυπους αναγνωριστικούς κωδικούς." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Activar búsqueda web" + "value" : "The backup contains duplicate identifiers." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Abilita ricerca web" + "value" : "La copia de seguridad contiene identificadores duplicados." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ativar Pesquisa Web", - "state" : "translated" + "state" : "translated", + "value" : "La sauvegarde contient des identifiants en double." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Enable Web Search", - "state" : "translated" + "state" : "translated", + "value" : "Il backup contiene identificatori duplicati." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Webzoekfunctie inschakelen", - "state" : "translated" + "state" : "translated", + "value" : "バックアップに重複した識別子が含まれています。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Activer la recherche Web", - "state" : "translated" + "state" : "translated", + "value" : "De back-up bevat dubbele identificaties." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ウェブ検索を有効にする", - "state" : "translated" + "state" : "translated", + "value" : "O backup contém identificadores duplicados." } }, "sv" : { "stringUnit" : { - "value" : "Aktivera webbsökning", - "state" : "translated" + "state" : "translated", + "value" : "Säkerhetskopian innehåller dubblettidentifierare." } } - }, - "comment" : "A label for a button that enables web search." + } }, - "Help" : { + "The backup file is invalid." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Hjälp", - "state" : "translated" + "state" : "translated", + "value" : "Die Sicherungsdatei ist ungültig." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Ayuda", - "state" : "translated" + "state" : "translated", + "value" : "Το αρχείο αντιγράφου ασφαλείας είναι άκυρο." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ヘルプ" + "value" : "The backup file is invalid." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aiuto", - "state" : "translated" + "state" : "translated", + "value" : "El archivo de respaldo no es válido." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ajuda", - "state" : "translated" + "state" : "translated", + "value" : "Le fichier de sauvegarde est invalide." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Help" + "value" : "Il file di backup non è valido." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Help", - "state" : "translated" + "state" : "translated", + "value" : "バックアップファイルが無効です。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Aide" + "value" : "Het back-upbestand is ongeldig." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Βοήθεια", - "state" : "translated" + "state" : "translated", + "value" : "O ficheiro de backup é inválido." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Hilfe", - "state" : "translated" + "state" : "translated", + "value" : "Säkerhetskopieringsfilen är ogiltig." } } - }, - "comment" : "The title of the help screen." + } }, - "Tap the Share button in any app." : { + "The cloud deletion could not be completed because iCloud is unavailable." : { + "comment" : "Error description for when the cloud deletion fails because iCloud is unavailable.", + "isCommentAutoGenerated" : true + }, + "The cloud deletion could not be completed." : { + "comment" : "Error message when the cloud deletion fails.", + "isCommentAutoGenerated" : true + }, + "The cloud deletion is waiting for required downloads." : { + "comment" : "Error description for when the cloud deletion is waiting for required downloads.", + "isCommentAutoGenerated" : true + }, + "The cloud operation was cancelled by an app data reset." : { + "comment" : "Error message when the cloud operation was cancelled by an app data reset.", + "isCommentAutoGenerated" : true + }, + "The conversation changed or was deleted before this save completed." : { + "comment" : "Error message when a conversation has changed or been deleted before the save completed.", + "isCommentAutoGenerated" : true + }, + "The conversation context window must be greater than zero." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Tryck på dela-knappen i valfri app." + "value" : "Das Kontextfenster der Unterhaltung muss größer als null sein." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Toca el botón Compartir en cualquier app.", - "state" : "translated" + "state" : "translated", + "value" : "Το παράθυρο συμφραζομένων συνομιλίας πρέπει να είναι μεγαλύτερο του μηδενός." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Πατήστε το κουμπί Κοινή χρήση σε οποιαδήποτε εφαρμογή." + "value" : "The conversation context window must be greater than zero." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Tocca il pulsante Condividi in qualsiasi app." + "value" : "La ventana de contexto de la conversación debe ser mayor que cero." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Toque no botão Partilhar em qualquer aplicação.", - "state" : "translated" + "state" : "translated", + "value" : "La fenêtre de contexte de la conversation doit être supérieure à zéro." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Tap the Share button in any app.", - "state" : "translated" + "state" : "translated", + "value" : "La finestra del contesto della conversazione deve essere maggiore di zero." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Appuyez sur le bouton Partager dans n’importe quelle application.", - "state" : "translated" + "state" : "translated", + "value" : "会話コンテキストウィンドウはゼロより大きくする必要があります。" } }, "nl" : { "stringUnit" : { - "value" : "Tik op de Deel-knop in een app.", - "state" : "translated" + "state" : "translated", + "value" : "Het contextvenster van het gesprek moet groter zijn dan nul." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "任意のアプリで共有ボタンをタップしてください。", - "state" : "translated" + "state" : "translated", + "value" : "A janela de contexto da conversa deve ser maior que zero." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Tippen Sie in einer beliebigen App auf die Teilen-Taste.", - "state" : "translated" + "state" : "translated", + "value" : "Samtalskontextfönstret måste vara större än noll." } } } }, - "Only images and PDFs are supported" : { + "The conversation summary and its cursor must both be present." : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Nur Bilder und PDFs werden unterstützt", - "state" : "translated" + "state" : "translated", + "value" : "Die Zusammenfassung der Unterhaltung und ihr Cursor müssen beide vorhanden sein." } }, - "en" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Only images and PDFs are supported" + "value" : "Το σύνοψη της συνομιλίας και ο δείκτης της πρέπει να υπάρχουν και τα δύο." } }, - "pt-PT" : { + "en" : { "stringUnit" : { - "value" : "Apenas imagens e PDFs são suportados", - "state" : "translated" + "state" : "translated", + "value" : "The conversation summary and its cursor must both be present." } }, - "ja" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "画像とPDFのみ対応しています" + "value" : "El resumen de la conversación y su cursor deben estar presentes." } }, - "nl" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen afbeeldingen en PDF's worden ondersteund" + "value" : "Le résumé de la conversation et son curseur doivent tous deux être présents." } }, - "fr" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Seules les images et les PDF sont pris en charge" + "value" : "Il riepilogo della conversazione e il suo cursore devono essere entrambi presenti." } }, - "it" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Sono supportate solo immagini e PDF" + "value" : "会話の要約とそのカーソルの両方が存在する必要があります。" } }, - "sv" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Endast bilder och PDF-filer stöds" + "value" : "De samenvatting van het gesprek en de cursor moeten beide aanwezig zijn." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Υποστηρίζονται μόνο εικόνες και αρχεία PDF", - "state" : "translated" + "state" : "translated", + "value" : "O resumo da conversa e o seu cursor devem estar ambos presentes." } }, - "es" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Solo se admiten imágenes y PDFs" + "value" : "Samtalssammanfattningen och dess markör måste båda vara närvarande." } } } }, - "Quick Actions" : { + "The conversation summary cursor does not reference one of its messages." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "クイックアクション", - "state" : "translated" + "state" : "translated", + "value" : "Der Zusammenfassungs-Cursor der Unterhaltung verweist nicht auf eine seiner Nachrichten." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Acciones rápidas", - "state" : "translated" + "state" : "translated", + "value" : "Ο δείκτης περίληψης συνομιλίας δεν αναφέρεται σε κάποιο από τα μηνύματά του." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Γρήγορες Ενέργειες" + "value" : "The conversation summary cursor does not reference one of its messages." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Azioni rapide", - "state" : "translated" + "state" : "translated", + "value" : "El cursor del resumen de la conversación no hace referencia a uno de sus mensajes." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ações Rápidas", - "state" : "translated" + "state" : "translated", + "value" : "Le curseur du résumé de la conversation ne fait pas référence à l’un de ses messages." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Quick Actions" + "value" : "Il cursore del riepilogo della conversazione non fa riferimento a uno dei suoi messaggi." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Actions rapides", - "state" : "translated" + "state" : "translated", + "value" : "会話の要約カーソルがメッセージのいずれかを参照していません。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Snelle acties" + "value" : "De samenvattingscursor van het gesprek verwijst niet naar een van zijn berichten." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Schnellaktionen", - "state" : "translated" + "state" : "translated", + "value" : "O cursor do resumo da conversa não referencia uma das suas mensagens." } }, "sv" : { "stringUnit" : { - "value" : "Snabba åtgärder", - "state" : "translated" + "state" : "translated", + "value" : "Samtalssammanfattningens markör refererar inte till ett av dess meddelanden." } } - }, - "comment" : "Widget name." + } }, - "Add to Favourites" : { + "The iCloud account changed during synchronization." : { + "comment" : "Error description when the iCloud account changes during synchronization.", + "isCommentAutoGenerated" : true + }, + "The iCloud container is unavailable." : { + "comment" : "Error description when the iCloud container is unavailable.", + "isCommentAutoGenerated" : true + }, + "The latest message and its attachments exceed this context window. Increase the context window or shorten the message." : { + "comment" : "Error message when the latest message and its attachments exceed the context window.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "お気に入りに追加", - "state" : "translated" + "state" : "translated", + "value" : "Die neueste Nachricht und ihre Anhänge überschreiten dieses Kontextfenster. Erhöhen Sie das Kontextfenster oder kürzen Sie die Nachricht." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Añadir a Favoritos" + "value" : "Το πιο πρόσφατο μήνυμα και τα συνημμένα του υπερβαίνουν το παράθυρο συμφραζομένων. Αυξήστε το παράθυρο συμφραζομένων ή συντομεύστε το μήνυμα." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Zu Favoriten hinzufügen" + "value" : "The latest message and its attachments exceed this context window. Increase the context window or shorten the message." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Aggiungi ai Preferiti", - "state" : "translated" + "state" : "translated", + "value" : "El último mensaje y sus archivos adjuntos superan esta ventana de contexto. Aumenta la ventana de contexto o acorta el mensaje." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Adicionar aos Favoritos" + "value" : "Le dernier message et ses pièces jointes dépassent cette fenêtre de contexte. Agrandissez la fenêtre de contexte ou raccourcissez le message." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Add to Favorites", - "state" : "translated" + "state" : "translated", + "value" : "L'ultimo messaggio e i suoi allegati superano questa finestra di contesto. Aumenta la finestra di contesto o riduci il messaggio." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Toevoegen aan favorieten", - "state" : "translated" + "state" : "translated", + "value" : "最新のメッセージと添付ファイルがこのコンテキストウィンドウの容量を超えています。コンテキストウィンドウを拡大するか、メッセージを短くしてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ajouter aux favoris", - "state" : "translated" + "state" : "translated", + "value" : "Het nieuwste bericht en de bijlagen overschrijden dit contextvenster. Vergroot het contextvenster of verkort het bericht." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προσθήκη στα Αγαπημένα", - "state" : "translated" + "state" : "translated", + "value" : "A última mensagem e os seus anexos excedem esta janela de contexto. Aumente a janela de contexto ou reduza a mensagem." } }, "sv" : { "stringUnit" : { - "value" : "Lägg till i favoriter", - "state" : "translated" + "state" : "translated", + "value" : "Det senaste meddelandet och dess bilagor överskrider detta kontextfönster. Öka kontextfönstret eller förkorta meddelandet." } } - }, - "comment" : "A label for a button that adds a message to the user's favourites." + } }, - "Some MCP servers could not be loaded: %@." : { + "The latest turn exceeds the available context" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ορισμένοι διακομιστές MCP δεν μπόρεσαν να φορτωθούν: %@.", - "state" : "translated" + "state" : "translated", + "value" : "Der letzte Zug überschreitet den verfügbaren Kontext" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudieron cargar algunos servidores MCP: %@.", - "state" : "translated" + "state" : "translated", + "value" : "Η τελευταία κίνηση υπερβαίνει το διαθέσιμο πλαίσιο" } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "一部のMCPサーバーを読み込めませんでした: %@", - "state" : "translated" + "state" : "translated", + "value" : "The latest turn exceeds the available context" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Alcuni server MCP non sono stati caricati: %@.", - "state" : "translated" + "state" : "translated", + "value" : "El último turno supera el contexto disponible" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Alguns servidores MCP não puderam ser carregados: %@." + "value" : "Le dernier tour dépasse le contexte disponible" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Some MCP servers could not be loaded: %@." + "value" : "L'ultimo turno supera il contesto disponibile" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Certains serveurs MCP n'ont pas pu être chargés : %@.", - "state" : "translated" + "state" : "translated", + "value" : "最新のターンが利用可能なコンテキストを超えています" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Sommige MCP-servers konden niet worden geladen: %@." + "value" : "De laatste beurt overschrijdt de beschikbare context" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Einige MCP-Server konnten nicht geladen werden: %@.", - "state" : "translated" + "state" : "translated", + "value" : "A última jogada excede o contexto disponível" } }, "sv" : { "stringUnit" : { - "value" : "Vissa MCP-servrar kunde inte laddas: %@.", - "state" : "translated" + "state" : "translated", + "value" : "Det senaste draget överskrider det tillgängliga sammanhanget" } } - }, - "comment" : "A message that describes which MCP servers failed to load." + } }, - "Too many requests. Please try again later." : { + "The MCP server reported a tool error." : { + "comment" : "Error message when an MCP tool call fails.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "リクエストが多すぎます。後でもう一度お試しください。" + "value" : "Der MCP-Server meldete einen Werkzeugfehler." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Demasiadas solicitudes. Por favor, inténtalo de nuevo más tarde.", - "state" : "translated" + "state" : "translated", + "value" : "Ο διακομιστής MCP ανέφερε σφάλμα εργαλείου." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Zu viele Anfragen. Bitte versuchen Sie es später erneut." + "value" : "The MCP server reported a tool error." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Troppe richieste. Riprova più tardi." + "value" : "El servidor MCP informó un error de herramienta." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Demasiados pedidos. Por favor, tente novamente mais tarde.", - "state" : "translated" + "state" : "translated", + "value" : "Le serveur MCP a signalé une erreur d’outil." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Too many requests. Please try again later.", - "state" : "translated" + "state" : "translated", + "value" : "Il server MCP ha segnalato un errore dello strumento." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Te veel verzoeken. Probeer het later opnieuw.", - "state" : "translated" + "state" : "translated", + "value" : "MCPサーバーがツールエラーを報告しました。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Trop de requêtes. Veuillez réessayer plus tard.", - "state" : "translated" + "state" : "translated", + "value" : "De MCP-server meldde een toolfout." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Πάρα πολλά αιτήματα. Παρακαλώ δοκιμάστε ξανά αργότερα.", - "state" : "translated" + "state" : "translated", + "value" : "O servidor MCP reportou um erro na ferramenta." } }, "sv" : { "stringUnit" : { - "value" : "För många förfrågningar. Försök igen senare.", - "state" : "translated" + "state" : "translated", + "value" : "MCP-servern rapporterade ett verktygsfel." } } } }, - "Could not load tip options. Please try again later." : { + "The MCP tool arguments are not valid JSON." : { + "comment" : "Error message when the MCP tool arguments are not valid JSON.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Kunde inte ladda dricksalternativ. Försök igen senare." + "value" : "Die Argumente des MCP-Tools sind kein gültiges JSON." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudieron cargar las opciones de propina. Por favor, inténtelo de nuevo más tarde.", - "state" : "translated" + "state" : "translated", + "value" : "Τα επιχειρήματα του εργαλείου MCP δεν είναι έγκυρο JSON." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν ήταν δυνατή η φόρτωση των επιλογών φιλοδωρήματος. Δοκιμάστε ξανά αργότερα." + "value" : "The MCP tool arguments are not valid JSON." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Impossibile caricare le opzioni di mancia. Riprova più tardi." + "value" : "Los argumentos de la herramienta MCP no son un JSON válido." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Não foi possível carregar as opções de gorjeta. Por favor, tente novamente mais tarde.", - "state" : "translated" + "state" : "translated", + "value" : "Les arguments de l’outil MCP ne sont pas un JSON valide." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Could not load tip options. Please try again later.", - "state" : "translated" + "state" : "translated", + "value" : "Gli argomenti dello strumento MCP non sono un JSON valido." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Impossible de charger les options de pourboire. Veuillez réessayer plus tard.", - "state" : "translated" + "state" : "translated", + "value" : "MCPツールの引数が有効なJSONではありません。" } }, "nl" : { "stringUnit" : { - "value" : "Kan de fooiopties niet laden. Probeer het later opnieuw.", - "state" : "translated" + "state" : "translated", + "value" : "De argumenten van de MCP-tool zijn geen geldige JSON." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "チップオプションを読み込めませんでした。後でもう一度お試しください。", - "state" : "translated" + "state" : "translated", + "value" : "Os argumentos da ferramenta MCP não são JSON válido." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Tippoptionen konnten nicht geladen werden. Bitte versuchen Sie es später erneut.", - "state" : "translated" + "state" : "translated", + "value" : "Argumenten för MCP-verktyget är inte giltig JSON." } } - }, - "comment" : "Error message displayed when there is an issue loading the tip options." + } }, - "Prepare meeting notes" : { + "The MCP tool arguments do not match the tool schema." : { + "comment" : "Error description when the MCP tool arguments do not match the tool schema.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Besprechungsnotizen vorbereiten" + "value" : "Die Argumente des MCP-Tools stimmen nicht mit dem Toolschema überein." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Preparar notas de la reunión", - "state" : "translated" + "state" : "translated", + "value" : "Τα επιχειρήματα του εργαλείου MCP δεν ταιριάζουν με το σχήμα του εργαλείου." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Förbered mötesanteckningar" + "value" : "The MCP tool arguments do not match the tool schema." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Prepara appunti della riunione", - "state" : "translated" + "state" : "translated", + "value" : "Los argumentos de la herramienta MCP no coinciden con el esquema de la herramienta." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Preparar notas da reunião", - "state" : "translated" + "state" : "translated", + "value" : "Les arguments de l’outil MCP ne correspondent pas au schéma de l’outil." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Prepare meeting notes", - "state" : "translated" + "state" : "translated", + "value" : "Gli argomenti dello strumento MCP non corrispondono allo schema dello strumento." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Notulen voorbereiden", - "state" : "translated" + "state" : "translated", + "value" : "MCPツールの引数がツールスキーマと一致しません。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Préparer les notes de réunion" + "value" : "De argumenten van de MCP-tool komen niet overeen met het toolschema." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会議メモの準備", - "state" : "translated" + "state" : "translated", + "value" : "Os argumentos da ferramenta MCP não correspondem ao esquema da ferramenta." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Προετοιμασία σημειώσεων συνάντησης", - "state" : "translated" + "state" : "translated", + "value" : "Argumenten för MCP-verktyget stämmer inte överens med verktygsschemat." } } - }, - "comment" : "Title of a conversation." + } }, - "Loading tools..." : { + "The memory change could not be saved. Please try again." : { + "comment" : "Error message displayed when an error occurs while saving a memory change.", + "isCommentAutoGenerated" : true + }, + "The message to fork from could not be found." : { + "comment" : "Error message displayed when the message to fork from cannot be found.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Φόρτωση εργαλείων..." + "value" : "Die Nachricht, von der verzweigt werden soll, konnte nicht gefunden werden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Cargando herramientas...", - "state" : "translated" + "state" : "translated", + "value" : "Το μήνυμα για διακλάδωση δεν βρέθηκε." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ツールを読み込み中..." + "value" : "The message to fork from could not be found." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Caricamento strumenti...", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo encontrar el mensaje del cual bifurcar." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A carregar ferramentas...", - "state" : "translated" + "state" : "translated", + "value" : "Le message à partir duquel bifurquer est introuvable." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Loading tools...", - "state" : "translated" + "state" : "translated", + "value" : "Impossibile trovare il messaggio da cui fare il fork." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Tools laden...", - "state" : "translated" + "state" : "translated", + "value" : "フォーク元のメッセージが見つかりませんでした。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Chargement des outils...", - "state" : "translated" + "state" : "translated", + "value" : "Het bericht om van te forken kon niet worden gevonden." } }, - "de" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Werkzeuge werden geladen..." + "value" : "A mensagem para a qual se pretende criar um fork não foi encontrada." } }, "sv" : { "stringUnit" : { - "value" : "Laddar verktyg...", - "state" : "translated" + "state" : "translated", + "value" : "Meddelandet att förgrena från kunde inte hittas." } } - }, - "comment" : "A loading message for MCP tools." + } }, - "This backup version is not supported." : { + "The model finished responding. Tap to continue." : { + "comment" : "Text displayed in a notification when the LLM has finished responding.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Αυτή η έκδοση αντιγράφου ασφαλείας δεν υποστηρίζεται." + "value" : "Das Modell hat die Antwort beendet. Tippen, um fortzufahren." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Esta versión de la copia de seguridad no es compatible." + "value" : "Το μοντέλο ολοκλήρωσε την απάντηση. Πατήστε για συνέχεια." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "このバックアップバージョンはサポートされていません。" + "value" : "The model finished responding. Tap to continue." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Questa versione di backup non è supportata.", - "state" : "translated" + "state" : "translated", + "value" : "El modelo terminó de responder. Toca para continuar." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Esta versão de backup não é suportada.", - "state" : "translated" + "state" : "translated", + "value" : "Le modèle a terminé de répondre. Touchez pour continuer." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "This backup version is not supported.", - "state" : "translated" + "state" : "translated", + "value" : "Il modello ha terminato la risposta. Tocca per continuare." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Cette version de sauvegarde n’est pas prise en charge.", - "state" : "translated" + "state" : "translated", + "value" : "モデルの応答が完了しました。タップして続行してください。" } }, "nl" : { "stringUnit" : { - "value" : "Deze back-upversie wordt niet ondersteund.", - "state" : "translated" + "state" : "translated", + "value" : "Het model is klaar met antwoorden. Tik om door te gaan." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Diese Sicherungsversion wird nicht unterstützt.", - "state" : "translated" + "state" : "translated", + "value" : "O modelo terminou de responder. Toque para continuar." } }, "sv" : { "stringUnit" : { - "value" : "Den här säkerhetskopieringsversionen stöds inte.", - "state" : "translated" + "state" : "translated", + "value" : "Modellen har slutat svara. Tryck för att fortsätta." } } } }, - "Sign in to iCloud to enable sync" : { + "The model returned an empty response. Please try again." : { + "comment" : "Error message displayed when the assistant returns an empty response.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "iCloudにサインインして同期を有効にする", - "state" : "translated" + "state" : "translated", + "value" : "Das Modell hat eine leere Antwort zurückgegeben. Bitte versuchen Sie es erneut." } }, - "de" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Melden Sie sich bei iCloud an, um die Synchronisierung zu aktivieren" + "value" : "Το μοντέλο επέστρεψε κενή απάντηση. Παρακαλώ δοκιμάστε ξανά." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Inicia sesión en iCloud para activar la sincronización" + "value" : "The model returned an empty response. Please try again." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Accedi a iCloud per abilitare la sincronizzazione" + "value" : "El modelo devolvió una respuesta vacía. Por favor, inténtalo de nuevo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Inicie sessão no iCloud para ativar a sincronização", - "state" : "translated" + "state" : "translated", + "value" : "Le modèle a renvoyé une réponse vide. Veuillez réessayer." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Sign in to iCloud to enable sync", - "state" : "translated" + "state" : "translated", + "value" : "Il modello ha restituito una risposta vuota. Riprova." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Meld u aan bij iCloud om synchronisatie in te schakelen", - "state" : "translated" + "state" : "translated", + "value" : "モデルが空の応答を返しました。もう一度お試しください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Connectez-vous à iCloud pour activer la synchronisation", - "state" : "translated" + "state" : "translated", + "value" : "Het model gaf een lege reactie terug. Probeer het opnieuw." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Συνδεθείτε στο iCloud για να ενεργοποιήσετε το συγχρονισμό", - "state" : "translated" + "state" : "translated", + "value" : "O modelo devolveu uma resposta vazia. Por favor, tente novamente." } }, "sv" : { "stringUnit" : { - "value" : "Logga in på iCloud för att aktivera synkronisering", - "state" : "translated" + "state" : "translated", + "value" : "Modellen gav inget svar. Försök igen." } } - }, - "comment" : "A message that instructs the user to sign in to iCloud to enable iCloud sync." + } }, - "Swift uses structured concurrency with async\/await..." : { + "The model returned an invalid agent response." : { + "comment" : "Error message displayed when the model returns an invalid agent response.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η Swift χρησιμοποιεί δομημένη ασύγχρονη εκτέλεση με async\/await...", - "state" : "translated" + "state" : "translated", + "value" : "Das Modell hat eine ungültige Agentenantwort zurückgegeben." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Swift usa concurrencia estructurada con async\/await...", - "state" : "translated" + "state" : "translated", + "value" : "Το μοντέλο επέστρεψε μη έγκυρη απάντηση πράκτορα." } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Swift verwendet strukturierte Nebenläufigkeit mit async\/await...", - "state" : "translated" + "state" : "translated", + "value" : "The model returned an invalid agent response." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Swift utilizza la concorrenza strutturata con async\/await...", - "state" : "translated" + "state" : "translated", + "value" : "El modelo devolvió una respuesta de agente no válida." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Swift usa concorrência estruturada com async\/await...", - "state" : "translated" + "state" : "translated", + "value" : "Le modèle a renvoyé une réponse d’agent invalide." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Swift uses structured concurrency with async\/await..." + "value" : "Il modello ha restituito una risposta agente non valida." } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Swift gebruikt gestructureerde gelijktijdigheid met async\/await..." + "value" : "モデルが無効なエージェント応答を返しました。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Swift utilise la concurrence structurée avec async\/await..." + "value" : "Het model gaf een ongeldige agentrespons terug." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "Swiftはasync\/awaitを使った構造化並行処理を採用しています...", - "state" : "translated" + "state" : "translated", + "value" : "O modelo devolveu uma resposta de agente inválida." } }, "sv" : { "stringUnit" : { - "value" : "Swift använder strukturerad samtidighet med async\/await...", - "state" : "translated" + "state" : "translated", + "value" : "Modellen returnerade ett ogiltigt agent-svar." } } - }, - "comment" : "Text of a message preview in a conversation." + } }, - "OpenClient connects to your LiteLLM for privacy-first access to any AI." : { + "The network connection was lost." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient ansluter till din LiteLLM för integritetsfokuserad åtkomst till AI." + "value" : "Die Netzwerkverbindung wurde unterbrochen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "OpenClient se conecta a tu LiteLLM para un acceso a cualquier IA con prioridad en la privacidad.", - "state" : "translated" + "state" : "translated", + "value" : "Η σύνδεση δικτύου διακόπηκε." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClientはプライバシー重視でLiteLLMに接続し、あらゆるAIにアクセスします。" + "value" : "The network connection was lost." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient si connette al tuo LiteLLM per un accesso all’IA prioritariamente orientato alla privacy." + "value" : "Se perdió la conexión de red." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O OpenClient liga-se ao seu LiteLLM para acesso prioritário à privacidade a qualquer IA.", - "state" : "translated" + "state" : "translated", + "value" : "La connexion réseau a été perdue." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "OpenClient connects to your LiteLLM for privacy-first access to any AI.", - "state" : "translated" + "state" : "translated", + "value" : "La connessione di rete è stata persa." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "OpenClient se connecte à votre LiteLLM pour un accès à l’IA privilégiant la confidentialité.", - "state" : "translated" + "state" : "translated", + "value" : "ネットワーク接続が切断されました。" } }, "nl" : { "stringUnit" : { - "value" : "OpenClient maakt verbinding met je LiteLLM voor privacygerichte toegang tot elke AI.", - "state" : "translated" + "state" : "translated", + "value" : "De netwerkverbinding is verbroken." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Το OpenClient συνδέεται με το LiteLLM σας για πρόσβαση με προτεραιότητα στην ιδιωτικότητα σε οποιαδήποτε AI.", - "state" : "translated" + "state" : "translated", + "value" : "A ligação de rede foi perdida." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "OpenClient verbindet sich mit Ihrem LiteLLM für datenschutzorientierten Zugriff auf jede KI.", - "state" : "translated" + "state" : "translated", + "value" : "Nätverksanslutningen förlorades." } } - }, - "comment" : "A description of OpenClient's privacy-first connection to LiteLLM." + } }, - "Completed" : { + "The profile changed or was deleted before this save completed." : { + "comment" : "Error description when a profile change or deletion occurred before the save operation completed.", + "isCommentAutoGenerated" : true + }, + "The profile has conflicting changes with the same revision." : { + "comment" : "Error message when a profile change is detected to be conflicting with a previous revision.", + "isCommentAutoGenerated" : true + }, + "The project notes are ready to review." : { + "comment" : "Last message preview text for a conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "完了" + "value" : "Die Projektnotizen sind bereit zur Überprüfung." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Completado" + "value" : "Οι σημειώσεις του έργου είναι έτοιμες για ανασκόπηση." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Abgeschlossen" + "value" : "The project notes are ready to review." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Completato", - "state" : "translated" + "state" : "translated", + "value" : "Las notas del proyecto están listas para revisar." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Concluído", - "state" : "translated" + "state" : "translated", + "value" : "Les notes du projet sont prêtes à être examinées." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Completed", - "state" : "translated" + "state" : "translated", + "value" : "Le note del progetto sono pronte per la revisione." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Voltooid", - "state" : "translated" + "state" : "translated", + "value" : "プロジェクトのメモがレビュー可能です。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Terminé", - "state" : "translated" + "state" : "translated", + "value" : "De projectnotities zijn klaar om te bekijken." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ολοκληρώθηκε", - "state" : "translated" + "state" : "translated", + "value" : "As notas do projeto estão prontas para revisão." } }, "sv" : { "stringUnit" : { - "value" : "Slutförd", - "state" : "translated" + "state" : "translated", + "value" : "Projektanteckningarna är klara för granskning." } } } }, - "Suggestions" : { + "The request timed out. The server may be slow or unreachable." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "提案" + "value" : "Die Anfrage hat ein Zeitlimit überschritten. Der Server ist möglicherweise langsam oder nicht erreichbar." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sugerencias", - "state" : "translated" + "state" : "translated", + "value" : "Η αίτηση έληξε λόγω χρόνου αναμονής. Ο διακομιστής μπορεί να είναι αργός ή μη προσβάσιμος." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Förslag" + "value" : "The request timed out. The server may be slow or unreachable." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Suggerimenti", - "state" : "translated" + "state" : "translated", + "value" : "La solicitud agotó el tiempo de espera. El servidor puede estar lento o inaccesible." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sugestões", - "state" : "translated" + "state" : "translated", + "value" : "La requête a expiré. Le serveur peut être lent ou inaccessible." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Suggestions" + "value" : "La richiesta è scaduta. Il server potrebbe essere lento o non raggiungibile." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Suggesties", - "state" : "translated" + "state" : "translated", + "value" : "リクエストがタイムアウトしました。サーバーが遅いか、接続できません。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Suggestions", - "state" : "translated" + "state" : "translated", + "value" : "De aanvraag is verlopen. De server is mogelijk traag of niet bereikbaar." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προτάσεις", - "state" : "translated" + "state" : "translated", + "value" : "O pedido expirou. O servidor pode estar lento ou inacessível." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Vorschläge", - "state" : "translated" + "state" : "translated", + "value" : "Förfrågan tog för lång tid. Servern kan vara långsam eller otillgänglig." } } } }, - "Only active" : { + "The request was cancelled." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Endast aktiva" + "value" : "Die Anfrage wurde abgebrochen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Solo activos", - "state" : "translated" + "state" : "translated", + "value" : "Το αίτημα ακυρώθηκε." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "アクティブのみ" + "value" : "The request was cancelled." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Solo attivi", - "state" : "translated" + "state" : "translated", + "value" : "La solicitud fue cancelada." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Apenas ativo", - "state" : "translated" + "state" : "translated", + "value" : "La requête a été annulée." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Only active", - "state" : "translated" + "state" : "translated", + "value" : "La richiesta è stata annullata." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Alleen actief", - "state" : "translated" + "state" : "translated", + "value" : "リクエストはキャンセルされました。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Uniquement actif", - "state" : "translated" + "state" : "translated", + "value" : "Het verzoek is geannuleerd." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Nur aktiv", - "state" : "translated" + "state" : "translated", + "value" : "O pedido foi cancelado." } }, - "el" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Μόνο ενεργά" + "value" : "Begäran avbröts." } } } }, - "%.1f — %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "value" : "%1$.1f — %2$@", - "state" : "new" - } - } - }, - "shouldTranslate" : false - }, - "Start a private chat" : { + "The response was cut short. Open the app to see what was received." : { + "comment" : "Text displayed in a notification when the response to a prompt was cut short.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Privaten Chat starten", - "state" : "translated" + "state" : "translated", + "value" : "Die Antwort wurde abgeschnitten. Öffnen Sie die App, um zu sehen, was empfangen wurde." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Ξεκινήστε μια ιδιωτική συνομιλία" + "value" : "Η απάντηση διακόπηκε. Άνοιξε την εφαρμογή για να δεις τι λήφθηκε." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Iniciar un chat privado" + "value" : "The response was cut short. Open the app to see what was received." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Avvia una chat privata", - "state" : "translated" + "state" : "translated", + "value" : "La respuesta se cortó. Abre la app para ver lo recibido." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Iniciar uma conversa privada", - "state" : "translated" + "state" : "translated", + "value" : "La réponse a été interrompue. Ouvrez l’application pour voir ce qui a été reçu." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Start a private chat" + "value" : "La risposta è stata interrotta. Apri l’app per vedere cosa è stato ricevuto." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Begin een privégesprek", - "state" : "translated" + "state" : "translated", + "value" : "応答が途中で切れました。受信内容を確認するにはアプリを開いてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Démarrer une conversation privée", - "state" : "translated" + "state" : "translated", + "value" : "Het antwoord is afgebroken. Open de app om te zien wat er is ontvangen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "プライベートチャットを開始", - "state" : "translated" + "state" : "translated", + "value" : "A resposta foi interrompida. Abra a app para ver o que foi recebido." } }, "sv" : { "stringUnit" : { - "value" : "Starta en privat chatt", - "state" : "translated" + "state" : "translated", + "value" : "Svaret avbröts. Öppna appen för att se vad som mottogs." } } - }, - "comment" : "A description of the private chat feature." + } }, - "Speech recognition is not available on this device." : { + "The selected file is not a valid image." : { + "comment" : "Error message when the selected file is not a valid image.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Spracherkennung ist auf diesem Gerät nicht verfügbar.", - "state" : "translated" + "state" : "translated", + "value" : "Die ausgewählte Datei ist kein gültiges Bild." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Η αναγνώριση ομιλίας δεν είναι διαθέσιμη σε αυτή τη συσκευή." + "value" : "Το επιλεγμένο αρχείο δεν είναι έγκυρη εικόνα." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "El reconocimiento de voz no está disponible en este dispositivo." + "value" : "The selected file is not a valid image." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il riconoscimento vocale non è disponibile su questo dispositivo.", - "state" : "translated" + "state" : "translated", + "value" : "El archivo seleccionado no es una imagen válida." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O reconhecimento de voz não está disponível neste dispositivo." + "value" : "Le fichier sélectionné n’est pas une image valide." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Speech recognition is not available on this device.", - "state" : "translated" + "state" : "translated", + "value" : "Il file selezionato non è un'immagine valida." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Spraakherkenning is niet beschikbaar op dit apparaat.", - "state" : "translated" + "state" : "translated", + "value" : "選択したファイルは有効な画像ではありません。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "La reconnaissance vocale n’est pas disponible sur cet appareil.", - "state" : "translated" + "state" : "translated", + "value" : "Het geselecteerde bestand is geen geldige afbeelding." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "このデバイスでは音声認識が利用できません。", - "state" : "translated" + "state" : "translated", + "value" : "O ficheiro selecionado não é uma imagem válida." } }, "sv" : { "stringUnit" : { - "value" : "Taligenkänning är inte tillgänglig på den här enheten.", - "state" : "translated" + "state" : "translated", + "value" : "Den valda filen är inte en giltig bild." } } - }, - "comment" : "Error message when the speech recognition is not available on the device." + } }, - "Any additional context for the assistant" : { + "The selected image could not be prepared. Please choose another image." : { + "comment" : "Error message displayed when an error occurs during the preparation of an image.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Zusätzlicher Kontext für den Assistenten", - "state" : "translated" + "state" : "translated", + "value" : "Das ausgewählte Bild konnte nicht vorbereitet werden. Bitte wählen Sie ein anderes Bild aus." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Contexto adicional para el asistente" + "value" : "Δεν ήταν δυνατή η προετοιμασία της επιλεγμένης εικόνας. Επιλέξτε άλλη εικόνα." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ytterligare information för assistenten" + "value" : "The selected image could not be prepared. Please choose another image." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Contesto aggiuntivo per l’assistente", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo preparar la imagen seleccionada. Elige otra imagen." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Contexto adicional para o assistente", - "state" : "translated" + "state" : "translated", + "value" : "L’image sélectionnée n’a pas pu être préparée. Veuillez choisir une autre image." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Additional context for the assistant", - "state" : "translated" + "state" : "translated", + "value" : "Non è stato possibile preparare l’immagine selezionata. Scegli un’altra immagine." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Aanvullende context voor de assistent", - "state" : "translated" + "state" : "translated", + "value" : "選択した画像を準備できませんでした。別の画像を選択してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Contexte supplémentaire pour l’assistant" + "value" : "De geselecteerde afbeelding kon niet worden voorbereid. Kies een andere afbeelding." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アシスタントへの追加情報", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível preparar a imagem selecionada. Escolha outra imagem." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Πρόσθετο πλαίσιο για τον βοηθό", - "state" : "translated" + "state" : "translated", + "value" : "Den valda bilden kunde inte förberedas. Välj en annan bild." } } - }, - "comment" : "A label for a text field where the user can add additional context for the assistant." + } }, - "The latest message and its attachments exceed this context window. Increase the context window or shorten the message." : { + "The server certificate is not trusted." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "最新のメッセージと添付ファイルがこのコンテキストウィンドウの容量を超えています。コンテキストウィンドウを拡大するか、メッセージを短くしてください。", - "state" : "translated" + "state" : "translated", + "value" : "Das Serverzertifikat wird nicht vertraut." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El último mensaje y sus archivos adjuntos superan esta ventana de contexto. Aumenta la ventana de contexto o acorta el mensaje.", - "state" : "translated" + "state" : "translated", + "value" : "Το πιστοποιητικό διακομιστή δεν είναι αξιόπιστο." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Το πιο πρόσφατο μήνυμα και τα συνημμένα του υπερβαίνουν το παράθυρο συμφραζομένων. Αυξήστε το παράθυρο συμφραζομένων ή συντομεύστε το μήνυμα." + "value" : "The server certificate is not trusted." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "L'ultimo messaggio e i suoi allegati superano questa finestra di contesto. Aumenta la finestra di contesto o riduci il messaggio.", - "state" : "translated" + "state" : "translated", + "value" : "El certificado del servidor no es de confianza." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "A última mensagem e os seus anexos excedem esta janela de contexto. Aumente a janela de contexto ou reduza a mensagem.", - "state" : "translated" + "state" : "translated", + "value" : "Le certificat du serveur n’est pas fiable." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The latest message and its attachments exceed this context window. Increase the context window or shorten the message.", - "state" : "translated" + "state" : "translated", + "value" : "Il certificato del server non è attendibile." } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Le dernier message et ses pièces jointes dépassent cette fenêtre de contexte. Agrandissez la fenêtre de contexte ou raccourcissez le message." + "value" : "サーバー証明書は信頼されていません。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Het nieuwste bericht en de bijlagen overschrijden dit contextvenster. Vergroot het contextvenster of verkort het bericht." + "value" : "Het servercertificaat wordt niet vertrouwd." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Die neueste Nachricht und ihre Anhänge überschreiten dieses Kontextfenster. Erhöhen Sie das Kontextfenster oder kürzen Sie die Nachricht.", - "state" : "translated" + "state" : "translated", + "value" : "O certificado do servidor não é confiável." } }, "sv" : { "stringUnit" : { - "value" : "Det senaste meddelandet och dess bilagor överskrider detta kontextfönster. Öka kontextfönstret eller förkorta meddelandet.", - "state" : "translated" + "state" : "translated", + "value" : "Serverns certifikat är inte betrott." } } - }, - "comment" : "Error message when the latest message and its attachments exceed the context window." + } }, - "Fork from here" : { + "The server is not reachable." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "ここからフォーク", - "state" : "translated" + "state" : "translated", + "value" : "Der Server ist nicht erreichbar." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Bifurcar desde aquí", - "state" : "translated" + "state" : "translated", + "value" : "Ο διακομιστής δεν είναι προσβάσιμος." } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "Abzweigen von hier", - "state" : "translated" + "state" : "translated", + "value" : "The server is not reachable." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Crea fork da qui", - "state" : "translated" + "state" : "translated", + "value" : "El servidor no es accesible." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Criar bifurcação daqui" + "value" : "Le serveur est inaccessible." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Fork from here", - "state" : "translated" + "state" : "translated", + "value" : "Il server non è raggiungibile." } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Créer une branche ici" + "value" : "サーバーに接続できません。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Vertakking vanaf hier" + "value" : "De server is niet bereikbaar." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δημιουργία αντιγράφου από εδώ", - "state" : "translated" + "state" : "translated", + "value" : "O servidor não está acessível." } }, "sv" : { "stringUnit" : { - "value" : "Gaffla härifrån", - "state" : "translated" + "state" : "translated", + "value" : "Servern är inte nåbar." } } - }, - "comment" : "A label for a button that forks a message." + } }, - "Rename Conversation" : { + "The server returned an invalid response." : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "会話の名前を変更" + "value" : "Der Server hat eine ungültige Antwort zurückgegeben." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Renombrar conversación", - "state" : "translated" + "state" : "translated", + "value" : "Ο διακομιστής επέστρεψε μη έγκυρη απάντηση." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Byt namn på konversation" + "value" : "The server returned an invalid response." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Rinomina conversazione", - "state" : "translated" + "state" : "translated", + "value" : "El servidor devolvió una respuesta no válida." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Renomear Conversa", - "state" : "translated" + "state" : "translated", + "value" : "Le serveur a renvoyé une réponse invalide." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Rename Conversation", - "state" : "translated" + "state" : "translated", + "value" : "Il server ha restituito una risposta non valida." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gesprek hernoemen", - "state" : "translated" + "state" : "translated", + "value" : "サーバーが無効な応答を返しました。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Renommer la conversation" + "value" : "De server gaf een ongeldige reactie terug." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Konversation umbenennen", - "state" : "translated" + "state" : "translated", + "value" : "O servidor devolveu uma resposta inválida." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Μετονομασία Συνομιλίας", - "state" : "translated" + "state" : "translated", + "value" : "Servern returnerade ett ogiltigt svar." } } - }, - "comment" : "A dialog box title that appears when renaming a conversation." + } }, - "Image could not be loaded" : { + "The server URL is not valid." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bilden kunde inte laddas" + "value" : "Die Server-URL ist ungültig." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudo cargar la imagen", - "state" : "translated" + "state" : "translated", + "value" : "Η διεύθυνση URL του διακομιστή δεν είναι έγκυρη." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Η εικόνα δεν μπόρεσε να φορτωθεί" + "value" : "The server URL is not valid." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Immagine non caricabile", - "state" : "translated" + "state" : "translated", + "value" : "La URL del servidor no es válida." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Não foi possível carregar a imagem" + "value" : "L’URL du serveur n’est pas valide." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Image could not be loaded", - "state" : "translated" + "state" : "translated", + "value" : "L'URL del server non è valido." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeelding kon niet worden geladen", - "state" : "translated" + "state" : "translated", + "value" : "サーバーのURLが無効です。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Impossible de charger l’image", - "state" : "translated" + "state" : "translated", + "value" : "De server-URL is niet geldig." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "画像を読み込めませんでした", - "state" : "translated" + "state" : "translated", + "value" : "O URL do servidor não é válido." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Bild konnte nicht geladen werden", - "state" : "translated" + "state" : "translated", + "value" : "Serverns URL är inte giltig." } } - }, - "comment" : "A message displayed when an image fails to load." + } }, - "Unable to save the backup file." : { + "Thinking" : { + "comment" : "A label displayed in a bubble that indicates that the assistant is thinking.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "バックアップファイルを保存できませんでした。", - "state" : "translated" + "state" : "translated", + "value" : "Denke" } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Die Sicherungsdatei konnte nicht gespeichert werden.", - "state" : "translated" + "state" : "translated", + "value" : "Σκέψη" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "No se pudo guardar el archivo de respaldo." + "value" : "Thinking" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile salvare il file di backup.", - "state" : "translated" + "state" : "translated", + "value" : "Pensando" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Não foi possível guardar o ficheiro de cópia de segurança." + "value" : "Réflexion en cours" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to save the backup file." + "value" : "Sto pensando" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Kan het back-upbestand niet opslaan.", - "state" : "translated" + "state" : "translated", + "value" : "考え中" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Impossible d’enregistrer le fichier de sauvegarde.", - "state" : "translated" + "state" : "translated", + "value" : "Bezig met nadenken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αδυναμία αποθήκευσης του αρχείου αντιγράφου ασφαλείας.", - "state" : "translated" + "state" : "translated", + "value" : "A pensar" } }, "sv" : { "stringUnit" : { - "value" : "Kunde inte spara säkerhetskopian.", - "state" : "translated" + "state" : "translated", + "value" : "Tänker" } } - }, - "comment" : "Error message displayed when there is an issue writing the backup file." + } }, - "Keep your important conversations close at hand." : { + "Thinking..." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Κρατήστε τις σημαντικές συνομιλίες σας κοντά σας.", - "state" : "translated" + "state" : "translated", + "value" : "Denke..." } }, - "sv" : { + "el" : { "stringUnit" : { - "value" : "Ha dina viktiga konversationer nära till hands.", - "state" : "translated" + "state" : "translated", + "value" : "Σκέψη..." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Mantén tus conversaciones importantes a mano." + "value" : "Thinking..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tieni le tue conversazioni importanti sempre a portata di mano.", - "state" : "translated" + "state" : "translated", + "value" : "Pensando..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Tenha as suas conversas importantes sempre à mão.", - "state" : "translated" + "state" : "translated", + "value" : "Réflexion en cours..." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Keep your important conversations close at hand." + "value" : "Sto pensando..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Houd je belangrijke gesprekken binnen handbereik.", - "state" : "translated" + "state" : "translated", + "value" : "考え中..." } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gardez vos conversations importantes à portée de main." + "value" : "Bezig met nadenken..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "重要な会話をすぐにアクセスできる場所に保ちましょう", - "state" : "translated" + "state" : "translated", + "value" : "A pensar..." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Behalte deine wichtigen Unterhaltungen griffbereit.", - "state" : "translated" + "state" : "translated", + "value" : "Tänker..." } } - }, - "comment" : "Description of the Pinned Conversations widget." + } }, - "The backup file is invalid." : { + "This backup version is not supported." : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Die Sicherungsdatei ist ungültig.", - "state" : "translated" + "state" : "translated", + "value" : "Diese Sicherungsversion wird nicht unterstützt." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El archivo de respaldo no es válido.", - "state" : "translated" + "state" : "translated", + "value" : "Αυτή η έκδοση αντιγράφου ασφαλείας δεν υποστηρίζεται." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Säkerhetskopieringsfilen är ogiltig." + "value" : "This backup version is not supported." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Il file di backup non è valido.", - "state" : "translated" + "state" : "translated", + "value" : "Esta versión de la copia de seguridad no es compatible." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O ficheiro de backup é inválido.", - "state" : "translated" + "state" : "translated", + "value" : "Cette version de sauvegarde n’est pas prise en charge." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The backup file is invalid." + "value" : "Questa versione di backup non è supportata." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Le fichier de sauvegarde est invalide.", - "state" : "translated" + "state" : "translated", + "value" : "このバックアップバージョンはサポートされていません。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Het back-upbestand is ongeldig." + "value" : "Deze back-upversie wordt niet ondersteund." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Το αρχείο αντιγράφου ασφαλείας είναι άκυρο.", - "state" : "translated" + "state" : "translated", + "value" : "Esta versão de backup não é suportada." } }, - "ja" : { + "sv" : { "stringUnit" : { - "value" : "バックアップファイルが無効です。", - "state" : "translated" + "state" : "translated", + "value" : "Den här säkerhetskopieringsversionen stöds inte." } } } }, - "The conversation summary and its cursor must both be present." : { + "This chat is not saved or added to memory." : { + "comment" : "A description of a private chat.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Το σύνοψη της συνομιλίας και ο δείκτης της πρέπει να υπάρχουν και τα δύο.", - "state" : "translated" + "state" : "translated", + "value" : "Dieser Chat wird nicht gespeichert oder im Speicher abgelegt." } }, - "sv" : { + "el" : { "stringUnit" : { - "value" : "Samtalssammanfattningen och dess markör måste båda vara närvarande.", - "state" : "translated" + "state" : "translated", + "value" : "Αυτή η συνομιλία δεν αποθηκεύεται ούτε προστίθεται στη μνήμη." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "El resumen de la conversación y su cursor deben estar presentes." + "value" : "This chat is not saved or stored in memory." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il riepilogo della conversazione e il suo cursore devono essere entrambi presenti." + "value" : "Este chat no se guarda ni se añade a la memoria." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O resumo da conversa e o seu cursor devem estar ambos presentes.", - "state" : "translated" + "state" : "translated", + "value" : "Cette conversation n’est pas enregistrée ni ajoutée à la mémoire." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The conversation summary and its cursor must both be present.", - "state" : "translated" + "state" : "translated", + "value" : "Questa chat non viene salvata né aggiunta alla memoria." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De samenvatting van het gesprek en de cursor moeten beide aanwezig zijn.", - "state" : "translated" + "state" : "translated", + "value" : "このチャットは保存されず、記憶にも追加されません。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Le résumé de la conversation et son curseur doivent tous deux être présents." + "value" : "Deze chat wordt niet opgeslagen of toegevoegd aan het geheugen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話の要約とそのカーソルの両方が存在する必要があります。", - "state" : "translated" + "state" : "translated", + "value" : "Esta conversa não é guardada nem adicionada à memória." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Die Zusammenfassung der Unterhaltung und ihr Cursor müssen beide vorhanden sein.", - "state" : "translated" + "state" : "translated", + "value" : "Den här chatten sparas inte eller läggs till i minnet." } } } }, - "PDF Document" : { + "This file is not an OpenClient backup." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Έγγραφο PDF", - "state" : "translated" + "state" : "translated", + "value" : "Diese Datei ist keine OpenClient-Sicherung." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Documento PDF", - "state" : "translated" + "state" : "translated", + "value" : "Αυτό το αρχείο δεν είναι αντίγραφο ασφαλείας OpenClient." } }, - "de" : { + "en" : { "stringUnit" : { - "value" : "PDF-Dokument", - "state" : "translated" + "state" : "translated", + "value" : "This file is not an OpenClient backup." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Documento PDF" + "value" : "Este archivo no es una copia de seguridad de OpenClient." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Documento PDF" + "value" : "Ce fichier n’est pas une sauvegarde OpenClient." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "PDF Document", - "state" : "translated" + "state" : "translated", + "value" : "Questo file non è un backup di OpenClient." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "PDF-document", - "state" : "translated" + "state" : "translated", + "value" : "このファイルはOpenClientのバックアップではありません。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Document PDF" + "value" : "Dit bestand is geen OpenClient-back-up." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "PDFドキュメント", - "state" : "translated" + "state" : "translated", + "value" : "Este ficheiro não é uma cópia de segurança OpenClient." } }, "sv" : { "stringUnit" : { - "value" : "PDF-dokument", - "state" : "translated" + "state" : "translated", + "value" : "Den här filen är inte en OpenClient-säkerhetskopia." } } } }, - "Select a model to start chatting" : { + "This information is added to every conversation so models can personalise their responses." : { + "comment" : "A description of the information that is added to every conversation.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "チャットを始めるモデルを選択してください", - "state" : "translated" + "state" : "translated", + "value" : "Diese Informationen werden jeder Unterhaltung hinzugefügt, damit Modelle ihre Antworten personalisieren können." } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Wähle ein Modell, um das Gespräch zu beginnen", - "state" : "translated" + "state" : "translated", + "value" : "Αυτές οι πληροφορίες προστίθενται σε κάθε συνομιλία ώστε τα μοντέλα να προσωποποιούν τις απαντήσεις τους." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Selecciona un modelo para empezar a chatear" + "value" : "This information is added to every conversation so models can personalize their responses." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Seleziona un modello per iniziare a chattare", - "state" : "translated" + "state" : "translated", + "value" : "Esta información se añade a cada conversación para que los modelos puedan personalizar sus respuestas." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Selecione um modelo para começar a conversar" + "value" : "Ces informations sont ajoutées à chaque conversation pour que les modèles puissent personnaliser leurs réponses." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Select a model to start chatting" + "value" : "Queste informazioni vengono aggiunte a ogni conversazione affinché i modelli possano personalizzare le loro risposte." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Selecteer een model om te beginnen met chatten", - "state" : "translated" + "state" : "translated", + "value" : "この情報は、モデルが応答をパーソナライズできるように、すべての会話に追加されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Sélectionnez un modèle pour commencer la conversation", - "state" : "translated" + "state" : "translated", + "value" : "Deze informatie wordt aan elk gesprek toegevoegd zodat modellen hun antwoorden kunnen personaliseren." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επιλέξτε ένα μοντέλο για να ξεκινήσετε τη συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Esta informação é adicionada a cada conversa para que os modelos possam personalizar as suas respostas." } }, "sv" : { "stringUnit" : { - "value" : "Välj en modell för att börja chatta", - "state" : "translated" + "state" : "translated", + "value" : "Denna information läggs till i varje konversation så att modeller kan anpassa sina svar." } } } }, - "Choose the tag shown by the conversations widget." : { + "This Week" : { + "comment" : "Title of a conversation section for conversations from the current week.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Wähle das vom Konversations-Widget angezeigte Tag.", - "state" : "translated" + "state" : "translated", + "value" : "Diese Woche" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Elige la etiqueta que muestra el widget de conversaciones" + "value" : "Αυτή την εβδομάδα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "会話ウィジェットで表示するタグを選択してください" + "value" : "This Week" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Scegli il tag mostrato dal widget delle conversazioni", - "state" : "translated" + "state" : "translated", + "value" : "Esta semana" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Escolha a etiqueta mostrada pelo widget de conversas", - "state" : "translated" + "state" : "translated", + "value" : "Cette semaine" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Choose the tag displayed by the conversations widget" + "value" : "Questa settimana" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Choisissez l’étiquette affichée par le widget de conversations", - "state" : "translated" + "state" : "translated", + "value" : "今週" } }, "nl" : { "stringUnit" : { - "value" : "Kies de tag die door de gesprekken-widget wordt weergegeven", - "state" : "translated" + "state" : "translated", + "value" : "Deze week" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επιλέξτε την ετικέτα που εμφανίζεται στο widget συνομιλιών", - "state" : "translated" + "state" : "translated", + "value" : "Esta Semana" } }, "sv" : { "stringUnit" : { - "value" : "Välj taggen som visas i konversationswidgeten", - "state" : "translated" + "state" : "translated", + "value" : "Den här veckan" } } - }, - "comment" : "Title of the widget configuration intent." + } }, - "GPT, Claude, Gemini, Llama and more via LiteLLM, Ollama, LM Studio..." : { + "This will be injected into every conversation's system prompt." : { + "comment" : "A description of the content of a memory.", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dies wird in die Systemaufforderung jedes Gesprächs eingefügt." + } + }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "GPT, Claude, Gemini, Llama και άλλα μέσω LiteLLM, Ollama, LM Studio..." + "value" : "Αυτό θα εισαχθεί στην προτροπή συστήματος κάθε συνομιλίας." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This will be injected into every conversation's system prompt." } }, "es" : { "stringUnit" : { - "value" : "GPT, Claude, Gemini, Llama y más a través de LiteLLM, Ollama, LM Studio...", - "state" : "translated" + "state" : "translated", + "value" : "Esto se añadirá en el prompt del sistema de cada conversación." } }, - "de" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "GPT, Claude, Gemini, Llama und mehr über LiteLLM, Ollama, LM Studio..." + "value" : "Ceci sera injecté dans l’invite système de chaque conversation." } }, "it" : { - "stringUnit" : { - "value" : "GPT, Claude, Gemini, Llama e altri tramite LiteLLM, Ollama, LM Studio...", - "state" : "translated" - } - }, - "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "GPT, Claude, Gemini, Llama e mais via LiteLLM, Ollama, LM Studio..." + "value" : "Questo verrà inserito nel prompt di sistema di ogni conversazione." } }, - "en" : { + "ja" : { "stringUnit" : { - "value" : "GPT, Claude, Gemini, Llama and more via LiteLLM, Ollama, LM Studio...", - "state" : "translated" + "state" : "translated", + "value" : "これはすべての会話のシステムプロンプトに挿入されます。" } }, "nl" : { "stringUnit" : { - "value" : "GPT, Claude, Gemini, Llama en meer via LiteLLM, Ollama, LM Studio...", - "state" : "translated" - } - }, - "fr" : { - "stringUnit" : { - "value" : "GPT, Claude, Gemini, Llama et plus encore via LiteLLM, Ollama, LM Studio...", - "state" : "translated" + "state" : "translated", + "value" : "Dit wordt in de systeemopdracht van elk gesprek geïnjecteerd." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "LiteLLM、Ollama、LM Studioを通じて利用可能なGPT、Claude、Gemini、Llamaなど...", - "state" : "translated" + "state" : "translated", + "value" : "Isto será inserido no prompt do sistema de cada conversa." } }, "sv" : { "stringUnit" : { - "value" : "GPT, Claude, Gemini, Llama med flera via LiteLLM, Ollama, LM Studio...", - "state" : "translated" + "state" : "translated", + "value" : "Detta kommer att injiceras i systemprompten för varje konversation." } } - }, - "comment" : "A description of the features of the app." + } }, - "The conversation summary cursor does not reference one of its messages." : { + "Tips appear only when their related features are available." : { + "comment" : "A description of the feature tips section.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ο δείκτης περίληψης συνομιλίας δεν αναφέρεται σε κάποιο από τα μηνύματά του.", - "state" : "translated" + "state" : "translated", + "value" : "Tipps erscheinen nur, wenn die zugehörigen Funktionen verfügbar sind." } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Der Zusammenfassungs-Cursor der Unterhaltung verweist nicht auf eine seiner Nachrichten.", - "state" : "translated" + "state" : "translated", + "value" : "Οι συμβουλές εμφανίζονται μόνο όταν είναι διαθέσιμες οι σχετικές λειτουργίες." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "El cursor del resumen de la conversación no hace referencia a uno de sus mensajes." + "value" : "Tips appear only when their related features are available." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Il cursore del riepilogo della conversazione non fa riferimento a uno dei suoi messaggi." + "value" : "Los consejos aparecen solo cuando sus funciones relacionadas están disponibles." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O cursor do resumo da conversa não referencia uma das suas mensagens." + "value" : "Les astuces apparaissent uniquement lorsque leurs fonctionnalités associées sont disponibles." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The conversation summary cursor does not reference one of its messages.", - "state" : "translated" + "state" : "translated", + "value" : "I suggerimenti appaiono solo quando le relative funzionalità sono disponibili." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De samenvattingscursor van het gesprek verwijst niet naar een van zijn berichten.", - "state" : "translated" + "state" : "translated", + "value" : "ヒントは関連機能が利用可能な場合にのみ表示されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Le curseur du résumé de la conversation ne fait pas référence à l’un de ses messages.", - "state" : "translated" + "state" : "translated", + "value" : "Tips verschijnen alleen wanneer de bijbehorende functies beschikbaar zijn." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話の要約カーソルがメッセージのいずれかを参照していません。", - "state" : "translated" + "state" : "translated", + "value" : "As dicas aparecem apenas quando as funcionalidades relacionadas estão disponíveis." } }, "sv" : { "stringUnit" : { - "value" : "Samtalssammanfattningens markör refererar inte till ett av dess meddelanden.", - "state" : "translated" + "state" : "translated", + "value" : "Tips visas endast när deras relaterade funktioner är tillgängliga." } } } }, - "Search" : { + "Title" : { + "comment" : "A label displayed above the title field.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Sökning", - "state" : "translated" + "state" : "translated", + "value" : "Titel" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Buscar", - "state" : "translated" + "state" : "translated", + "value" : "Τίτλος" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "検索" + "value" : "Title" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Cerca" + "value" : "Título" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Pesquisar" + "value" : "Titre" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Search", - "state" : "translated" + "state" : "translated", + "value" : "Titolo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Zoeken", - "state" : "translated" + "state" : "translated", + "value" : "タイトル" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Recherche", - "state" : "translated" + "state" : "translated", + "value" : "Titel" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Suche", - "state" : "translated" + "state" : "translated", + "value" : "Título" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αναζήτηση", - "state" : "translated" + "state" : "translated", + "value" : "Titel" } } - }, - "comment" : "A title for a screen that searches for conversations." + } }, - "Send" : { + "Title (Minimum 3 characters)" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "送信", - "state" : "translated" + "state" : "translated", + "value" : "Titel (mindestens 3 Zeichen)" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Skicka" + "value" : "Τίτλος (Ελάχιστο 3 χαρακτήρες)" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enviar" + "value" : "Title (Minimum 3 characters)" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Invia" + "value" : "Título (mínimo 3 caracteres)" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Enviar", - "state" : "translated" + "state" : "translated", + "value" : "Titre (Minimum 3 caractères)" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Send", - "state" : "translated" + "state" : "translated", + "value" : "Titolo (Minimo 3 caratteri)" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Verzenden", - "state" : "translated" + "state" : "translated", + "value" : "タイトル(最低3文字)" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Envoyer", - "state" : "translated" + "state" : "translated", + "value" : "Titel (Minimaal 3 tekens)" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Senden", - "state" : "translated" + "state" : "translated", + "value" : "Título (Mínimo 3 caracteres)" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Αποστολή", - "state" : "translated" + "state" : "translated", + "value" : "Titel (Minst 3 tecken)" } } } }, - "Skip" : { + "Today" : { + "comment" : "Title of a conversation section for conversations from today.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Überspringen", - "state" : "translated" + "state" : "translated", + "value" : "Heute" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Omitir" + "value" : "Σήμερα" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "スキップ" + "value" : "Today" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Salta", - "state" : "translated" + "state" : "translated", + "value" : "Hoy" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ignorar", - "state" : "translated" + "state" : "translated", + "value" : "Aujourd’hui" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Skip" + "value" : "Oggi" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Passer", - "state" : "translated" + "state" : "translated", + "value" : "今日" } }, "nl" : { "stringUnit" : { - "value" : "Overslaan", - "state" : "translated" + "state" : "translated", + "value" : "Vandaag" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Παράλειψη", - "state" : "translated" + "state" : "translated", + "value" : "Hoje" } }, "sv" : { "stringUnit" : { - "value" : "Hoppa över", - "state" : "translated" + "state" : "translated", + "value" : "Idag" } } } }, - "Context Window" : { + "Too many requests. Please try again later." : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Kontextfenster", - "state" : "translated" + "state" : "translated", + "value" : "Zu viele Anfragen. Bitte versuchen Sie es später erneut." } }, "el" : { "stringUnit" : { - "value" : "Παράθυρο Συμφραζομένων", - "state" : "translated" + "state" : "translated", + "value" : "Πάρα πολλά αιτήματα. Παρακαλώ δοκιμάστε ξανά αργότερα." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ventana de contexto" + "value" : "Too many requests. Please try again later." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Finestra di contesto", - "state" : "translated" + "state" : "translated", + "value" : "Demasiadas solicitudes. Por favor, inténtalo de nuevo más tarde." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Janela de Contexto" + "value" : "Trop de requêtes. Veuillez réessayer plus tard." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Context Window", - "state" : "translated" + "state" : "translated", + "value" : "Troppe richieste. Riprova più tardi." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Contextvenster", - "state" : "translated" + "state" : "translated", + "value" : "リクエストが多すぎます。後でもう一度お試しください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fenêtre de contexte" + "value" : "Te veel verzoeken. Probeer het later opnieuw." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "コンテキストウィンドウ", - "state" : "translated" + "state" : "translated", + "value" : "Demasiados pedidos. Por favor, tente novamente mais tarde." } }, "sv" : { "stringUnit" : { - "value" : "Kontextfönster", - "state" : "translated" + "state" : "translated", + "value" : "För många förfrågningar. Försök igen senare." } } - }, - "comment" : "A section that displays the maximum number of tokens that can be processed in a single request." + } }, - "Deletes all local settings and credentials. iCloud data will not be affected." : { + "Top P" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Διαγράφει όλες τις τοπικές ρυθμίσεις και τα διαπιστευτήρια. Τα δεδομένα iCloud δεν θα επηρεαστούν." + "value" : "Top P" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Elimina todas las configuraciones y credenciales locales. Los datos de iCloud no se verán afectados.", - "state" : "translated" + "state" : "translated", + "value" : "Κορυφαίο P" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "すべてのローカル設定と認証情報を削除します。iCloudのデータには影響しません。" + "value" : "Top P" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Elimina tutte le impostazioni e le credenziali locali. I dati di iCloud non saranno interessati.", - "state" : "translated" + "state" : "translated", + "value" : "Top P" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Apaga todas as definições e credenciais locais. Os dados do iCloud não serão afetados.", - "state" : "translated" + "state" : "translated", + "value" : "Top P" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Deletes all local settings and credentials. iCloud data will not be affected.", - "state" : "translated" + "state" : "translated", + "value" : "Top P" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Supprime tous les paramètres et identifiants locaux. Les données iCloud ne seront pas affectées.", - "state" : "translated" + "state" : "translated", + "value" : "トップP" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Verwijdert alle lokale instellingen en inloggegevens. iCloud-gegevens blijven ongewijzigd." + "value" : "Top P" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Löscht alle lokalen Einstellungen und Anmeldedaten. iCloud-Daten bleiben unberührt.", - "state" : "translated" + "state" : "translated", + "value" : "Top P" } }, "sv" : { "stringUnit" : { - "value" : "Tar bort alla lokala inställningar och inloggningsuppgifter. iCloud-data påverkas inte.", - "state" : "translated" + "state" : "translated", + "value" : "Topp P" } } - }, - "comment" : "A footer for the reset button in the settings." + } }, - "File" : { + "Touch and hold a conversation to pin, rename, or add tags." : { + "comment" : "A description of the action to pin, rename, or add tags to a conversation.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Fil", - "state" : "translated" + "state" : "translated", + "value" : "Tippen und halten Sie eine Unterhaltung, um sie anzuheften, umzubenennen oder Tags hinzuzufügen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Archivo", - "state" : "translated" + "state" : "translated", + "value" : "Πατήστε παρατεταμένα μια συνομιλία για καρφίτσωμα, μετονομασία ή προσθήκη ετικετών." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ファイル" + "value" : "Touch and hold a conversation to pin, rename, or add tags" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "File", - "state" : "translated" + "state" : "translated", + "value" : "Mantén pulsada una conversación para anclar, renombrar o agregar etiquetas." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ficheiro", - "state" : "translated" + "state" : "translated", + "value" : "Touchez et maintenez une conversation pour l’épingler, la renommer ou ajouter des tags." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "File" + "value" : "Tocca e tieni premuta una conversazione per fissarla, rinominarla o aggiungere tag." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bestand", - "state" : "translated" + "state" : "translated", + "value" : "会話を長押しして、ピン留め、名前変更、またはタグの追加を行います。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Fichier" + "value" : "Houd een gesprek ingedrukt om vast te zetten, hernoemen of tags toe te voegen." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Αρχείο", - "state" : "translated" + "state" : "translated", + "value" : "Toque e mantenha uma conversa para fixar, renomear ou adicionar etiquetas." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Datei", - "state" : "translated" + "state" : "translated", + "value" : "Tryck och håll på en konversation för att fästa, byta namn eller lägga till taggar." } } } }, - "Quantum entanglement is a phenomenon where..." : { + "Touch and hold a message to edit, regenerate, branch, or save it as a favourite." : { + "comment" : "A description of the action to edit, regenerate, branch, or save a message.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Η κβαντική εμπλοκή είναι ένα φαινόμενο όπου...", - "state" : "translated" + "state" : "translated", + "value" : "Tippen und halten Sie eine Nachricht, um sie zu bearbeiten, neu zu generieren, zu verzweigen oder als Favorit zu speichern." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El entrelazamiento cuántico es un fenómeno donde...", - "state" : "translated" + "state" : "translated", + "value" : "Πατήστε παρατεταμένα ένα μήνυμα για να το επεξεργαστείτε, αναγεννήσετε, διακλαδώσετε ή αποθηκεύσετε στα αγαπημένα." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "量子もつれは、...という現象です" + "value" : "Touch and hold a message to edit, regenerate, branch, or save it as a favorite." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "L’entanglement quantistico è un fenomeno in cui...", - "state" : "translated" + "state" : "translated", + "value" : "Mantén pulsado un mensaje para editarlo, regenerarlo, ramificarlo o guardarlo como favorito." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "O entrelaçamento quântico é um fenómeno onde..." + "value" : "Touchez et maintenez un message pour le modifier, régénérer, créer une branche ou l’enregistrer en favori." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Quantum entanglement is a phenomenon where...", - "state" : "translated" + "state" : "translated", + "value" : "Tocca e tieni premuto un messaggio per modificarlo, rigenerarlo, creare un ramo o salvarlo tra i preferiti." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Quantumverstrengeling is een fenomeen waarbij...", - "state" : "translated" + "state" : "translated", + "value" : "メッセージを長押しして編集、再生成、分岐、またはお気に入りに保存します。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "L’intrication quantique est un phénomène où..." + "value" : "Raak een bericht aan en houd vast om het te bewerken, opnieuw te genereren, vertakken of als favoriet op te slaan." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Quantenverschränkung ist ein Phänomen, bei dem...", - "state" : "translated" + "state" : "translated", + "value" : "Toque e mantenha uma mensagem para editar, regenerar, ramificar ou guardar como favorita." } }, "sv" : { "stringUnit" : { - "value" : "Kvantintrassling är ett fenomen där...", - "state" : "translated" + "state" : "translated", + "value" : "Tryck och håll på ett meddelande för att redigera, generera om, förgrena eller spara det som favorit." } } - }, - "comment" : "Text of a message preview in a conversation." + } }, - "More" : { + "Transcribing..." : { + "comment" : "A placeholder text displayed when the user is recording audio.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Περισσότερα", - "state" : "translated" + "state" : "translated", + "value" : "Transkribiere..." } }, - "de" : { + "el" : { "stringUnit" : { - "value" : "Mehr", - "state" : "translated" + "state" : "translated", + "value" : "Μεταγραφή σε εξέλιξη..." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Más" + "value" : "Transcribing..." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Altro" + "value" : "Transcribiendo..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mais" + "value" : "Transcription en cours..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "More", - "state" : "translated" + "state" : "translated", + "value" : "Trascrizione in corso..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Meer", - "state" : "translated" + "state" : "translated", + "value" : "文字起こし中..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Plus", - "state" : "translated" + "state" : "translated", + "value" : "Bezig met transcriberen..." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "もっと見る", - "state" : "translated" + "state" : "translated", + "value" : "A transcrever..." } }, "sv" : { "stringUnit" : { - "value" : "Mer", - "state" : "translated" + "state" : "translated", + "value" : "Transkriberar..." } } - }, - "comment" : "A button that opens a menu with options to export and import conversations." + } }, - "Be the first to suggest something!" : { + "Translate text to another language" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "Sei der Erste, der etwas vorschlägt!", - "state" : "translated" + "state" : "translated", + "value" : "Text in eine andere Sprache übersetzen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¡Sé el primero en sugerir algo!", - "state" : "translated" + "state" : "translated", + "value" : "Μεταφράστε το κείμενο σε άλλη γλώσσα" } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "最初に提案しましょう!", - "state" : "translated" + "state" : "translated", + "value" : "Translate text to another language" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Sii il primo a suggerire qualcosa!" + "value" : "Traducir texto a otro idioma" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Seja o primeiro a sugerir algo!" + "value" : "Traduire le texte dans une autre langue" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Be the first to suggest something!", - "state" : "translated" + "state" : "translated", + "value" : "Traduci testo in un'altra lingua" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Soyez le premier à suggérer quelque chose !", - "state" : "translated" + "state" : "translated", + "value" : "テキストを別の言語に翻訳する" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Wees de eerste om iets voor te stellen!" + "value" : "Vertaal tekst naar een andere taal" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Να είστε ο πρώτος που θα προτείνει κάτι!", - "state" : "translated" + "state" : "translated", + "value" : "Traduzir texto para outra língua" } }, "sv" : { "stringUnit" : { - "value" : "Var den första att föreslå något!", - "state" : "translated" + "state" : "translated", + "value" : "Översätt text till ett annat språk" } } } }, - "Speech to Text" : { + "Translator" : { + "comment" : "Name of the prompt template for translating text.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ομιλία σε κείμενο" + "value" : "Übersetzer" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Voz a texto", - "state" : "translated" + "state" : "translated", + "value" : "Μεταφραστής" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tal till text" + "value" : "Translator" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Da voce a testo", - "state" : "translated" + "state" : "translated", + "value" : "Traductor" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Fala para Texto", - "state" : "translated" + "state" : "translated", + "value" : "Traducteur" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Speech to Text", - "state" : "translated" + "state" : "translated", + "value" : "Traduttore" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Spraak naar tekst", - "state" : "translated" + "state" : "translated", + "value" : "翻訳者" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Parole en texte", - "state" : "translated" + "state" : "translated", + "value" : "Vertaler" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "音声認識", - "state" : "translated" + "state" : "translated", + "value" : "Tradutor" } }, - "de" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Sprache zu Text" + "value" : "Översättare" } } - }, - "comment" : "A section title for speech-to-text models." + } }, - "Thinking" : { + "Type" : { + "comment" : "A label that describes the type of a model.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Denke", - "state" : "translated" + "state" : "translated", + "value" : "Typ" } }, - "sv" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Tänker" + "value" : "Τύπος" } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Pensando" + "value" : "Type" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sto pensando", - "state" : "translated" + "state" : "translated", + "value" : "Tipo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A pensar" + "value" : "Type" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Thinking", - "state" : "translated" + "state" : "translated", + "value" : "Tipo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bezig met nadenken", - "state" : "translated" + "state" : "translated", + "value" : "タイプ" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Réflexion en cours", - "state" : "translated" + "state" : "translated", + "value" : "Type" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "考え中", - "state" : "translated" + "state" : "translated", + "value" : "Tipo" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Σκέψη", - "state" : "translated" + "state" : "translated", + "value" : "Typ" } } - }, - "comment" : "A label displayed in a bubble that indicates that the assistant is thinking." + } }, - "Chat" : { + "Unable to Load Models" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "チャット", - "state" : "translated" + "state" : "translated", + "value" : "Modelle können nicht geladen werden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Chat", - "state" : "translated" + "state" : "translated", + "value" : "Αδυναμία φόρτωσης μοντέλων" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Συνομιλία" + "value" : "Unable to Load Models" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Chat" + "value" : "No se pueden cargar los modelos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Chat" + "value" : "Impossible de charger les modèles" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Chat", - "state" : "translated" + "state" : "translated", + "value" : "Impossibile caricare i modelli" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Chatten", - "state" : "translated" + "state" : "translated", + "value" : "モデルを読み込めません" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Discussion", - "state" : "translated" + "state" : "translated", + "value" : "Kan modellen niet laden" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Chat", - "state" : "translated" + "state" : "translated", + "value" : "Incapaz de carregar modelos" } }, "sv" : { "stringUnit" : { - "value" : "Chatt", - "state" : "translated" + "state" : "translated", + "value" : "Kan inte ladda modeller" } } - }, - "comment" : "A section of the settings view that deals with chat-related settings." + } }, - "Share your thoughts..." : { + "Unable to read the backup file." : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Μοιραστείτε τις σκέψεις σας...", - "state" : "translated" + "state" : "translated", + "value" : "Die Sicherungsdatei kann nicht gelesen werden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Comparte tus pensamientos...", - "state" : "translated" + "state" : "translated", + "value" : "Αδυναμία ανάγνωσης του αρχείου αντιγράφου ασφαλείας." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Teile deine Gedanken..." + "value" : "Unable to read the backup file." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Condividi i tuoi pensieri...", - "state" : "translated" + "state" : "translated", + "value" : "No se puede leer el archivo de copia de seguridad." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Partilhe as suas ideias...", - "state" : "translated" + "state" : "translated", + "value" : "Impossible de lire le fichier de sauvegarde." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Share your thoughts..." + "value" : "Impossibile leggere il file di backup." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Deel je gedachten...", - "state" : "translated" + "state" : "translated", + "value" : "バックアップファイルを読み取れません。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Partagez vos pensées..." + "value" : "Kan het back-upbestand niet lezen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "あなたの考えを共有してください...", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível ler o ficheiro de backup." } }, "sv" : { "stringUnit" : { - "value" : "Dela dina tankar...", - "state" : "translated" + "state" : "translated", + "value" : "Kan inte läsa säkerhetskopieringsfilen." } } } }, - "All Tags" : { + "Unable to save the backup file." : { + "comment" : "Error message displayed when there is an issue writing the backup file.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "すべてのタグ" + "value" : "Die Sicherungsdatei konnte nicht gespeichert werden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Todas las etiquetas", - "state" : "translated" + "state" : "translated", + "value" : "Αδυναμία αποθήκευσης του αρχείου αντιγράφου ασφαλείας." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Alle Tags" + "value" : "Unable to save the backup file." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Tutti i tag", - "state" : "translated" + "state" : "translated", + "value" : "No se pudo guardar el archivo de respaldo." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Todas as Etiquetas", - "state" : "translated" + "state" : "translated", + "value" : "Impossible d’enregistrer le fichier de sauvegarde." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "All Tags", - "state" : "translated" + "state" : "translated", + "value" : "Impossibile salvare il file di backup." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Tous les tags", - "state" : "translated" + "state" : "translated", + "value" : "バックアップファイルを保存できませんでした。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alle tags" + "value" : "Kan het back-upbestand niet opslaan." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Όλες οι ετικέτες", - "state" : "translated" + "state" : "translated", + "value" : "Não foi possível guardar o ficheiro de cópia de segurança." } }, "sv" : { "stringUnit" : { - "value" : "Alla taggar", - "state" : "translated" + "state" : "translated", + "value" : "Kunde inte spara säkerhetskopian." } } - }, - "comment" : "The default tag to be selected when the widget is configured." + } }, - "Opens a new conversation in OpenClient." : { + "Unknown" : { + "comment" : "A label for an unknown LLM model.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Öffnet eine neue Unterhaltung in OpenClient." + "value" : "Unbekannt" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Abre una nueva conversación en OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Άγνωστο" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ανοίγει μια νέα συνομιλία στο OpenClient." + "value" : "Unknown" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apre una nuova conversazione in OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Desconocido" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Abre uma nova conversa no OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Inconnu" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Opens a new conversation in OpenClient" + "value" : "Sconosciuto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Opent een nieuw gesprek in OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "不明" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ouvre une nouvelle conversation dans OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Onbekend" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenClientで新しい会話を開始します", - "state" : "translated" + "state" : "translated", + "value" : "Desconhecido" } }, "sv" : { "stringUnit" : { - "value" : "Öppnar en ny konversation i OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Okänd" } } - }, - "comment" : "Description of the control center widget that opens a new conversation in OpenClient." + } }, - "Indigo" : { + "Unpin" : { + "comment" : "A label for un-pinning a conversation.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ινδικό", - "state" : "translated" + "state" : "translated", + "value" : "Anheften aufheben" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Índigo", - "state" : "translated" + "state" : "translated", + "value" : "Αποκόλληση" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "インディゴ" + "value" : "Unpin" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Indaco" + "value" : "Desfijar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Índigo", - "state" : "translated" + "state" : "translated", + "value" : "Détacher" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Indigo", - "state" : "translated" + "state" : "translated", + "value" : "Sblocca dalla barra" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Indigo", - "state" : "translated" + "state" : "translated", + "value" : "ピン留め解除" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Indigo" + "value" : "Losmaken" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Indigo", - "state" : "translated" + "state" : "translated", + "value" : "Desafixar" } }, "sv" : { "stringUnit" : { - "value" : "Indigo", - "state" : "translated" + "state" : "translated", + "value" : "Ta bort fästning" } } - }, - "comment" : "Name of the color indigo." + } }, - "Submit" : { + "Update" : { + "comment" : "A button that updates the app.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Skicka" + "value" : "Aktualisieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Enviar", - "state" : "translated" + "state" : "translated", + "value" : "Ενημέρωση" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Υποβολή" + "value" : "Update" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Invia", - "state" : "translated" + "state" : "translated", + "value" : "Actualizar" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Enviar", - "state" : "translated" + "state" : "translated", + "value" : "Mettre à jour" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Submit", - "state" : "translated" + "state" : "translated", + "value" : "Aggiorna" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Envoyer", - "state" : "translated" + "state" : "translated", + "value" : "アップデート" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Verzenden" + "value" : "Bijwerken" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "送信", - "state" : "translated" + "state" : "translated", + "value" : "Atualizar" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Senden", - "state" : "translated" + "state" : "translated", + "value" : "Uppdatera" } } } }, - "Are you sure you want to delete this comment?" : { + "Update available" : { + "comment" : "A title for an alert that notifies the user that an update is available.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "このコメントを削除してもよろしいですか?", - "state" : "translated" + "state" : "translated", + "value" : "Update verfügbar" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¿Seguro que quieres eliminar este comentario?", - "state" : "translated" + "state" : "translated", + "value" : "Διαθέσιμη ενημέρωση" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το σχόλιο;" + "value" : "Update available" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Sei sicuro di voler eliminare questo commento?", - "state" : "translated" + "state" : "translated", + "value" : "Actualización disponible" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Tem a certeza de que pretende eliminar este comentário?" + "value" : "Mise à jour disponible" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Are you sure you want to delete this comment?", - "state" : "translated" + "state" : "translated", + "value" : "Aggiornamento disponibile" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Êtes-vous sûr de vouloir supprimer ce commentaire ?", - "state" : "translated" + "state" : "translated", + "value" : "アップデートがあります" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Weet je zeker dat je deze opmerking wilt verwijderen?" + "value" : "Update beschikbaar" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Möchten Sie diesen Kommentar wirklich löschen?", - "state" : "translated" + "state" : "translated", + "value" : "Atualização disponível" } }, "sv" : { "stringUnit" : { - "value" : "Är du säker på att du vill ta bort den här kommentaren?", - "state" : "translated" + "state" : "translated", + "value" : "Uppdatering tillgänglig" } } } }, - "Notifications disabled" : { + "Update OpenClient" : { + "comment" : "A button that updates the OpenClient app.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "通知が無効になっています", - "state" : "translated" + "state" : "translated", + "value" : "OpenClient aktualisieren" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Notificaciones desactivadas", - "state" : "translated" + "state" : "translated", + "value" : "Ενημέρωση του OpenClient" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Benachrichtigungen deaktiviert" + "value" : "Update OpenClient" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Notifiche disattivate" + "value" : "Actualizar OpenClient" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Notificações desativadas", - "state" : "translated" + "state" : "translated", + "value" : "Mettre à jour OpenClient" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Notifications disabled", - "state" : "translated" + "state" : "translated", + "value" : "Aggiorna OpenClient" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Notifications désactivées", - "state" : "translated" + "state" : "translated", + "value" : "OpenClientをアップデート" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Meldingen uitgeschakeld" + "value" : "OpenClient bijwerken" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ειδοποιήσεις απενεργοποιημένες", - "state" : "translated" + "state" : "translated", + "value" : "Atualizar o OpenClient" } }, "sv" : { "stringUnit" : { - "value" : "Aviseringar avstängda", - "state" : "translated" + "state" : "translated", + "value" : "Uppdatera OpenClient" } } - }, - "comment" : "A label that indicates that notifications are disabled." + } }, - "Only completed" : { + "Update OpenClient to version %@ to continue using the app." : { + "comment" : "A description of the update process.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Μόνο ολοκληρωμένα", - "state" : "translated" + "state" : "translated", + "value" : "Aktualisieren Sie OpenClient auf Version %@, um die App weiterhin zu verwenden." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Solo completados", - "state" : "translated" + "state" : "translated", + "value" : "Ενημερώστε το OpenClient στην έκδοση %@ για να συνεχίσετε να χρησιμοποιείτε την εφαρμογή." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nur abgeschlossen" + "value" : "Update OpenClient to version %@ to continue using the app." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Solo completati", - "state" : "translated" + "state" : "translated", + "value" : "Actualiza OpenClient a la versión %@ para seguir usando la aplicación." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Apenas concluídos", - "state" : "translated" + "state" : "translated", + "value" : "Mettez OpenClient à jour vers la version %@ pour continuer à utiliser l’app." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Only completed" + "value" : "Aggiorna OpenClient alla versione %@ per continuare a utilizzare l’app." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Uniquement terminés", - "state" : "translated" + "state" : "translated", + "value" : "アプリを引き続き使用するには、OpenClientをバージョン%@にアップデートしてください。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Alleen voltooid" + "value" : "Werk OpenClient bij naar versie %@ om de app te blijven gebruiken." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "完了のみ", - "state" : "translated" + "state" : "translated", + "value" : "Atualize o OpenClient para a versão %@ para continuar a utilizar a aplicação." } }, "sv" : { "stringUnit" : { - "value" : "Endast slutförda", - "state" : "translated" + "state" : "translated", + "value" : "Uppdatera OpenClient till version %@ för att fortsätta använda appen." } } } }, - "Open a new conversation in OpenClient." : { + "Update required" : { + "comment" : "A title for the update required alert.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "OpenClientで新しい会話を開始する", - "state" : "translated" + "state" : "translated", + "value" : "Update erforderlich" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Abrir una nueva conversación en OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Απαιτείται ενημέρωση" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Eine neue Unterhaltung in OpenClient starten." + "value" : "Update required" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Apri una nuova conversazione in OpenClient" + "value" : "Actualización necesaria" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir uma nova conversa no OpenClient." + "value" : "Mise à jour requise" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Open a new conversation in OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Aggiornamento richiesto" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Open een nieuw gesprek in OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "アップデートが必要です" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ouvrir une nouvelle conversation dans OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Update vereist" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Άνοιγμα νέας συνομιλίας στο OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Atualização necessária" } }, "sv" : { "stringUnit" : { - "value" : "Öppna en ny konversation i OpenClient.", - "state" : "translated" + "state" : "translated", + "value" : "Uppdatering krävs" } } - }, - "comment" : "Description of the New Chat widget." + } }, - "Information" : { + "URL Scheme" : { + "comment" : "A label that describes the URL scheme feature.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Information" + "value" : "URL-Schema" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Información", - "state" : "translated" + "state" : "translated", + "value" : "Σχήμα URL" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Information" + "value" : "URL Scheme" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Informazioni", - "state" : "translated" + "state" : "translated", + "value" : "Esquema de URL" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Informação", - "state" : "translated" + "state" : "translated", + "value" : "Schéma d’URL" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Information", - "state" : "translated" + "state" : "translated", + "value" : "Schema URL" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Informatie", - "state" : "translated" + "state" : "translated", + "value" : "URLスキーム" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Informations" + "value" : "URL-schema" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "情報", - "state" : "translated" + "state" : "translated", + "value" : "Esquema URL" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Πληροφορίες", - "state" : "translated" + "state" : "translated", + "value" : "URL-schema" } } } }, - "Remove from Favourites" : { + "Use iCloud Data" : { + "comment" : "A button that selects iCloud data as the preferred data source.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Aus Favoriten entfernen", - "state" : "translated" + "state" : "translated", + "value" : "iCloud-Daten verwenden" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Quitar de Favoritos" + "value" : "Χρήση δεδομένων iCloud" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Αφαίρεση από Αγαπημένα" + "value" : "Use iCloud Data" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Rimuovi dai Preferiti" + "value" : "Usar datos de iCloud" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Remover dos Favoritos", - "state" : "translated" + "state" : "translated", + "value" : "Utiliser les données iCloud" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Remove from Favorites", - "state" : "translated" + "state" : "translated", + "value" : "Usa dati iCloud" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Verwijderen uit favorieten", - "state" : "translated" + "state" : "translated", + "value" : "iCloudデータを使用" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Retirer des favoris", - "state" : "translated" + "state" : "translated", + "value" : "Gebruik iCloud-gegevens" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "お気に入りから削除", - "state" : "translated" + "state" : "translated", + "value" : "Usar dados do iCloud" } }, "sv" : { "stringUnit" : { - "value" : "Ta bort från favoriter", - "state" : "translated" + "state" : "translated", + "value" : "Använd iCloud-data" } } - }, - "comment" : "A label for removing a message from the user's favourites." + } }, - "Yesterday" : { + "Use Local Data" : { + "comment" : "A button that uses the local data.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "昨日" + "value" : "Lokale Daten verwenden" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Ayer", - "state" : "translated" + "state" : "translated", + "value" : "Χρήση τοπικών δεδομένων" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Χθες" + "value" : "Use Local Data" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Ieri" + "value" : "Usar datos locales" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Ontem", - "state" : "translated" + "state" : "translated", + "value" : "Utiliser les données locales" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Yesterday", - "state" : "translated" + "state" : "translated", + "value" : "Usa dati locali" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gisteren", - "state" : "translated" + "state" : "translated", + "value" : "ローカルデータを使用" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Hier", - "state" : "translated" + "state" : "translated", + "value" : "Gebruik lokale gegevens" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Gestern", - "state" : "translated" + "state" : "translated", + "value" : "Usar Dados Locais" } }, "sv" : { "stringUnit" : { - "value" : "Igår", - "state" : "translated" + "state" : "translated", + "value" : "Använd lokal data" } } } }, - "Ok" : { + "Use these sources to answer the user's question. Cite sources using [Source Title](URL) format." : { + "comment" : "Citation guide for web search results.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Verwenden Sie diese Quellen, um die Frage des Benutzers zu beantworten. Zitieren Sie Quellen im Format [Quellentitel](URL)." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Χρησιμοποιήστε αυτές τις πηγές για να απαντήσετε στην ερώτηση του χρήστη. Αναφέρετε τις πηγές χρησιμοποιώντας τη μορφή [Τίτλος Πηγής](URL)." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Use these sources to answer the user's question. Cite sources using [Source Title](URL) format." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Utilice estas fuentes para responder a la pregunta del usuario. Cite las fuentes usando el formato [Título de la fuente](URL)." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Utilisez ces sources pour répondre à la question de l'utilisateur. Citez les sources en utilisant le format [Titre de la source](URL)." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Ok", - "state" : "translated" + "state" : "translated", + "value" : "Usa queste fonti per rispondere alla domanda dell'utente. Cita le fonti utilizzando il formato [Titolo della fonte](URL)." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "これらの情報源を使用してユーザーの質問に回答してください。情報源は[情報源タイトル](URL)形式で引用してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Gebruik deze bronnen om de vraag van de gebruiker te beantwoorden. Verwijs naar bronnen met de notatie [Bron Titel](URL)." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Utilize estas fontes para responder à pergunta do utilizador. Cite as fontes usando o formato [Título da Fonte](URL)." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "OK", - "state" : "translated" + "state" : "translated", + "value" : "Använd dessa källor för att besvara användarens fråga. Ange källor med formatet [Källtitel](URL)." } } } }, - "%lld tokens" : { + "Use this when an OpenAI-compatible server does not provide context metadata." : { "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "value" : "%lld tokens", - "state" : "translated" + "state" : "translated", + "value" : "Verwenden Sie dies, wenn ein OpenAI-kompatibler Server keine Kontextmetadaten bereitstellt." } - } - }, - "shouldTranslate" : false - }, - "Refresh Tools" : { - "localizations" : { - "sv" : { + }, + "el" : { "stringUnit" : { - "value" : "Uppdatera verktyg", - "state" : "translated" + "state" : "translated", + "value" : "Χρησιμοποιήστε το όταν ένας διακομιστής συμβατός με OpenAI δεν παρέχει μεταδεδομένα συμφραζομένων." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ツールを更新" + "value" : "Use this when an OpenAI-compatible server does not provide context metadata" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Actualizar herramientas" + "value" : "Usa esto cuando un servidor compatible con OpenAI no proporcione metadatos de contexto." } }, - "it" : { + "fr" : { "stringUnit" : { - "value" : "Aggiorna strumenti", - "state" : "translated" + "state" : "translated", + "value" : "Utilisez ceci lorsqu’un serveur compatible OpenAI ne fournit pas de métadonnées contextuelles." } }, - "pt-PT" : { + "it" : { "stringUnit" : { - "value" : "Atualizar Ferramentas", - "state" : "translated" + "state" : "translated", + "value" : "Usa questo quando un server compatibile con OpenAI non fornisce metadati di contesto." } }, - "en" : { + "ja" : { "stringUnit" : { - "value" : "Refresh Tools", - "state" : "translated" + "state" : "translated", + "value" : "OpenAI互換サーバーがコンテキストメタデータを提供しない場合に使用してください。" } }, "nl" : { - "stringUnit" : { - "value" : "Vernieuw Hulpmiddelen", - "state" : "translated" - } - }, - "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Actualiser les outils" + "value" : "Gebruik dit wanneer een OpenAI-compatibele server geen contextmetadata levert." } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ανανέωση Εργαλείων", - "state" : "translated" + "state" : "translated", + "value" : "Utilize isto quando um servidor compatível com OpenAI não fornecer metadados de contexto." } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Werkzeuge aktualisieren", - "state" : "translated" + "state" : "translated", + "value" : "Använd detta när en OpenAI-kompatibel server inte tillhandahåller kontextmetadata." } } - }, - "comment" : "A button that refreshes the list of search tools." + } }, - "Terms of Use" : { + "Version %@ (%@)" : { + "comment" : "A label displaying the current version of the app and its build number. The first argument is the string “CFBundleShortVersionString” or the string “—”. The second argument is the string “CFBundleVersion” or the string “—”.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Nutzungsbedingungen", - "state" : "translated" + "state" : "translated", + "value" : "Version %1$@ (%2$@)" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Términos de uso", - "state" : "translated" + "state" : "translated", + "value" : "Έκδοση %1$@ (%2$@)" } }, - "el" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Όροι Χρήσης" + "state" : "new", + "value" : "Version %1$@ (%2$@)" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Termini di utilizzo", - "state" : "translated" + "state" : "translated", + "value" : "Versión %1$@ (%2$@)" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Termos de Utilização", - "state" : "translated" + "state" : "translated", + "value" : "Version %1$@ (%2$@)" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Terms of Use", - "state" : "translated" + "state" : "translated", + "value" : "Versione %1$@ (%2$@)" } }, - "fr" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Conditions d’utilisation" + "value" : "バージョン %1$@(%2$@)" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruiksvoorwaarden" + "value" : "Versie %1$@ (%2$@)" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "利用規約", - "state" : "translated" + "state" : "translated", + "value" : "Versão %1$@ (%2$@)" } }, "sv" : { "stringUnit" : { - "value" : "Användarvillkor", - "state" : "translated" + "state" : "translated", + "value" : "Version %1$@ (%2$@)" } } } }, - "Thank you! ☕" : { + "Very creative" : { "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Ευχαριστούμε! ☕", - "state" : "translated" + "state" : "translated", + "value" : "Sehr kreativ" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "¡Gracias! ☕", - "state" : "translated" + "state" : "translated", + "value" : "Πολύ δημιουργικό" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Danke! ☕" + "value" : "Very creative" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Grazie! ☕", - "state" : "translated" + "state" : "translated", + "value" : "Muy creativo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Obrigado! ☕" + "value" : "Très créatif" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Thank you! ☕" + "value" : "Molto creativo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Bedankt! ☕", - "state" : "translated" + "state" : "translated", + "value" : "とても創造的" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Merci ! ☕", - "state" : "translated" + "state" : "translated", + "value" : "Zeer creatief" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "ありがとうございます!☕", - "state" : "translated" + "state" : "translated", + "value" : "Muito criativo" } }, "sv" : { "stringUnit" : { - "value" : "Tack! ☕", - "state" : "translated" + "state" : "translated", + "value" : "Mycket kreativ" } } - }, - "comment" : "A title for a system alert that appears after a user purchases a tip." + } }, - "Rejected" : { + "Voice" : { + "comment" : "A label displayed above a list of available voices.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Abgelehnt", - "state" : "translated" + "state" : "translated", + "value" : "Stimme" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Rechazado", - "state" : "translated" + "state" : "translated", + "value" : "Φωνή" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "拒否されました" + "value" : "Voice" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Rifiutato", - "state" : "translated" + "state" : "translated", + "value" : "Voz" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Rejeitado", - "state" : "translated" + "state" : "translated", + "value" : "Voix" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Rejected", - "state" : "translated" + "state" : "translated", + "value" : "Voce" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Geweigerd" + "value" : "音声" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Rejeté" + "value" : "Stem" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Απορρίφθηκε", - "state" : "translated" + "state" : "translated", + "value" : "Vozes" } }, "sv" : { "stringUnit" : { - "value" : "Avvisad", - "state" : "translated" + "state" : "translated", + "value" : "Röst" } } } }, - "Delete suggestion" : { + "Voice ID" : { + "comment" : "A label for the voice ID field.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "提案を削除", - "state" : "translated" + "state" : "translated", + "value" : "Sprach-ID" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eliminar sugerencia", - "state" : "translated" + "state" : "translated", + "value" : "Ταυτότητα φωνής" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Vorschlag löschen" + "value" : "Voice ID" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Elimina suggerimento", - "state" : "translated" + "state" : "translated", + "value" : "ID de voz" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Eliminar sugestão", - "state" : "translated" + "state" : "translated", + "value" : "ID vocal" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Delete suggestion", - "state" : "translated" + "state" : "translated", + "value" : "ID voce" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Suggestie verwijderen" + "value" : "音声ID" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Supprimer la suggestion" + "value" : "Stem-ID" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Διαγραφή πρότασης", - "state" : "translated" + "state" : "translated", + "value" : "ID de voz" } }, "sv" : { "stringUnit" : { - "value" : "Ta bort förslag", - "state" : "translated" + "state" : "translated", + "value" : "Röst-ID" } } } }, - "Delete comment" : { + "votes" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Kommentar löschen" + "value" : "Stimmen" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eliminar comentario", - "state" : "translated" + "state" : "translated", + "value" : "ψήφοι" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "コメントを削除" + "value" : "votes" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Elimina commento", - "state" : "translated" + "state" : "translated", + "value" : "votos" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Eliminar comentário", - "state" : "translated" + "state" : "translated", + "value" : "votes" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Delete comment" + "value" : "voti" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Reactie verwijderen", - "state" : "translated" + "state" : "translated", + "value" : "投票数" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Supprimer le commentaire", - "state" : "translated" + "state" : "translated", + "value" : "stemmen" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Διαγραφή σχολίου", - "state" : "translated" + "state" : "translated", + "value" : "votos" } }, "sv" : { "stringUnit" : { - "value" : "Radera kommentar", - "state" : "translated" + "state" : "translated", + "value" : "röster" } } } }, - "New Private Chat" : { + "Waiting for iCloud downloads: %@." : { + "comment" : "A message that indicates that some data is being downloaded from iCloud. The argument is a list of categories separated by commas.", + "isCommentAutoGenerated" : true + }, + "Warning" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "新しいプライベートチャット", - "state" : "translated" + "state" : "translated", + "value" : "Warnung" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nuevo chat privado", - "state" : "translated" + "state" : "translated", + "value" : "Προειδοποίηση" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ny privatchatt" + "value" : "Warning" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuova chat privata", - "state" : "translated" + "state" : "translated", + "value" : "Advertencia" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nova Conversa Privada" + "value" : "Avertissement" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New Private Chat", - "state" : "translated" + "state" : "translated", + "value" : "Avviso" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nouvelle discussion privée", - "state" : "translated" + "state" : "translated", + "value" : "警告" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nieuw privégesprek" + "value" : "Waarschuwing" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Νέα Ιδιωτική Συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Aviso" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Neuer privater Chat", - "state" : "translated" + "state" : "translated", + "value" : "Varning" } } - }, - "comment" : "A label for a button that opens a new private chat." + } }, - "Customise this conversation" : { + "We're making a few improvements. Please try again later." : { + "comment" : "A message displayed when the app is under maintenance.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Diese Unterhaltung anpassen", - "state" : "translated" + "state" : "translated", + "value" : "Wir nehmen einige Verbesserungen vor. Bitte versuchen Sie es später erneut." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Personalizar esta conversación", - "state" : "translated" + "state" : "translated", + "value" : "Κάνουμε μερικές βελτιώσεις. Δοκιμάστε ξανά αργότερα." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Προσαρμόστε αυτή τη συνομιλία" + "value" : "We're making a few improvements. Please try again later." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Personalizza questa conversazione", - "state" : "translated" + "state" : "translated", + "value" : "Estamos realizando algunas mejoras. Vuelve a intentarlo más tarde." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Personalizar esta conversa", - "state" : "translated" + "state" : "translated", + "value" : "Nous apportons quelques améliorations. Veuillez réessayer plus tard." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Customize this conversation" + "value" : "Stiamo apportando alcuni miglioramenti. Riprova più tardi." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Personnaliser cette conversation", - "state" : "translated" + "state" : "translated", + "value" : "いくつか改善を行っています。しばらくしてからもう一度お試しください。" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Pas dit gesprek aan" + "value" : "We voeren enkele verbeteringen door. Probeer het later opnieuw." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "この会話をカスタマイズする", - "state" : "translated" + "state" : "translated", + "value" : "Estamos a fazer algumas melhorias. Tente novamente mais tarde." } }, "sv" : { "stringUnit" : { - "value" : "Anpassa den här konversationen", - "state" : "translated" + "state" : "translated", + "value" : "Vi gör några förbättringar. Försök igen senare." } } - }, - "comment" : "A label for a menu that allows users to customise their current conversation." + } }, - "OpenClient" : { + "Web Search" : { + "comment" : "A section of the settings view that allows the user to configure the web search tool.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient" + "value" : "Websuche" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Αναζήτηση στο Διαδίκτυο" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient" + "value" : "Web Search" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "OpenClient" + "value" : "Búsqueda web" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Recherche Web" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Ricerca Web" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "ウェブ検索" } }, "nl" : { "stringUnit" : { - "value" : "OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Webzoekfunctie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Pesquisa Web" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "OpenClient", - "state" : "translated" + "state" : "translated", + "value" : "Webbsökning" } } - }, - "comment" : "The name of the app." + } }, - "New Chat" : { + "Work" : { + "comment" : "A placeholder tag.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Νέα Συνομιλία" + "value" : "Arbeit" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nuevo chat", - "state" : "translated" + "state" : "translated", + "value" : "Εργασία" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Neuer Chat" + "value" : "Work" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nuova chat" + "value" : "Trabajo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nova Conversa", - "state" : "translated" + "state" : "translated", + "value" : "Travail" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New Chat", - "state" : "translated" + "state" : "translated", + "value" : "Lavoro" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nieuw gesprek", - "state" : "translated" + "state" : "translated", + "value" : "作業" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Nouveau chat", - "state" : "translated" + "state" : "translated", + "value" : "Werk" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "新しいチャット", - "state" : "translated" + "state" : "translated", + "value" : "Trabalho" } }, "sv" : { "stringUnit" : { - "value" : "Ny chatt", - "state" : "translated" + "state" : "translated", + "value" : "Arbete" } } } }, - "Open in App" : { + "Write a creative story" : { "localizations" : { "de" : { "stringUnit" : { - "value" : "In App öffnen", - "state" : "translated" + "state" : "translated", + "value" : "Schreibe eine kreative Geschichte" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir en la app" + "value" : "Γράψε μια δημιουργική ιστορία" } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Άνοιγμα στην εφαρμογή" + "value" : "Write a creative story" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Apri nell’app", - "state" : "translated" + "state" : "translated", + "value" : "Escribe una historia creativa" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Abrir na App", - "state" : "translated" + "state" : "translated", + "value" : "Écris une histoire créative" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Open in App" + "value" : "Scrivi una storia creativa" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Openen in app", - "state" : "translated" + "state" : "translated", + "value" : "創造的な物語を書く" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Ouvrir dans l’app", - "state" : "translated" + "state" : "translated", + "value" : "Schrijf een creatief verhaal" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "アプリで開く", - "state" : "translated" + "state" : "translated", + "value" : "Escreve uma história criativa" } }, "sv" : { "stringUnit" : { - "value" : "Öppna i appen", - "state" : "translated" + "state" : "translated", + "value" : "Skriv en kreativ berättelse" } } - }, - "comment" : "A button that opens the app." + } }, - "Imported %lld conversations, restored %lld attachments, and skipped %lld attachments." : { + "Yellow" : { + "comment" : "Name of the color yellow.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εισήχθησαν %1$lld συνομιλίες, αποκαταστάθηκαν %2$lld συνημμένα και παραλείφθηκαν %3$lld συνημμένα.", - "state" : "translated" + "state" : "translated", + "value" : "Gelb" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Se importaron %1$lld conversaciones, se restauraron %2$lld archivos adjuntos y se omitieron %3$lld archivos adjuntos.", - "state" : "translated" + "state" : "translated", + "value" : "Κίτρινο" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Importerade %1$lld konversationer, återställde %2$lld bilagor och hoppade över %3$lld bilagor." + "value" : "Yellow" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Importate %1$lld conversazioni, ripristinati %2$lld allegati e saltati %3$lld allegati.", - "state" : "translated" + "state" : "translated", + "value" : "Amarillo" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Importadas %1$lld conversas, restaurados %2$lld anexos e ignorados %3$lld anexos." + "value" : "Jaune" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Imported %1$lld conversations, restored %2$lld attachments, and skipped %3$lld attachments.", - "state" : "new" + "state" : "translated", + "value" : "Giallo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "%1$lld gesprekken geïmporteerd, %2$lld bijlagen hersteld en %3$lld bijlagen overgeslagen.", - "state" : "translated" + "state" : "translated", + "value" : "黄色" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld conversations importées, %2$lld pièces jointes restaurées, et %3$lld pièces jointes ignorées." + "value" : "Geel" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "%1$lld 件の会話をインポートし、%2$lld 件の添付ファイルを復元し、%3$lld 件の添付ファイルをスキップしました。", - "state" : "translated" + "state" : "translated", + "value" : "Amarelo" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "%1$lld Konversationen importiert, %2$lld Anhänge wiederhergestellt und %3$lld Anhänge übersprungen.", - "state" : "translated" + "state" : "translated", + "value" : "Gul" } } } }, - "Attach Image" : { + "Yesterday" : { "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "画像を添付", - "state" : "translated" + "state" : "translated", + "value" : "Gestern" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Adjuntar imagen", - "state" : "translated" + "state" : "translated", + "value" : "Χθες" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bild anhängen" + "value" : "Yesterday" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Allega immagine" + "value" : "Ayer" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Anexar imagem" + "value" : "Hier" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Attach Image", - "state" : "translated" + "state" : "translated", + "value" : "Ieri" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Afbeelding toevoegen", - "state" : "translated" + "state" : "translated", + "value" : "昨日" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Joindre une image", - "state" : "translated" + "state" : "translated", + "value" : "Gisteren" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επισύναψη εικόνας", - "state" : "translated" + "state" : "translated", + "value" : "Ontem" } }, "sv" : { "stringUnit" : { - "value" : "Bifoga bild", - "state" : "translated" + "state" : "translated", + "value" : "Igår" } } } }, - "Start a new conversation" : { + "You" : { + "comment" : "A name for the user.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "新しい会話を始める" + "value" : "Du" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Iniciar una nueva conversación", - "state" : "translated" + "state" : "translated", + "value" : "Εσύ" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Neue Unterhaltung starten" + "value" : "You" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Inizia una nuova conversazione" + "value" : "Tú" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Iniciar nova conversa", - "state" : "translated" + "state" : "translated", + "value" : "Vous" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Start a new conversation", - "state" : "translated" + "state" : "translated", + "value" : "Tu" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Begin een nieuw gesprek", - "state" : "translated" + "state" : "translated", + "value" : "あなた" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Commencer une nouvelle conversation", - "state" : "translated" + "state" : "translated", + "value" : "Jij" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Ξεκινήστε μια νέα συνομιλία", - "state" : "translated" + "state" : "translated", + "value" : "Tu" } }, "sv" : { "stringUnit" : { - "value" : "Starta en ny konversation", - "state" : "translated" + "state" : "translated", + "value" : "Du" } } - }, - "comment" : "Shortcut action to start a new chat." + } }, - "No MCP servers loaded. Tap \"Load Available Tools\" to fetch them from your server." : { - "comment" : "A label that describes the state when no MCP servers are available.", + "You are a concise summarizer. Extract the key points from any text the user provides. Present summaries in clear bullet points. Focus on the most important information and omit redundant details." : { + "comment" : "Description of the summarizer assistant.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Keine MCP-Server geladen. Tippen Sie auf „Verfügbare Tools laden“, um sie von Ihrem Server abzurufen." + "value" : "Du bist ein prägnanter Zusammenfasser. Extrahiere die wichtigsten Punkte aus jedem vom Nutzer bereitgestellten Text. Präsentiere Zusammenfassungen in klaren Aufzählungspunkten. Konzentriere dich auf die wichtigsten Informationen und lasse redundante Details weg." } }, - "en" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "No MCP servers loaded. Tap \"Load Available Tools\" to fetch them from your server." + "value" : "Είστε συνοπτικός περιληπτής. Εξάγετε τα βασικά σημεία από οποιοδήποτε κείμενο παρέχει ο χρήστης. Παρουσιάζετε τις περιλήψεις με σαφή κουκκίδες. Επικεντρωθείτε στις πιο σημαντικές πληροφορίες και παραλείψτε τις επαναλαμβανόμενες λεπτομέρειες." } }, - "pt-PT" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nenhum servidor MCP carregado. Toque em \"Carregar Ferramentas Disponíveis\" para os obter do seu servidor." + "value" : "- Concise summarizer \n- Extracts key points from user-provided text \n- Presents summaries in clear bullet points \n- Focuses on most important information \n- Omits redundant details" } }, - "ja" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "MCPサーバーが読み込まれていません。「利用可能なツールを読み込む」をタップしてサーバーから取得してください。" + "value" : "Eres un resumidor conciso. Extrae los puntos clave de cualquier texto que el usuario proporcione. Presenta resúmenes en viñetas claras. Enfócate en la información más importante y omite detalles redundantes." } }, - "nl" : { + "fr" : { "stringUnit" : { - "value" : "Geen MCP-servers geladen. Tik op \"Beschikbare tools laden\" om ze van je server op te halen.", - "state" : "translated" + "state" : "translated", + "value" : "Vous êtes un résumé concis. Extrait les points clés de tout texte fourni par l’utilisateur. Présente les résumés sous forme de puces claires. Concentre-toi sur l’information la plus importante et omets les détails redondants." } }, - "fr" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Aucun serveur MCP chargé. Appuyez sur « Charger les outils disponibles » pour les récupérer depuis votre serveur." + "value" : "Sei un riassuntore conciso. Estrai i punti chiave da qualsiasi testo fornito dall’utente. Presenta i riassunti in elenchi puntati chiari. Concentrati sulle informazioni più importanti ed elimina i dettagli ridondanti." } }, - "it" : { + "ja" : { "stringUnit" : { - "value" : "Nessun server MCP caricato. Tocca \"Carica Strumenti Disponibili\" per recuperarli dal tuo server.", - "state" : "translated" + "state" : "translated", + "value" : "簡潔な要約者です。 \nユーザーが提供するテキストから重要なポイントを抽出します。 \n要約は明確な箇条書きで提示します。 \n最も重要な情報に焦点を当て、冗長な詳細は省きます。" } }, - "sv" : { + "nl" : { "stringUnit" : { - "value" : "Inga MCP-servrar laddade. Tryck på \"Ladda tillgängliga verktyg\" för att hämta dem från din server.", - "state" : "translated" + "state" : "translated", + "value" : "Je bent een beknopte samenvatter. Haal de belangrijkste punten uit elke tekst die de gebruiker aanlevert. Presenteer samenvattingen in duidelijke opsommingstekens. Richt je op de belangrijkste informatie en laat overbodige details weg." } }, - "el" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Δεν έχουν φορτωθεί MCP διακομιστές. Πατήστε «Φόρτωση Διαθέσιμων Εργαλείων» για να τους λάβετε από τον διακομιστή σας." + "value" : "És um resumidor conciso. Extrai os pontos-chave de qualquer texto fornecido pelo utilizador. Apresenta os resumos em tópicos claros. Foca-te na informação mais importante e omite detalhes redundantes." } }, - "es" : { + "sv" : { "stringUnit" : { "state" : "translated", - "value" : "No se cargaron servidores MCP. Toca \"Cargar herramientas disponibles\" para obtenerlos de tu servidor." + "value" : "Du är en kortfattad sammanfattare. Extrahera nyckelpunkterna från all text användaren tillhandahåller. Presentera sammanfattningar i tydliga punktlistor. Fokusera på den viktigaste informationen och utelämna överflödiga detaljer." } } } }, - "Unknown" : { + "You are a creative writing assistant. Help craft engaging stories, characters, dialogue, and descriptions. Offer imaginative ideas, vivid imagery, and compelling narrative structure tailored to the user's style and genre." : { + "comment" : "Description of the creative writing assistant role.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Unbekannt", - "state" : "translated" + "state" : "translated", + "value" : "Du bist ein kreativer Schreibassistent. Hilf dabei, fesselnde Geschichten, Charaktere, Dialoge und Beschreibungen zu gestalten. Biete einfallsreiche Ideen, lebendige Bilder und eine überzeugende Erzählstruktur, die auf den Stil und das Genre des Nutzers zugeschnitten sind." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Άγνωστο" + "value" : "Είστε βοηθός δημιουργικής γραφής. Βοηθήστε στη δημιουργία συναρπαστικών ιστοριών, χαρακτήρων, διαλόγων και περιγραφών. Προσφέρετε φανταστικές ιδέες, ζωντανές εικόνες και ελκυστική δομή αφήγησης προσαρμοσμένη στο ύφος και το είδος του χρήστη." } }, - "es" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Desconocido" + "value" : "You are a creative writing assistant. Help craft engaging stories, characters, dialogue, and descriptions. Offer imaginative ideas, vivid imagery, and compelling narrative structure tailored to the user's style and genre." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Sconosciuto" + "value" : "Eres un asistente de escritura creativa. Ayuda a crear historias, personajes, diálogos y descripciones atractivas. Ofrece ideas imaginativas, imágenes vívidas y una estructura narrativa convincente adaptada al estilo y género del usuario." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Desconhecido", - "state" : "translated" + "state" : "translated", + "value" : "Vous êtes un assistant d’écriture créative. Aidez à concevoir des histoires captivantes, des personnages, des dialogues et des descriptions. Proposez des idées imaginatives, des images vivantes et une structure narrative convaincante adaptée au style et au genre de l’utilisateur." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Unknown", - "state" : "translated" + "state" : "translated", + "value" : "Sei un assistente di scrittura creativa. Aiuta a creare storie coinvolgenti, personaggi, dialoghi e descrizioni. Offri idee immaginative, immagini vivide e una struttura narrativa avvincente, adattata allo stile e al genere dell’utente." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Onbekend", - "state" : "translated" + "state" : "translated", + "value" : "あなたはクリエイティブライティングアシスタントです。魅力的な物語、キャラクター、対話、描写の作成を支援します。ユーザーのスタイルやジャンルに合わせて、想像力豊かなアイデア、生き生きとしたイメージ、説得力のある物語構成を提供します。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Inconnu", - "state" : "translated" + "state" : "translated", + "value" : "Je bent een assistent voor creatief schrijven. Help bij het bedenken van boeiende verhalen, personages, dialogen en beschrijvingen. Bied fantasierijke ideeën, levendige beelden en een meeslepende verhaallijn die aansluit bij de stijl en het genre van de gebruiker." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "不明", - "state" : "translated" + "state" : "translated", + "value" : "És um assistente de escrita criativa. Ajuda a criar histórias envolventes, personagens, diálogos e descrições. Oferece ideias imaginativas, imagens vívidas e uma estrutura narrativa cativante adaptada ao estilo e género do utilizador." } }, "sv" : { "stringUnit" : { - "value" : "Okänd", - "state" : "translated" + "state" : "translated", + "value" : "Du är en kreativ skrivassistent. Hjälp till att skapa engagerande berättelser, karaktärer, dialoger och beskrivningar. Erbjud fantasifulla idéer, levande bilder och en fängslande berättarstruktur anpassad efter användarens stil och genre." } } - }, - "comment" : "A label for an unknown LLM model." + } }, - "See your latest conversations and jump back in." : { + "You are a data analysis expert. Help interpret data, identify patterns, suggest visualisations, and explain statistical concepts. Provide clear and actionable insights from any data the user shares." : { + "comment" : "Description of a data analyst assistant.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "最新の会話を確認してすぐに再開できます" + "value" : "Sie sind ein Experte für Datenanalyse. Helfen Sie dabei, Daten zu interpretieren, Muster zu erkennen, Visualisierungen vorzuschlagen und statistische Konzepte zu erklären. Liefern Sie klare und umsetzbare Erkenntnisse aus allen vom Nutzer bereitgestellten Daten." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Consulta tus últimas conversaciones y vuelve a ellas.", - "state" : "translated" + "state" : "translated", + "value" : "Είστε ειδικός στην ανάλυση δεδομένων. Βοηθήστε στην ερμηνεία δεδομένων, την αναγνώριση προτύπων, την πρόταση οπτικοποιήσεων και την εξήγηση στατιστικών εννοιών. Παρέχετε σαφείς και εφαρμόσιμες πληροφορίες από οποιαδήποτε δεδομένα μοιραστεί ο χρήστης." } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Se dina senaste konversationer och hoppa tillbaka in." + "value" : "You are a data analysis expert. Help interpret data, identify patterns, suggest visualizations, and explain statistical concepts. Provide clear and actionable insights from any data the user shares." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Visualizza le tue ultime conversazioni e riprendi da dove avevi interrotto." + "value" : "Eres un experto en análisis de datos. Ayuda a interpretar datos, identificar patrones, sugerir visualizaciones y explicar conceptos estadísticos. Proporciona información clara y accionable a partir de cualquier dato que el usuario comparta." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Veja as suas conversas mais recentes e volte a elas.", - "state" : "translated" + "state" : "translated", + "value" : "Vous êtes un expert en analyse de données. Aidez à interpréter les données, identifier les tendances, suggérer des visualisations et expliquer les concepts statistiques. Fournissez des analyses claires et exploitables à partir de toutes les données partagées par l’utilisateur." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "See your latest conversations and jump back in", - "state" : "translated" + "state" : "translated", + "value" : "Sei un esperto di analisi dei dati. Aiuta a interpretare i dati, identificare modelli, suggerire visualizzazioni e spiegare concetti statistici. Fornisci approfondimenti chiari e concreti da qualsiasi dato l’utente condivida." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Voir vos dernières conversations et y revenir.", - "state" : "translated" + "state" : "translated", + "value" : "あなたはデータ分析の専門家です。データの解釈、パターンの特定、可視化の提案、統計概念の説明を行います。ユーザーが共有するあらゆるデータから明確で実用的な洞察を提供します。" } }, "nl" : { "stringUnit" : { - "value" : "Bekijk je laatste gesprekken en ga er direct mee verder.", - "state" : "translated" + "state" : "translated", + "value" : "Je bent een expert in data-analyse. Help met het interpreteren van data, het identificeren van patronen, het voorstellen van visualisaties en het uitleggen van statistische concepten. Bied duidelijke en bruikbare inzichten uit alle data die de gebruiker deelt." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Sieh dir deine neuesten Unterhaltungen an und steige wieder ein.", - "state" : "translated" + "state" : "translated", + "value" : "É um especialista em análise de dados. Ajuda a interpretar dados, identificar padrões, sugerir visualizações e explicar conceitos estatísticos. Fornece insights claros e acionáveis a partir de quaisquer dados que o utilizador partilhe." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Δες τις πιο πρόσφατες συνομιλίες σου και συνέχισε από εκεί.", - "state" : "translated" + "state" : "translated", + "value" : "Du är en expert på dataanalys. Hjälp till att tolka data, identifiera mönster, föreslå visualiseringar och förklara statistiska begrepp. Ge tydliga och användbara insikter från all data som användaren delar." } } - }, - "comment" : "Widget description." + } }, - "Apple Shortcuts" : { + "You are a professional email writing assistant. Draft clear, concise, and appropriately toned emails based on the user's brief. Adapt the tone (formal, casual, or persuasive) to the context described." : { + "comment" : "Description of an email composer prompt template.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Apple-genvägar" + "value" : "Sie sind ein professioneller Assistent zum Verfassen von E-Mails. Erstellen Sie klare, prägnante und angemessen formulierte E-Mails basierend auf der Kurzzusammenfassung des Nutzers. Passen Sie den Ton (formell, locker oder überzeugend) an den beschriebenen Kontext an." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Atajos de Apple" + "value" : "Είστε επαγγελματίας βοηθός σύνταξης email. Δημιουργήστε σαφή, συνοπτικά και κατάλληλα διατυπωμένα email βάσει της περίληψης του χρήστη. Προσαρμόστε τον τόνο (επίσημο, ανεπίσημο ή πειστικό) ανάλογα με το περιγραφόμενο πλαίσιο." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Apple Kurzbefehle" + "value" : "You are a professional email writing assistant. Draft clear, concise, and appropriately toned emails based on the user's brief. Adapt the tone (formal, casual, or persuasive) to the context described." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Scorciatoie Apple", - "state" : "translated" + "state" : "translated", + "value" : "Eres un asistente profesional para redactar correos electrónicos. Redacta correos claros, concisos y con el tono adecuado según el resumen del usuario. Adapta el tono (formal, informal o persuasivo) al contexto descrito." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Atalhos Apple", - "state" : "translated" + "state" : "translated", + "value" : "Vous êtes un assistant professionnel de rédaction d’e-mails. Rédigez des e-mails clairs, concis et au ton approprié selon le résumé de l’utilisateur. Adaptez le ton (formel, informel ou persuasif) au contexte décrit." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Apple Shortcuts", - "state" : "translated" + "state" : "translated", + "value" : "Sei un assistente professionale per la scrittura di email. Redigi email chiare, concise e con un tono adeguato in base al breve riassunto fornito dall’utente. Adatti il tono (formale, informale o persuasivo) al contesto descritto." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Apple-snelkoppelingen", - "state" : "translated" + "state" : "translated", + "value" : "あなたはプロのメール作成アシスタントです。ユーザーの要望に基づき、明確で簡潔かつ適切なトーンのメールを作成します。状況に応じてトーン(フォーマル、カジュアル、説得力のある)を調整します。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Raccourcis Apple", - "state" : "translated" + "state" : "translated", + "value" : "Je bent een professionele e-mailassistent. Stel heldere, beknopte en passend getoonde e-mails op op basis van de samenvatting van de gebruiker. Pas de toon (formeel, informeel of overtuigend) aan op de beschreven context." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "Appleショートカット", - "state" : "translated" + "state" : "translated", + "value" : "É um assistente profissional de redação de emails. Elabore emails claros, concisos e com o tom adequado com base no resumo do utilizador. Adapte o tom (formal, informal ou persuasivo) ao contexto descrito." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Συντομεύσεις Apple", - "state" : "translated" + "state" : "translated", + "value" : "Du är en professionell assistent för e-postskrivning. Skapa tydliga, koncisa och passande tonade e-postmeddelanden baserat på användarens sammanfattning. Anpassa tonen (formell, avslappnad eller övertygande) efter den beskrivna kontexten." } } - }, - "comment" : "A heading for the Apple Shortcuts section." + } }, - "Work" : { + "You are a professional translator. Translate the user's text accurately while preserving the original meaning, tone, and nuance. Identify the source language automatically and ask for the target language if not specified." : { + "comment" : "Content of the \"Translator\" built-in template.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Arbete", - "state" : "translated" + "state" : "translated", + "value" : "Sie sind ein professioneller Übersetzer. Übersetzen Sie den Text des Benutzers genau und bewahren Sie dabei die ursprüngliche Bedeutung, den Ton und die Nuancen. Erkennen Sie die Ausgangssprache automatisch und fragen Sie nach der Zielsprache, falls diese nicht angegeben ist." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Trabajo", - "state" : "translated" + "state" : "translated", + "value" : "Είστε επαγγελματίας μεταφραστής. Μεταφράστε το κείμενο του χρήστη με ακρίβεια διατηρώντας το αρχικό νόημα, τόνο και αποχρώσεις. Αναγνωρίστε αυτόματα τη γλώσσα προέλευσης και ζητήστε τη γλώσσα στόχο αν δεν έχει καθοριστεί." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "作業" + "value" : "You are a professional translator. Translate the user's text accurately while preserving the original meaning, tone, and nuance. Identify the source language automatically and ask for the target language if not specified." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Lavoro" + "value" : "Eres un traductor profesional. Traduce el texto del usuario con precisión, preservando el significado, tono y matiz originales. Identifica automáticamente el idioma de origen y solicita el idioma de destino si no está especificado." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Trabalho", - "state" : "translated" + "state" : "translated", + "value" : "Vous êtes un traducteur professionnel. Traduisez le texte de l'utilisateur avec précision tout en préservant le sens, le ton et la nuance originaux. Identifiez automatiquement la langue source et demandez la langue cible si elle n'est pas spécifiée." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Work", - "state" : "translated" + "state" : "translated", + "value" : "Sei un traduttore professionista. Traduci accuratamente il testo dell'utente preservando il significato, il tono e le sfumature originali. Identifica automaticamente la lingua di origine e chiedi la lingua di destinazione se non specificata." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Werk", - "state" : "translated" + "state" : "translated", + "value" : "あなたはプロの翻訳者です。元の意味、トーン、ニュアンスを保ちながら、ユーザーのテキストを正確に翻訳してください。ソース言語を自動的に識別し、ターゲット言語が指定されていない場合は尋ねてください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Travail" + "value" : "Je bent een professionele vertaler. Vertaal de tekst van de gebruiker nauwkeurig en behoud de oorspronkelijke betekenis, toon en nuance. Identificeer automatisch de brontaal en vraag om de doeltaal als deze niet is opgegeven." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Arbeit", - "state" : "translated" + "state" : "translated", + "value" : "És um tradutor profissional. Traduz o texto do utilizador com precisão, preservando o significado, tom e nuances originais. Identifica automaticamente a língua de origem e pergunta pela língua de destino se não estiver especificada." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Εργασία", - "state" : "translated" + "state" : "translated", + "value" : "Du är en professionell översättare. Översätt användarens text noggrant samtidigt som du bevarar den ursprungliga betydelsen, tonen och nyansen. Identifiera källspråket automatiskt och fråga efter målspråket om det inte är angivet." } } - }, - "comment" : "A placeholder tag." + } }, - "Description (optional)" : { + "You are an expert software engineer. Help with code, explain concepts clearly, suggest best practices, and provide working code examples. Always prefer readable and maintainable solutions." : { + "comment" : "Prompt template content for each role type", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Beskrivning (valfritt)" + "value" : "Sie sind ein erfahrener Softwareingenieur. Helfen Sie bei Code, erklären Sie Konzepte klar, schlagen Sie Best Practices vor und liefern Sie funktionierende Codebeispiele. Bevorzugen Sie stets lesbare und wartbare Lösungen." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Descripción (opcional)", - "state" : "translated" + "state" : "translated", + "value" : "Είστε έμπειρος μηχανικός λογισμικού. Βοηθήστε με κώδικα, εξηγήστε έννοιες με σαφήνεια, προτείνετε βέλτιστες πρακτικές και παρέχετε λειτουργικά παραδείγματα κώδικα. Προτιμήστε πάντα λύσεις που είναι ευανάγνωστες και εύκολες στη συντήρηση." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Beschreibung (optional)" + "value" : "You are an expert software engineer. Help with code, explain concepts clearly, suggest best practices, and provide working code examples. Always prefer readable and maintainable solutions." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Descrizione (opzionale)", - "state" : "translated" + "state" : "translated", + "value" : "Eres un ingeniero de software experto. Ayuda con el código, explica conceptos claramente, sugiere las mejores prácticas y proporciona ejemplos de código funcionales. Siempre prefiere soluciones legibles y mantenibles." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Descrição (opcional)", - "state" : "translated" + "state" : "translated", + "value" : "Vous êtes un ingénieur logiciel expert. Aidez avec le code, expliquez clairement les concepts, suggérez les meilleures pratiques et fournissez des exemples de code fonctionnels. Privilégiez toujours des solutions lisibles et maintenables." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Description (optional)", - "state" : "translated" + "state" : "translated", + "value" : "Sei un esperto ingegnere del software. Aiuta con il codice, spiega i concetti chiaramente, suggerisci le migliori pratiche e fornisci esempi di codice funzionanti. Preferisci sempre soluzioni leggibili e manutenibili." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Beschrijving (optioneel)", - "state" : "translated" + "state" : "translated", + "value" : "あなたは熟練のソフトウェアエンジニアです。コードの支援、概念の明確な説明、ベストプラクティスの提案、動作するコード例の提供を行います。常に読みやすく保守しやすい解決策を優先してください。" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Description (optionnel)" + "value" : "Je bent een expert software-engineer. Help met code, leg concepten duidelijk uit, stel best practices voor en geef werkende codevoorbeelden. Geef altijd de voorkeur aan leesbare en onderhoudbare oplossingen." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "説明(任意)", - "state" : "translated" + "state" : "translated", + "value" : "És um engenheiro de software especialista. Ajuda com código, explica conceitos claramente, sugere as melhores práticas e fornece exemplos de código funcionais. Prefere sempre soluções legíveis e fáceis de manter." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Περιγραφή (προαιρετικό)", - "state" : "translated" + "state" : "translated", + "value" : "Du är en expertprogrammerare. Hjälp till med kod, förklara koncept tydligt, föreslå bästa praxis och ge fungerande kodexempel. Föredra alltid läsbara och underhållbara lösningar." } } } }, - "Continue" : { + "You'll need eggs, guanciale, Pecorino Romano..." : { + "comment" : "Last message preview text in a conversation widget.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Fortsätt" + "value" : "Du brauchst Eier, Guanciale, Pecorino Romano..." } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Continuar" + "value" : "Θα χρειαστείς αυγά, γκουαντσιάλε, Πεκορίνο Ρομάνο..." } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "続行" + "value" : "You'll need eggs, guanciale, Pecorino Romano..." } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Continua", - "state" : "translated" + "state" : "translated", + "value" : "Necesitarás huevos, guanciale, Pecorino Romano..." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Continuar", - "state" : "translated" + "state" : "translated", + "value" : "Vous aurez besoin d'œufs, de guanciale, de Pecorino Romano..." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Continue", - "state" : "translated" + "state" : "translated", + "value" : "Ti serviranno uova, guanciale, Pecorino Romano..." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Doorgaan", - "state" : "translated" + "state" : "translated", + "value" : "卵、グアンチャーレ、ペコリーノ・ロマーノが必要です..." } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Continuer", - "state" : "translated" + "state" : "translated", + "value" : "Je hebt eieren, guanciale, Pecorino Romano nodig..." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Weiter", - "state" : "translated" + "state" : "translated", + "value" : "Vai precisar de ovos, guanciale, Pecorino Romano..." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Συνέχεια", - "state" : "translated" + "state" : "translated", + "value" : "Du behöver ägg, guanciale, Pecorino Romano..." } } - }, - "comment" : "A button that allows the user to continue the onboarding process." + } }, - "System Prompt" : { + "You're all set!" : { + "comment" : "A title displayed in the onboarding view when the server is ready.", "localizations" : { "de" : { "stringUnit" : { - "value" : "Systemaufforderung", - "state" : "translated" + "state" : "translated", + "value" : "Alles bereit!" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Mensaje del sistema" + "value" : "Είστε έτοιμοι!" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "システムプロンプト" + "value" : "You're all set!" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Prompt di sistema", - "state" : "translated" + "state" : "translated", + "value" : "¡Todo listo!" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Prompt do Sistema", - "state" : "translated" + "state" : "translated", + "value" : "Tout est prêt !" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "System Prompt", - "state" : "translated" + "state" : "translated", + "value" : "Tutto pronto!" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Invite système", - "state" : "translated" + "state" : "translated", + "value" : "準備完了です!" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Systeemprompt" + "value" : "Je bent helemaal klaar!" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Προτροπή συστήματος", - "state" : "translated" + "state" : "translated", + "value" : "Está tudo pronto!" } }, "sv" : { "stringUnit" : { - "value" : "Systemprompt", - "state" : "translated" + "state" : "translated", + "value" : "Allt är klart!" } } } }, - "Edit Template" : { + "You're welcome!" : { + "comment" : "A button that dismisses a thank you alert.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "テンプレート編集", - "state" : "translated" + "state" : "translated", + "value" : "Gern geschehen!" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Editar plantilla", - "state" : "translated" + "state" : "translated", + "value" : "Παρακαλώ!" } }, - "sv" : { + "en" : { "stringUnit" : { - "value" : "Redigera mall", - "state" : "translated" + "state" : "translated", + "value" : "You're welcome!" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Modifica modello" + "value" : "¡De nada!" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Editar Modelo", - "state" : "translated" + "state" : "translated", + "value" : "De rien !" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Edit Template" + "value" : "Prego!" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Sjabloon bewerken", - "state" : "translated" + "state" : "translated", + "value" : "どういたしまして!" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Modifier le modèle" + "value" : "Graag gedaan!" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Επεξεργασία Προτύπου", - "state" : "translated" + "state" : "translated", + "value" : "De nada!" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Vorlage bearbeiten", - "state" : "translated" + "state" : "translated", + "value" : "Varsågod!" } } - }, - "comment" : "A title for a view that allows the user to edit a prompt template." + } }, - "New chat with a URL" : { + "Your AI conversations" : { + "comment" : "A description of the app's privacy policy.", "localizations" : { - "sv" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ny chatt med en URL" + "value" : "Ihre KI-Gespräche" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nueva conversación con una URL", - "state" : "translated" + "state" : "translated", + "value" : "Οι συνομιλίες σας με την Τεχνητή Νοημοσύνη" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "URLで新しいチャットを開始" + "value" : "Your AI conversations" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuova chat con un URL", - "state" : "translated" + "state" : "translated", + "value" : "Tus conversaciones con IA" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nova conversa com um URL", - "state" : "translated" + "state" : "translated", + "value" : "Vos conversations avec l’IA" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New chat using a URL", - "state" : "translated" + "state" : "translated", + "value" : "Le tue conversazioni con l'IA" } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Nouvelle conversation avec une URL", - "state" : "translated" + "state" : "translated", + "value" : "あなたのAIとの会話" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nieuw gesprek met een URL" + "value" : "Jouw AI-gesprekken" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Neuer Chat mit einer URL", - "state" : "translated" + "state" : "translated", + "value" : "As suas conversas com IA" } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Νέα συνομιλία με URL", - "state" : "translated" + "state" : "translated", + "value" : "Dina AI-konversationer" } } - }, - "comment" : "A description of how to open a chat with a URL using the URL scheme." + } }, - "Automate OpenClient with the Shortcuts app using the URL scheme actions above." : { + "Your AI, Your Way" : { + "comment" : "The title of the onboarding screen.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Αυτοματοποιήστε το OpenClient με την εφαρμογή Συντομεύσεις χρησιμοποιώντας τις παραπάνω ενέργειες σχήματος URL.", - "state" : "translated" + "state" : "translated", + "value" : "Deine KI, Dein Weg" } }, - "es" : { + "el" : { "stringUnit" : { "state" : "translated", - "value" : "Automatiza OpenClient con la app Atajos usando las acciones del esquema de URL mencionadas arriba." + "value" : "Η Τεχνητή Νοημοσύνη Σας, Με Τον Τρόπο Σας" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "上記のURLスキームアクションを使って、ショートカットアプリでOpenClientを自動化します。" + "value" : "Your AI, Your Way" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Automatizza OpenClient con l’app Comandi usando le azioni dello schema URL sopra." + "value" : "Tu IA, a tu manera" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Automatize o OpenClient com a app Atalhos usando as ações do esquema URL acima.", - "state" : "translated" + "state" : "translated", + "value" : "Votre IA, à votre façon" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Automate OpenClient with the Shortcuts app using the URL scheme actions above.", - "state" : "translated" + "state" : "translated", + "value" : "La tua IA, a modo tuo" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Automatiseer OpenClient met de Opdrachten-app via de bovenstaande URL-scheme-acties.", - "state" : "translated" + "state" : "translated", + "value" : "あなたのAI、あなたのスタイル" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Automatisez OpenClient avec l’app Raccourcis en utilisant les actions du schéma d’URL ci-dessus.", - "state" : "translated" + "state" : "translated", + "value" : "Jouw AI, Jouw Manier" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Automatisieren Sie OpenClient mit der Kurzbefehle-App unter Verwendung der oben genannten URL-Schema-Aktionen.", - "state" : "translated" + "state" : "translated", + "value" : "A sua IA, à sua maneira" } }, "sv" : { "stringUnit" : { - "value" : "Automatisera OpenClient med appen Genvägar med hjälp av URL-schemakommandona ovan.", - "state" : "translated" + "state" : "translated", + "value" : "Din AI, på ditt sätt" } } - }, - "comment" : "A description of how to use the Shortcuts app to open OpenClient." + } }, - "No Favourites Yet" : { + "Your comment" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Noch keine Favoriten vorhanden" + "value" : "Ihr Kommentar" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Sin favoritos aún", - "state" : "translated" + "state" : "translated", + "value" : "Το σχόλιό σας" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "お気に入りはまだありません" + "value" : "Your comment" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nessun preferito ancora" + "value" : "Tu comentario" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Sem Favoritos Ainda", - "state" : "translated" + "state" : "translated", + "value" : "Votre commentaire" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "No Favorites Yet", - "state" : "translated" + "state" : "translated", + "value" : "Il tuo commento" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nog geen favorieten", - "state" : "translated" + "state" : "translated", + "value" : "あなたのコメント" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Aucun favori pour le moment", - "state" : "translated" + "state" : "translated", + "value" : "Je opmerking" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Δεν υπάρχουν αγαπημένα ακόμα", - "state" : "translated" + "state" : "translated", + "value" : "O seu comentário" } }, "sv" : { "stringUnit" : { - "value" : "Inga favoriter än", - "state" : "translated" + "state" : "translated", + "value" : "Din kommentar" } } - }, - "comment" : "A message displayed when a user has no favourite messages." + } }, - "Could not connect to the server." : { + "Your data stays on your own server — no telemetry" : { + "comment" : "A description of the privacy features of OpenClient.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή.", - "state" : "translated" + "state" : "translated", + "value" : "Ihre Daten bleiben auf Ihrem eigenen Server — keine Telemetrie" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "No se pudo conectar al servidor.", - "state" : "translated" + "state" : "translated", + "value" : "Τα δεδομένα σας παραμένουν στον δικό σας διακομιστή — χωρίς τηλεμετρία" } }, - "sv" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kunde inte ansluta till servern." + "value" : "Your data stays on your own server — no telemetry" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Impossibile connettersi al server.", - "state" : "translated" + "state" : "translated", + "value" : "Tus datos permanecen en tu propio servidor sin telemetría" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Não foi possível ligar ao servidor.", - "state" : "translated" + "state" : "translated", + "value" : "Vos données restent sur votre propre serveur — pas de télémétrie" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Could not connect to the server.", - "state" : "translated" + "state" : "translated", + "value" : "I tuoi dati restano sul tuo server — nessuna telemetria" } }, - "nl" : { + "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Kan geen verbinding maken met de server." + "value" : "データはお客様のサーバーにのみ保存され、テレメトリーはありません" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Impossible de se connecter au serveur." + "value" : "Uw gegevens blijven op uw eigen server — geen telemetrie" } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "サーバーに接続できませんでした。", - "state" : "translated" + "state" : "translated", + "value" : "Os seus dados permanecem no seu próprio servidor — sem telemetria" } }, - "de" : { + "sv" : { "stringUnit" : { - "value" : "Verbindung zum Server konnte nicht hergestellt werden.", - "state" : "translated" + "state" : "translated", + "value" : "Dina data stannar på din egen server — ingen telemetri" } } } }, - "The agent reached its maximum number of steps." : { + "Your local personal context differs from iCloud. Which version would you like to keep?" : { + "comment" : "A message displayed when a user has a conflict between their local and iCloud data.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { - "value" : "エージェントは最大ステップ数に達しました。", - "state" : "translated" + "state" : "translated", + "value" : "Ihr lokaler persönlicher Kontext unterscheidet sich von iCloud. Welche Version möchten Sie behalten?" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "El agente alcanzó su número máximo de pasos.", - "state" : "translated" + "state" : "translated", + "value" : "Το τοπικό προσωπικό σας περιεχόμενο διαφέρει από το iCloud. Ποια έκδοση θέλετε να κρατήσετε;" } }, - "el" : { + "en" : { "stringUnit" : { - "value" : "Ο πράκτορας έφτασε στον μέγιστο αριθμό βημάτων.", - "state" : "translated" + "state" : "translated", + "value" : "Your local personal context differs from iCloud. Which version would you like to keep?" } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "L'agente ha raggiunto il numero massimo di passi." + "value" : "Tu contexto personal local difiere del de iCloud. ¿Qué versión deseas conservar?" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "O agente atingiu o número máximo de passos.", - "state" : "translated" + "state" : "translated", + "value" : "Votre contexte personnel local diffère de celui d’iCloud. Quelle version souhaitez-vous conserver ?" } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "The agent has reached its maximum number of steps." + "value" : "Il tuo contesto personale locale differisce da quello di iCloud. Quale versione desideri mantenere?" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De agent heeft het maximale aantal stappen bereikt.", - "state" : "translated" + "state" : "translated", + "value" : "ローカルの個人情報がiCloudと異なります。どちらのバージョンを保持しますか?" } }, - "fr" : { + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "L'agent a atteint son nombre maximal d'étapes." + "value" : "Je lokale persoonlijke context verschilt van iCloud. Welke versie wil je behouden?" } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Der Agent hat die maximale Anzahl an Schritten erreicht.", - "state" : "translated" + "state" : "translated", + "value" : "O seu contexto pessoal local difere do iCloud. Qual versão pretende manter?" } }, "sv" : { "stringUnit" : { - "value" : "Agenten har nått sitt maximala antal steg.", - "state" : "translated" + "state" : "translated", + "value" : "Din lokala personliga kontext skiljer sig från iCloud. Vilken version vill du behålla?" } } - }, - "comment" : "Error message displayed when the agent has reached its maximum number of steps." + } }, - "New Memory" : { + "Your name" : { + "comment" : "A label that describes the user's name.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "新しいメモリー" + "value" : "Ihr Name" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Nueva memoria", - "state" : "translated" + "state" : "translated", + "value" : "Το όνομά σας" } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Neue Erinnerung" + "value" : "Your name" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "Nuova memoria", - "state" : "translated" + "state" : "translated", + "value" : "Tu nombre" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Nova Memória", - "state" : "translated" + "state" : "translated", + "value" : "Votre nom" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "New Memory", - "state" : "translated" + "state" : "translated", + "value" : "Il tuo nome" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Nieuwe herinnering", - "state" : "translated" + "state" : "translated", + "value" : "あなたの名前" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Nouvelle mémoire", - "state" : "translated" + "state" : "translated", + "value" : "Uw naam" } }, - "el" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Νέα Μνήμη" + "value" : "O seu nome" } }, "sv" : { "stringUnit" : { - "value" : "Nytt minne", - "state" : "translated" + "state" : "translated", + "value" : "Ditt namn" } } - }, - "comment" : "A label for a new memory item." + } }, - "The network connection was lost." : { + "Your name (optional)" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die Netzwerkverbindung wurde unterbrochen." + "value" : "Ihr Name (optional)" } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Se perdió la conexión de red.", - "state" : "translated" + "state" : "translated", + "value" : "Το όνομά σας (προαιρετικό)" } }, - "ja" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "ネットワーク接続が切断されました。" + "value" : "Your name (optional)" } }, - "it" : { + "es" : { "stringUnit" : { - "value" : "La connessione di rete è stata persa.", - "state" : "translated" + "state" : "translated", + "value" : "Tu nombre (opcional)" } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "A ligação de rede foi perdida." + "value" : "Votre nom (optionnel)" } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "The network connection was lost.", - "state" : "translated" + "state" : "translated", + "value" : "Il tuo nome (opzionale)" } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "De netwerkverbinding is verbroken.", - "state" : "translated" + "state" : "translated", + "value" : "あなたの名前(任意)" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "La connexion réseau a été perdue.", - "state" : "translated" + "state" : "translated", + "value" : "Je naam (optioneel)" } }, - "el" : { + "pt-PT" : { "stringUnit" : { - "value" : "Η σύνδεση δικτύου διακόπηκε.", - "state" : "translated" + "state" : "translated", + "value" : "O seu nome (opcional)" } }, "sv" : { "stringUnit" : { - "value" : "Nätverksanslutningen förlorades.", - "state" : "translated" + "state" : "translated", + "value" : "Ditt namn (valfritt)" } } } }, - "Delete Conversation" : { + "Your pinned conversation appears here." : { "localizations" : { - "sv" : { + "de" : { "stringUnit" : { - "value" : "Radera konversation", - "state" : "translated" + "state" : "translated", + "value" : "Deine angeheftete Unterhaltung erscheint hier." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Eliminar conversación", - "state" : "translated" + "state" : "translated", + "value" : "Η καρφιτσωμένη συνομιλία σας εμφανίζεται εδώ." } }, - "de" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Konversation löschen" + "value" : "Your pinned conversation appears here." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Elimina conversazione" + "value" : "Tu conversación fijada aparece aquí." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Eliminar Conversa" + "value" : "Votre conversation épinglée apparaît ici." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "Delete Conversation", - "state" : "translated" + "state" : "translated", + "value" : "La tua conversazione fissata appare qui." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "Gesprek verwijderen", - "state" : "translated" + "state" : "translated", + "value" : "ピン留めした会話がここに表示されます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Supprimer la conversation", - "state" : "translated" + "state" : "translated", + "value" : "Je vastgezette gesprek verschijnt hier." } }, - "ja" : { + "pt-PT" : { "stringUnit" : { - "value" : "会話を削除", - "state" : "translated" + "state" : "translated", + "value" : "A sua conversa fixada aparece aqui." } }, - "el" : { + "sv" : { "stringUnit" : { - "value" : "Διαγραφή Συνομιλίας", - "state" : "translated" + "state" : "translated", + "value" : "Din fastnålad konversation visas här." } } - }, - "comment" : "A confirmation dialog title for deleting a conversation." + } }, - "Show Actions" : { + "Your server is ready. Let's start a conversation." : { + "comment" : "A description of the onboarding screen when the server is ready.", "localizations" : { - "el" : { + "de" : { "stringUnit" : { - "value" : "Εμφάνιση ενεργειών", - "state" : "translated" + "state" : "translated", + "value" : "Ihr Server ist bereit. Beginnen wir ein Gespräch." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Mostrar acciones", - "state" : "translated" + "state" : "translated", + "value" : "Ο διακομιστής σας είναι έτοιμος. Ας ξεκινήσουμε μια συνομιλία." } }, - "ja" : { + "en" : { "stringUnit" : { - "value" : "アクションを表示", - "state" : "translated" + "state" : "translated", + "value" : "Your server is ready. Let's start a conversation." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Mostra azioni" + "value" : "Tu servidor está listo. Comencemos una conversación." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mostrar Ações" + "value" : "Votre serveur est prêt. Commençons une conversation." } }, - "en" : { + "it" : { "stringUnit" : { "state" : "translated", - "value" : "Show Actions" + "value" : "Il tuo server è pronto. Iniziamo una conversazione." } }, - "fr" : { + "ja" : { "stringUnit" : { - "value" : "Afficher les actions", - "state" : "translated" + "state" : "translated", + "value" : "サーバーの準備ができました。会話を始めましょう。" } }, "nl" : { "stringUnit" : { - "value" : "Acties tonen", - "state" : "translated" + "state" : "translated", + "value" : "Je server is klaar. Laten we een gesprek beginnen." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "Aktionen anzeigen", - "state" : "translated" + "state" : "translated", + "value" : "O seu servidor está pronto. Vamos começar uma conversa." } }, "sv" : { "stringUnit" : { - "value" : "Visa åtgärder", - "state" : "translated" + "state" : "translated", + "value" : "Din server är redo. Låt oss börja en konversation." } } - }, - "comment" : "A label for a button that shows additional actions." + } }, - "URL Scheme" : { + "Your support means a lot and helps keep the app free and open source." : { + "comment" : "A message displayed in a thank you alert.", "localizations" : { - "ja" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "URLスキーム" + "value" : "Deine Unterstützung bedeutet viel und hilft, die App kostenlos und Open Source zu halten." } }, - "es" : { + "el" : { "stringUnit" : { - "value" : "Esquema de URL", - "state" : "translated" + "state" : "translated", + "value" : "Η υποστήριξή σας σημαίνει πολλά και βοηθά να παραμείνει η εφαρμογή δωρεάν και ανοιχτού κώδικα." } }, - "el" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Σχήμα URL" + "value" : "Your support means a lot and helps keep the app free and open source." } }, - "it" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Schema URL" + "value" : "Tu apoyo significa mucho y ayuda a mantener la aplicación gratuita y de código abierto." } }, - "pt-PT" : { + "fr" : { "stringUnit" : { - "value" : "Esquema URL", - "state" : "translated" + "state" : "translated", + "value" : "Votre soutien est précieux et permet de garder l’application gratuite et open source." } }, - "en" : { + "it" : { "stringUnit" : { - "value" : "URL Scheme", - "state" : "translated" + "state" : "translated", + "value" : "Il tuo supporto è molto importante e aiuta a mantenere l’app gratuita e open source." } }, - "nl" : { + "ja" : { "stringUnit" : { - "value" : "URL-schema", - "state" : "translated" + "state" : "translated", + "value" : "ご支援いただくことで、アプリを無料かつオープンソースのまま維持できます。" } }, - "fr" : { + "nl" : { "stringUnit" : { - "value" : "Schéma d’URL", - "state" : "translated" + "state" : "translated", + "value" : "Je steun betekent veel en helpt de app gratis en open source te houden." } }, - "de" : { + "pt-PT" : { "stringUnit" : { - "value" : "URL-Schema", - "state" : "translated" + "state" : "translated", + "value" : "O seu apoio é muito importante e ajuda a manter a aplicação gratuita e de código aberto." } }, "sv" : { "stringUnit" : { - "value" : "URL-schema", - "state" : "translated" + "state" : "translated", + "value" : "Ditt stöd betyder mycket och hjälper till att hålla appen gratis och öppen källkod." } } - }, - "comment" : "A label that describes the URL scheme feature." + } } - } + }, + "version" : "1.2" } \ No newline at end of file diff --git a/specs/icloud-sync.instructions.md b/specs/icloud-sync.instructions.md new file mode 100644 index 00000000..3586e490 --- /dev/null +++ b/specs/icloud-sync.instructions.md @@ -0,0 +1,197 @@ +--- +description: "Use when implementing or changing iCloud synchronization, iCloud Documents storage, conflict resolution, cloud availability, or cloud data management." +--- + +# iCloud Documents Synchronization Contract + +## Scope + +This specification is the authoritative contract for OpenClient synchronization through the app's private iCloud +Documents container. It covers conversations, attachments, the user profile, memory items, custom prompt templates, and +the metadata required to reconcile or delete them. + +The synchronization implementation must remain file based. SwiftData, CloudKit records, third-party databases, and a +server-side synchronization service are outside the scope of this feature. + +## Core Guarantees + +- Existing local and cloud JSON files are user data and must remain readable across upgrades. +- Synchronization must never infer deletion from an empty directory, a missing file, an unavailable container, a pending + iCloud download, a decoding failure, or an unsupported schema. +- A write or delete may begin only after all metadata that can affect its reconciliation decision is current. +- Repeating the same synchronization with unchanged inputs must produce the same result and no additional writes. +- At most one synchronization operation may mutate local or cloud state at a time. Triggers received during a run are + coalesced into at most one follow-up run. +- User data may be permanently deleted only after an explicit user action or after applying durable deletion metadata + created by such an action. +- No iCloud file operation may block the main actor. +- A failure in one data category must be reported. It must not be converted into global success or silently discarded. + +## Storage Backend + +Both app targets use the private ubiquity container `iCloud.com.artcc.openclient-llm` with the `CloudDocuments` service. +The developer and the configured LiteLLM server have no access to this container. + +The current Version 1 layout under the container's `Documents` directory is: + +```text +Documents/ + Conversations/.json + Attachments// + ConversationTombstones/.json + ConversationTombstones.json + ConversationDeleteAll.json + UserProfile.json + PromptTemplates/