From 369e4fe6f711f7b23a6d09c4fc96b63d36989c28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:52:49 +0000 Subject: [PATCH 1/6] Remove unused non-OpenAI providers Inklet is wired to OpenAI only, so the Anthropic, Gemini and OpenAI-compatible providers, their presets, the provider factory and the custom endpoint setting were unreachable. Saved configs that still carry the old keys keep decoding and fall back to OpenAI. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DzisdHuvFVePscX66cn3Lw --- Sources/InkletApp/AppCoordinator.swift | 19 +- Sources/InkletApp/InkletPopoverView.swift | 46 +- Sources/InkletApp/SettingsView.swift | 14 +- Sources/InkletCore/AnthropicProvider.swift | 128 ------ .../InkletCore/ChatCompletionProvider.swift | 111 ----- Sources/InkletCore/ConfigStore.swift | 9 - Sources/InkletCore/GeminiProvider.swift | 130 ------ Sources/InkletCore/LLMProviderFactory.swift | 23 - Sources/InkletCore/LLMProviderPreset.swift | 206 --------- Sources/InkletCore/ModelCatalogService.swift | 17 +- .../Resources/model-catalog-snapshot.json | 410 ------------------ Tests/InkletCoreTests/ConfigStoreTests.swift | 12 +- Tests/InkletCoreTests/LLMProviderTests.swift | 123 ------ .../ModelCatalogServiceTests.swift | 4 +- 14 files changed, 40 insertions(+), 1212 deletions(-) delete mode 100644 Sources/InkletCore/AnthropicProvider.swift delete mode 100644 Sources/InkletCore/ChatCompletionProvider.swift delete mode 100644 Sources/InkletCore/GeminiProvider.swift delete mode 100644 Sources/InkletCore/LLMProviderFactory.swift delete mode 100644 Tests/InkletCoreTests/LLMProviderTests.swift diff --git a/Sources/InkletApp/AppCoordinator.swift b/Sources/InkletApp/AppCoordinator.swift index 4fe0d4e..4a03409 100644 --- a/Sources/InkletApp/AppCoordinator.swift +++ b/Sources/InkletApp/AppCoordinator.swift @@ -1038,14 +1038,17 @@ final class AppCoordinator: NSObject, NSMenuDelegate { let providerPreset = config.resolvedProviderPreset let providerID = config.providerID let apiKeyStore = self.apiKeyStore - let provider = LLMProviderFactory.provider(for: providerPreset) { - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) - } - return apiKey - } + let provider = OpenAIProvider( + apiKeyProvider: { + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) + } + return apiKey + }, + endpoint: providerPreset.endpoint + ) let targetLanguageName = config.selectionActions.translationLanguage.resolvedPromptTargetName( interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier ) diff --git a/Sources/InkletApp/InkletPopoverView.swift b/Sources/InkletApp/InkletPopoverView.swift index 1bdeba9..eb23f5c 100644 --- a/Sources/InkletApp/InkletPopoverView.swift +++ b/Sources/InkletApp/InkletPopoverView.swift @@ -46,7 +46,7 @@ final class InkletPopoverViewModel: ObservableObject { } var currentProviderName: String { - LLMProviderPreset.preset(id: config.providerID).name + config.resolvedProviderPreset.name } var currentModelName: String { @@ -604,13 +604,16 @@ final class InkletPopoverViewModel: ObservableObject { let providerPreset = config.resolvedProviderPreset let providerID = config.providerID let apiKeyStore = self.apiKeyStore - let provider = LLMProviderFactory.provider(for: providerPreset) { - try LocalAPIKeyProvider( - apiKeyStore: apiKeyStore, - providerID: providerID, - providerName: providerPreset.name - ).loadAPIKey() - } + let provider = OpenAIProvider( + apiKeyProvider: { + try LocalAPIKeyProvider( + apiKeyStore: apiKeyStore, + providerID: providerID, + providerName: providerPreset.name + ).loadAPIKey() + }, + endpoint: providerPreset.endpoint + ) let transformationService = transformationServiceFactory(provider) transformationTask = Task { [weak self] in @@ -823,23 +826,20 @@ private extension Error { } private func localizedProviderMessage(_ message: String) -> String { - for providerName in LLMProviderPreset.all.map(\.name) { - let prefix = "\(providerName) 请求失败:" - guard message.hasPrefix(prefix) else { - continue - } - - let detail = String(message.dropFirst(prefix.count)) - if detail == "URL 无效" { - return L10n.format("error.provider.urlInvalid", providerName) - } - if detail == "HTTP unknown" { - return L10n.format("error.provider.httpUnknown", providerName) - } - return L10n.format("error.provider.prefix", providerName, detail) + let providerName = LLMProviderPreset.openAI.name + let prefix = "\(providerName) 请求失败:" + guard message.hasPrefix(prefix) else { + return message } - return message + let detail = String(message.dropFirst(prefix.count)) + if detail == "URL 无效" { + return L10n.format("error.provider.urlInvalid", providerName) + } + if detail == "HTTP unknown" { + return L10n.format("error.provider.httpUnknown", providerName) + } + return L10n.format("error.provider.prefix", providerName, detail) } } diff --git a/Sources/InkletApp/SettingsView.swift b/Sources/InkletApp/SettingsView.swift index a61f692..e421c43 100644 --- a/Sources/InkletApp/SettingsView.swift +++ b/Sources/InkletApp/SettingsView.swift @@ -77,7 +77,7 @@ final class SettingsViewModel: ObservableObject { self.selectedPromptModeID = loadedConfig.promptModes.sorted { $0.sortOrder < $1.sortOrder }.first?.id ?? PromptMode.translateToEnglishID self.cachedProviderModels = Dictionary( - uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in + uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { return nil } @@ -110,15 +110,7 @@ final class SettingsViewModel: ObservableObject { return historyItems.filter { $0.source == historyFilter } } - var isCustomOpenAICompatibleProvider: Bool { - false - } - var selectedProviderModelOptions: [String] { - guard !isCustomOpenAICompatibleProvider else { - return [] - } - var seen = Set() var options: [String] = [] @@ -144,7 +136,7 @@ final class SettingsViewModel: ObservableObject { } var shouldShowCustomModelField: Bool { - isCustomOpenAICompatibleProvider || selectedModelMenuValue == Self.customModelMenuID + selectedModelMenuValue == Self.customModelMenuID } var selectedModelIsDefault: Bool { @@ -332,7 +324,7 @@ final class SettingsViewModel: ObservableObject { refreshMigrationWorkflowIdle() guard !isMigrationMaintenanceActive else { return } cachedProviderModels = Dictionary( - uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in + uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { return nil } diff --git a/Sources/InkletCore/AnthropicProvider.swift b/Sources/InkletCore/AnthropicProvider.swift deleted file mode 100644 index f613827..0000000 --- a/Sources/InkletCore/AnthropicProvider.swift +++ /dev/null @@ -1,128 +0,0 @@ -import Foundation - -public struct AnthropicProvider: LLMProvider { - public struct RequestBody: Codable, Equatable { - public struct Message: Codable, Equatable { - public struct Content: Codable, Equatable { - public var type: String - public var text: String - } - - public var role: String - public var content: [Content] - } - - public var model: String - public var maxTokens: Int - public var system: String - public var messages: [Message] - - enum CodingKeys: String, CodingKey { - case model - case maxTokens = "max_tokens" - case system - case messages - } - } - - private struct ResponseBody: Decodable { - struct Content: Decodable { - var type: String? - var text: String? - } - - var content: [Content] - } - - private struct ErrorResponseBody: Decodable { - struct ErrorBody: Decodable { - var message: String? - } - - var error: ErrorBody - } - - private let apiKeyProvider: @Sendable () throws -> String - private let endpoint: URL - private let session: URLSession - - public init( - apiKeyProvider: @escaping @Sendable () throws -> String, - endpoint: URL = URL(string: "https://api.anthropic.com/v1/messages")!, - session: URLSession = .shared - ) { - self.apiKeyProvider = apiKeyProvider - self.endpoint = endpoint - self.session = session - } - - public func transform(_ request: TransformationRequest) async throws -> TransformationResult { - let apiKey = try apiKeyProvider() - let bodyData = try JSONEncoder().encode(Self.makeRequestBody(for: request)) - - var urlRequest = URLRequest(url: endpoint, timeoutInterval: request.timeoutSeconds) - urlRequest.httpMethod = "POST" - urlRequest.setValue(apiKey, forHTTPHeaderField: "x-api-key") - urlRequest.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpBody = bodyData - - let started = Date() - let (data, response) = try await session.data(for: urlRequest) - let elapsedMilliseconds = Int(Date().timeIntervalSince(started) * 1_000) - - guard let httpResponse = response as? HTTPURLResponse else { - throw TransformationError.provider("Anthropic 请求失败:HTTP unknown") - } - - guard (200..<300).contains(httpResponse.statusCode) else { - throw TransformationError.provider(Self.providerErrorMessage(from: data, statusCode: httpResponse.statusCode)) - } - - let outputText = try Self.parseOutputText(from: data) - return TransformationResult( - outputText: outputText, - providerMetadata: [ - "provider": "anthropic", - "model": request.model - ], - elapsedMilliseconds: elapsedMilliseconds - ) - } - - public static func makeRequestBody(for request: TransformationRequest) -> RequestBody { - RequestBody( - model: request.model, - maxTokens: 4096, - system: request.systemPrompt, - messages: [ - RequestBody.Message( - role: "user", - content: [.init(type: "text", text: request.sourceText)] - ) - ] - ) - } - - public static func parseOutputText(from data: Data) throws -> String { - let outputText = try JSONDecoder() - .decode(ResponseBody.self, from: data) - .content - .filter { $0.type == "text" } - .compactMap(\.text) - .joined() - guard !outputText.isEmpty else { - throw TransformationError.emptyResponse - } - return outputText - } - - static func providerErrorMessage(from data: Data, statusCode: Int) -> String { - if let errorBody = try? JSONDecoder().decode(ErrorResponseBody.self, from: data), - let message = errorBody.error.message { - return "Anthropic 请求失败:\(message)" - } - - return "Anthropic 请求失败:HTTP \(statusCode)" - } -} diff --git a/Sources/InkletCore/ChatCompletionProvider.swift b/Sources/InkletCore/ChatCompletionProvider.swift deleted file mode 100644 index ac20288..0000000 --- a/Sources/InkletCore/ChatCompletionProvider.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation - -public struct ChatCompletionProvider: LLMProvider { - public struct RequestBody: Codable, Equatable { - public struct Message: Codable, Equatable { - public var role: String - public var content: String - } - - public var model: String - public var messages: [Message] - } - - private struct ResponseBody: Decodable { - struct Choice: Decodable { - struct Message: Decodable { - var content: String? - } - - var message: Message? - } - - var choices: [Choice] - } - - private struct ErrorResponseBody: Decodable { - struct ErrorBody: Decodable { - var message: String? - } - - var error: ErrorBody - } - - private let name: String - private let apiKeyProvider: @Sendable () throws -> String - private let endpoint: URL - private let session: URLSession - - public init( - name: String, - apiKeyProvider: @escaping @Sendable () throws -> String, - endpoint: URL, - session: URLSession = .shared - ) { - self.name = name - self.apiKeyProvider = apiKeyProvider - self.endpoint = endpoint - self.session = session - } - - public func transform(_ request: TransformationRequest) async throws -> TransformationResult { - let apiKey = try apiKeyProvider() - let bodyData = try JSONEncoder().encode(Self.makeRequestBody(for: request)) - - var urlRequest = URLRequest(url: endpoint, timeoutInterval: request.timeoutSeconds) - urlRequest.httpMethod = "POST" - urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpBody = bodyData - - let started = Date() - let (data, response) = try await session.data(for: urlRequest) - let elapsedMilliseconds = Int(Date().timeIntervalSince(started) * 1_000) - - guard let httpResponse = response as? HTTPURLResponse else { - throw TransformationError.provider("\(name) 请求失败:HTTP unknown") - } - - guard (200..<300).contains(httpResponse.statusCode) else { - throw TransformationError.provider(providerErrorMessage(from: data, statusCode: httpResponse.statusCode)) - } - - let outputText = try Self.parseOutputText(from: data) - return TransformationResult( - outputText: outputText, - providerMetadata: [ - "provider": name, - "model": request.model - ], - elapsedMilliseconds: elapsedMilliseconds - ) - } - - public static func makeRequestBody(for request: TransformationRequest) -> RequestBody { - RequestBody( - model: request.model, - messages: [ - RequestBody.Message(role: "system", content: request.systemPrompt), - RequestBody.Message(role: "user", content: request.sourceText) - ] - ) - } - - public static func parseOutputText(from data: Data) throws -> String { - let response = try JSONDecoder().decode(ResponseBody.self, from: data) - let outputText = response.choices.compactMap(\.message?.content).joined() - guard !outputText.isEmpty else { - throw TransformationError.emptyResponse - } - return outputText - } - - private func providerErrorMessage(from data: Data, statusCode: Int) -> String { - if let errorBody = try? JSONDecoder().decode(ErrorResponseBody.self, from: data), - let message = errorBody.error.message { - return "\(name) 请求失败:\(message)" - } - - return "\(name) 请求失败:HTTP \(statusCode)" - } -} diff --git a/Sources/InkletCore/ConfigStore.swift b/Sources/InkletCore/ConfigStore.swift index 6a8e213..ecb594c 100644 --- a/Sources/InkletCore/ConfigStore.swift +++ b/Sources/InkletCore/ConfigStore.swift @@ -21,7 +21,6 @@ public struct AppConfig: Codable, Equatable, Sendable { public var hotkey: String public var appearance: AppAppearance public var promptModes: [PromptMode] - public var customOpenAICompatibleEndpoint: String public var voiceInput: VoiceInputConfig public var selectionActions: SelectionActionsConfig @@ -33,7 +32,6 @@ public struct AppConfig: Codable, Equatable, Sendable { hotkey: String, appearance: AppAppearance = .system, promptModes: [PromptMode], - customOpenAICompatibleEndpoint: String = LLMProviderPreset.customOpenAICompatible.endpoint.absoluteString, voiceInput: VoiceInputConfig = VoiceInputConfig.defaultConfig(), selectionActions: SelectionActionsConfig = SelectionActionsConfig.defaultConfig() ) { @@ -44,7 +42,6 @@ public struct AppConfig: Codable, Equatable, Sendable { self.hotkey = hotkey self.appearance = appearance self.promptModes = promptModes - self.customOpenAICompatibleEndpoint = customOpenAICompatibleEndpoint self.voiceInput = voiceInput self.selectionActions = selectionActions } @@ -93,7 +90,6 @@ public struct AppConfig: Codable, Equatable, Sendable { case hotkey case appearance case promptModes - case customOpenAICompatibleEndpoint case voiceInput case selectionActions } @@ -123,10 +119,6 @@ public struct AppConfig: Codable, Equatable, Sendable { promptModes = AppConfig.migratedPromptModes( try container.decodeIfPresent([PromptMode].self, forKey: .promptModes) ?? defaults.promptModes ) - customOpenAICompatibleEndpoint = try container.decodeIfPresent( - String.self, - forKey: .customOpenAICompatibleEndpoint - ) ?? defaults.customOpenAICompatibleEndpoint voiceInput = try container.decodeIfPresent( VoiceInputConfig.self, forKey: .voiceInput @@ -147,7 +139,6 @@ public struct AppConfig: Codable, Equatable, Sendable { try container.encode(hotkey, forKey: .hotkey) try container.encode(appearance, forKey: .appearance) try container.encode(promptModes, forKey: .promptModes) - try container.encode(customOpenAICompatibleEndpoint, forKey: .customOpenAICompatibleEndpoint) try container.encode(voiceInput, forKey: .voiceInput) try container.encode(selectionActions, forKey: .selectionActions) } diff --git a/Sources/InkletCore/GeminiProvider.swift b/Sources/InkletCore/GeminiProvider.swift deleted file mode 100644 index 231b337..0000000 --- a/Sources/InkletCore/GeminiProvider.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation - -public struct GeminiProvider: LLMProvider { - public struct RequestBody: Codable, Equatable { - public struct Part: Codable, Equatable { - public var text: String - } - - public struct Content: Codable, Equatable { - public var role: String? - public var parts: [Part] - } - - public var systemInstruction: Content - public var contents: [Content] - } - - private struct ResponseBody: Decodable { - struct Candidate: Decodable { - struct Content: Decodable { - struct Part: Decodable { - var text: String? - } - - var parts: [Part]? - } - - var content: Content? - } - - var candidates: [Candidate] - } - - private struct ErrorResponseBody: Decodable { - struct ErrorBody: Decodable { - var message: String? - } - - var error: ErrorBody - } - - private let apiKeyProvider: @Sendable () throws -> String - private let baseURL: URL - private let session: URLSession - - public init( - apiKeyProvider: @escaping @Sendable () throws -> String, - baseURL: URL = URL(string: "https://generativelanguage.googleapis.com/v1beta")!, - session: URLSession = .shared - ) { - self.apiKeyProvider = apiKeyProvider - self.baseURL = baseURL - self.session = session - } - - public func transform(_ request: TransformationRequest) async throws -> TransformationResult { - let apiKey = try apiKeyProvider() - let bodyData = try JSONEncoder().encode(Self.makeRequestBody(for: request)) - let encodedModel = request.model.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? request.model - let endpoint = baseURL - .appendingPathComponent("models") - .appendingPathComponent(encodedModel) - .appendingPathComponent("generateContent") - - var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) - components?.queryItems = [URLQueryItem(name: "key", value: apiKey)] - - guard let url = components?.url else { - throw TransformationError.provider("Gemini 请求失败:URL 无效") - } - - var urlRequest = URLRequest(url: url, timeoutInterval: request.timeoutSeconds) - urlRequest.httpMethod = "POST" - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpBody = bodyData - - let started = Date() - let (data, response) = try await session.data(for: urlRequest) - let elapsedMilliseconds = Int(Date().timeIntervalSince(started) * 1_000) - - guard let httpResponse = response as? HTTPURLResponse else { - throw TransformationError.provider("Gemini 请求失败:HTTP unknown") - } - - guard (200..<300).contains(httpResponse.statusCode) else { - throw TransformationError.provider(Self.providerErrorMessage(from: data, statusCode: httpResponse.statusCode)) - } - - let outputText = try Self.parseOutputText(from: data) - return TransformationResult( - outputText: outputText, - providerMetadata: [ - "provider": "gemini", - "model": request.model - ], - elapsedMilliseconds: elapsedMilliseconds - ) - } - - public static func makeRequestBody(for request: TransformationRequest) -> RequestBody { - RequestBody( - systemInstruction: .init(role: nil, parts: [.init(text: request.systemPrompt)]), - contents: [ - .init(role: "user", parts: [.init(text: request.sourceText)]) - ] - ) - } - - public static func parseOutputText(from data: Data) throws -> String { - let outputText = try JSONDecoder() - .decode(ResponseBody.self, from: data) - .candidates - .flatMap { $0.content?.parts ?? [] } - .compactMap(\.text) - .joined() - guard !outputText.isEmpty else { - throw TransformationError.emptyResponse - } - return outputText - } - - static func providerErrorMessage(from data: Data, statusCode: Int) -> String { - if let errorBody = try? JSONDecoder().decode(ErrorResponseBody.self, from: data), - let message = errorBody.error.message { - return "Gemini 请求失败:\(message)" - } - - return "Gemini 请求失败:HTTP \(statusCode)" - } -} diff --git a/Sources/InkletCore/LLMProviderFactory.swift b/Sources/InkletCore/LLMProviderFactory.swift deleted file mode 100644 index 7200d82..0000000 --- a/Sources/InkletCore/LLMProviderFactory.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -public enum LLMProviderFactory { - public static func provider( - for preset: LLMProviderPreset, - apiKeyProvider: @escaping @Sendable () throws -> String - ) -> any LLMProvider { - switch preset.kind { - case .openAIResponses: - return OpenAIProvider(apiKeyProvider: apiKeyProvider, endpoint: preset.endpoint) - case .openAICompatibleChat: - return ChatCompletionProvider( - name: preset.name, - apiKeyProvider: apiKeyProvider, - endpoint: preset.endpoint - ) - case .anthropicMessages: - return AnthropicProvider(apiKeyProvider: apiKeyProvider, endpoint: preset.endpoint) - case .geminiGenerateContent: - return GeminiProvider(apiKeyProvider: apiKeyProvider, baseURL: preset.endpoint) - } - } -} diff --git a/Sources/InkletCore/LLMProviderPreset.swift b/Sources/InkletCore/LLMProviderPreset.swift index ac200bb..ab3d1f0 100644 --- a/Sources/InkletCore/LLMProviderPreset.swift +++ b/Sources/InkletCore/LLMProviderPreset.swift @@ -1,19 +1,10 @@ import Foundation -public enum LLMProviderKind: String, Codable, Sendable { - case openAIResponses - case openAICompatibleChat - case anthropicMessages - case geminiGenerateContent -} - public struct LLMProviderPreset: Codable, Equatable, Identifiable, Sendable { public var id: String public var name: String public var defaultModel: String public var apiKeyPlaceholder: String - public var keychainService: String - public var kind: LLMProviderKind public var endpoint: URL public init( @@ -21,16 +12,12 @@ public struct LLMProviderPreset: Codable, Equatable, Identifiable, Sendable { name: String, defaultModel: String, apiKeyPlaceholder: String, - keychainService: String, - kind: LLMProviderKind, endpoint: URL ) { self.id = id self.name = name self.defaultModel = defaultModel self.apiKeyPlaceholder = apiKeyPlaceholder - self.keychainService = keychainService - self.kind = kind self.endpoint = endpoint } @@ -39,199 +26,6 @@ public struct LLMProviderPreset: Codable, Equatable, Identifiable, Sendable { name: "OpenAI", defaultModel: "gpt-5.6-luna", apiKeyPlaceholder: "sk-...", - keychainService: KeychainStore.defaultService, - kind: .openAIResponses, endpoint: URL(string: "https://api.openai.com/v1/responses")! ) - - public static let customOpenAICompatible = openAICompatible( - id: "custom-openai-compatible", - name: "Custom OpenAI Compatible", - defaultModel: "gpt-5-mini", - placeholder: "sk-...", - service: "Inklet.CustomOpenAICompatible", - endpoint: "https://api.example.com/v1/chat/completions" - ) - - public static let all: [LLMProviderPreset] = [ - .openAI, - LLMProviderPreset( - id: "anthropic", - name: "Anthropic", - defaultModel: "claude-haiku-4-5", - apiKeyPlaceholder: "sk-ant-...", - keychainService: "Inklet.Anthropic", - kind: .anthropicMessages, - endpoint: URL(string: "https://api.anthropic.com/v1/messages")! - ), - LLMProviderPreset( - id: "gemini", - name: "Google Gemini", - defaultModel: "gemini-flash-latest", - apiKeyPlaceholder: "AIza...", - keychainService: "Inklet.Gemini", - kind: .geminiGenerateContent, - endpoint: URL(string: "https://generativelanguage.googleapis.com/v1beta")! - ), - openAICompatible( - id: "deepseek", - name: "DeepSeek", - defaultModel: "deepseek-v4-flash", - placeholder: "sk-...", - service: "Inklet.DeepSeek", - endpoint: "https://api.deepseek.com/chat/completions" - ), - openAICompatible( - id: "qwen", - name: "Alibaba Qwen", - defaultModel: "qwen3.6-plus", - placeholder: "sk-...", - service: "Inklet.Qwen", - endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" - ), - openAICompatible( - id: "moonshot", - name: "Moonshot Kimi", - defaultModel: "kimi-k2.6", - placeholder: "sk-...", - service: "Inklet.Moonshot", - endpoint: "https://api.moonshot.cn/v1/chat/completions" - ), - openAICompatible( - id: "zhipu", - name: "Zhipu GLM", - defaultModel: "glm-4.7-flash", - placeholder: "...", - service: "Inklet.Zhipu", - endpoint: "https://open.bigmodel.cn/api/paas/v4/chat/completions" - ), - openAICompatible( - id: "minimax", - name: "MiniMax", - defaultModel: "MiniMax-M2.7-highspeed", - placeholder: "...", - service: "Inklet.MiniMax", - endpoint: "https://api.minimax.io/v1/chat/completions" - ), - openAICompatible( - id: "siliconflow", - name: "SiliconFlow", - defaultModel: "deepseek-ai/deepseek-v4-flash", - placeholder: "sk-...", - service: "Inklet.SiliconFlow", - endpoint: "https://api.siliconflow.com/v1/chat/completions" - ), - openAICompatible( - id: "volcengine", - name: "Volcengine Ark", - defaultModel: "doubao-seed-1-6-flash", - placeholder: "...", - service: "Inklet.Volcengine", - endpoint: "https://ark.cn-beijing.volces.com/api/v3/chat/completions" - ), - openAICompatible( - id: "tencent-hunyuan", - name: "Tencent Hunyuan", - defaultModel: "hunyuan-turbos-latest", - placeholder: "...", - service: "Inklet.TencentHunyuan", - endpoint: "https://api.hunyuan.cloud.tencent.com/v1/chat/completions" - ), - openAICompatible( - id: "baichuan", - name: "Baichuan", - defaultModel: "Baichuan4-Turbo", - placeholder: "sk-...", - service: "Inklet.Baichuan", - endpoint: "https://api.baichuan-ai.com/v1/chat/completions" - ), - openAICompatible( - id: "lingyiwanwu", - name: "01.AI Yi", - defaultModel: "yi-lightning", - placeholder: "...", - service: "Inklet.Lingyiwanwu", - endpoint: "https://api.lingyiwanwu.com/v1/chat/completions" - ), - openAICompatible( - id: "xai", - name: "xAI", - defaultModel: "grok-4-fast", - placeholder: "xai-...", - service: "Inklet.xAI", - endpoint: "https://api.x.ai/v1/chat/completions" - ), - openAICompatible( - id: "groq", - name: "Groq", - defaultModel: "meta-llama/llama-4-scout-17b-16e-instruct", - placeholder: "gsk_...", - service: "Inklet.Groq", - endpoint: "https://api.groq.com/openai/v1/chat/completions" - ), - openAICompatible( - id: "mistral", - name: "Mistral", - defaultModel: "mistral-small-latest", - placeholder: "...", - service: "Inklet.Mistral", - endpoint: "https://api.mistral.ai/v1/chat/completions" - ), - openAICompatible( - id: "openrouter", - name: "OpenRouter", - defaultModel: "openai/gpt-5.4-mini", - placeholder: "sk-or-...", - service: "Inklet.OpenRouter", - endpoint: "https://openrouter.ai/api/v1/chat/completions" - ), - openAICompatible( - id: "perplexity", - name: "Perplexity", - defaultModel: "sonar", - placeholder: "pplx-...", - service: "Inklet.Perplexity", - endpoint: "https://api.perplexity.ai/chat/completions" - ), - openAICompatible( - id: "together", - name: "Together AI", - defaultModel: "Qwen/Qwen3.6-Plus", - placeholder: "...", - service: "Inklet.Together", - endpoint: "https://api.together.xyz/v1/chat/completions" - ), - openAICompatible( - id: "cerebras", - name: "Cerebras", - defaultModel: "gpt-oss-120b", - placeholder: "csk-...", - service: "Inklet.Cerebras", - endpoint: "https://api.cerebras.ai/v1/chat/completions" - ), - .customOpenAICompatible - ] - - public static func preset(id: String) -> LLMProviderPreset { - all.first { $0.id == id } ?? .openAI - } - - private static func openAICompatible( - id: String, - name: String, - defaultModel: String, - placeholder: String, - service: String, - endpoint: String - ) -> LLMProviderPreset { - LLMProviderPreset( - id: id, - name: name, - defaultModel: defaultModel, - apiKeyPlaceholder: placeholder, - keychainService: service, - kind: .openAICompatibleChat, - endpoint: URL(string: endpoint)! - ) - } } diff --git a/Sources/InkletCore/ModelCatalogService.swift b/Sources/InkletCore/ModelCatalogService.swift index 95bf450..7c91d33 100644 --- a/Sources/InkletCore/ModelCatalogService.swift +++ b/Sources/InkletCore/ModelCatalogService.swift @@ -129,22 +129,7 @@ public final class ModelCatalogService: @unchecked Sendable { } private static let providerMapping: [String: String] = [ - "openai": "openai", - "anthropic": "anthropic", - "gemini": "google", - "deepseek": "deepseek", - "qwen": "alibaba", - "moonshot": "moonshotai", - "xai": "xai", - "groq": "groq", - "mistral": "mistral", - "openrouter": "openrouter", - "perplexity": "perplexity", - "together": "togetherai", - "cerebras": "cerebras", - "zhipu": "zhipuai", - "minimax": "minimax", - "siliconflow": "siliconflow" + LLMProviderPreset.openAI.id: "openai" ] } diff --git a/Sources/InkletCore/Resources/model-catalog-snapshot.json b/Sources/InkletCore/Resources/model-catalog-snapshot.json index 04f6bde..e113a82 100644 --- a/Sources/InkletCore/Resources/model-catalog-snapshot.json +++ b/Sources/InkletCore/Resources/model-catalog-snapshot.json @@ -53,416 +53,6 @@ "text-embedding-3-small", "gpt-3.5-turbo", "text-embedding-ada-002" - ], - "anthropic": [ - "claude-opus-4-7", - "claude-opus-4-6", - "claude-sonnet-4-6", - "claude-opus-4-5", - "claude-opus-4-5-20251101", - "claude-haiku-4-5", - "claude-haiku-4-5-20251001", - "claude-sonnet-4-5", - "claude-sonnet-4-5-20250929", - "claude-opus-4-1", - "claude-opus-4-1-20250805", - "claude-opus-4-0", - "claude-opus-4-20250514", - "claude-sonnet-4-0", - "claude-sonnet-4-20250514", - "claude-3-7-sonnet-20250219", - "claude-3-5-haiku-20241022", - "claude-3-5-haiku-latest", - "claude-3-5-sonnet-20241022", - "claude-3-5-sonnet-20240620", - "claude-3-haiku-20240307", - "claude-3-sonnet-20240229", - "claude-3-opus-20240229" - ], - "gemini": [ - "gemini-3.5-flash", - "gemini-3.1-flash-lite", - "gemma-4-26b-a4b-it", - "gemma-4-31b-it", - "gemini-3.1-flash-lite-preview", - "gemini-3.1-flash-image-preview", - "gemini-3.1-pro-preview", - "gemini-3.1-pro-preview-customtools", - "gemini-3-flash-preview", - "gemini-3-pro-preview", - "gemini-2.5-flash-lite-preview-09-2025", - "gemini-2.5-flash-preview-09-2025", - "gemini-flash-latest", - "gemini-flash-lite-latest", - "gemini-live-2.5-flash-preview-native-audio", - "gemini-live-2.5-flash", - "gemini-2.5-flash-image", - "gemini-2.5-flash-image-preview", - "gemma-3n-e2b-it", - "gemini-2.5-flash-lite", - "gemini-2.5-flash-lite-preview-06-17", - "gemini-2.5-flash", - "gemini-2.5-pro", - "gemini-2.5-pro-preview-06-05", - "gemini-2.5-flash-preview-05-20", - "gemini-embedding-001", - "gemma-3n-e4b-it", - "gemini-2.5-pro-preview-05-06", - "gemini-2.5-flash-preview-04-17", - "gemma-3-12b-it", - "gemma-3-4b-it", - "gemma-3-27b-it", - "gemini-2.0-flash", - "gemini-2.0-flash-lite", - "gemini-1.5-flash-8b", - "gemini-1.5-flash", - "gemini-1.5-pro" - ], - "deepseek": [ - "deepseek-v4-flash", - "deepseek-v4-pro", - "deepseek-chat", - "deepseek-reasoner" - ], - "qwen": [ - "qwen3.6-27b", - "qwen3.6-max-preview", - "qwen3.6-35b-a3b", - "qwen3.6-plus", - "qwen3.5-122b-a10b", - "qwen3.5-27b", - "qwen3.5-35b-a3b", - "qwen3.5-plus", - "qwen3.5-397b-a17b", - "qwen3-max", - "qwen3-vl-plus", - "qwen3-livetranslate-flash-realtime", - "qwen3-omni-flash", - "qwen3-omni-flash-realtime", - "qwen-plus", - "qwen3-next-80b-a3b-instruct", - "qwen3-next-80b-a3b-thinking", - "qwen-vl-plus", - "qwen-vl-max", - "qwen-flash", - "qwen3-coder-flash", - "qwen3-coder-plus", - "qwen-omni-turbo-realtime", - "qwen-turbo", - "qwen-vl-ocr", - "qwen3-14b", - "qwen3-235b-a22b", - "qwen3-32b", - "qwen3-8b", - "qwen3-coder-30b-a3b-instruct", - "qwen3-coder-480b-a35b-instruct", - "qwen3-vl-235b-a22b", - "qwen3-vl-30b-a3b", - "qwen-omni-turbo", - "qvq-max", - "qwq-plus", - "qwen-max", - "qwen-mt-plus", - "qwen-mt-turbo", - "qwen2-5-omni-7b", - "qwen2-5-14b-instruct", - "qwen2-5-32b-instruct", - "qwen2-5-72b-instruct", - "qwen2-5-7b-instruct", - "qwen2-5-vl-72b-instruct", - "qwen2-5-vl-7b-instruct", - "qwen-plus-character-ja" - ], - "moonshot": [ - "kimi-k2.6", - "kimi-k2.5", - "kimi-k2-thinking", - "kimi-k2-thinking-turbo", - "kimi-k2-0905-preview", - "kimi-k2-turbo-preview", - "kimi-k2-0711-preview" - ], - "xai": [ - "grok-4.3", - "grok-4.20-0309-non-reasoning", - "grok-4.20-0309-reasoning", - "grok-4.20-multi-agent-0309", - "grok-2-1212", - "grok-2-latest", - "grok-2-vision-1212", - "grok-2-vision-latest", - "grok-beta", - "grok-vision-beta", - "grok-2", - "grok-2-vision" - ], - "groq": [ - "moonshotai/kimi-k2-instruct-0905", - "groq/compound", - "groq/compound-mini", - "openai/gpt-oss-120b", - "openai/gpt-oss-20b", - "moonshotai/kimi-k2-instruct", - "meta-llama/llama-4-maverick-17b-128e-instruct", - "meta-llama/llama-4-scout-17b-16e-instruct", - "meta-llama/llama-guard-4-12b", - "openai/gpt-oss-safeguard-20b", - "mistral-saba-24b", - "deepseek-r1-distill-llama-70b", - "qwen/qwen3-32b", - "llama-3.3-70b-versatile", - "qwen-qwq-32b", - "meta-llama/llama-prompt-guard-2-22m", - "meta-llama/llama-prompt-guard-2-86m", - "allam-2-7b", - "llama-3.1-8b-instant", - "llama-guard-3-8b", - "gemma2-9b-it", - "llama3-70b-8192", - "llama3-8b-8192" - ], - "mistral": [ - "mistral-medium-2604", - "mistral-medium-latest", - "mistral-small-2603", - "mistral-small-latest", - "devstral-2512", - "labs-devstral-small-2512", - "devstral-medium-latest", - "mistral-large-2512", - "mistral-large-latest", - "mistral-medium-2508", - "devstral-medium-2507", - "devstral-small-2507", - "mistral-small-2506", - "devstral-small-2505", - "mistral-medium-2505", - "magistral-medium-latest", - "magistral-small", - "codestral-latest", - "mistral-large-2411", - "pixtral-large-latest", - "ministral-3b-latest", - "ministral-8b-latest", - "pixtral-12b", - "mistral-nemo", - "open-mixtral-8x22b", - "mistral-embed", - "open-mixtral-8x7b", - "open-mistral-7b" - ], - "openrouter": [ - "anthropic/claude-opus-4.7-fast", - "perceptron/perceptron-mk1", - "inclusionai/ring-2.6-1t", - "google/gemini-3.1-flash-lite", - "baidu/cobuddy:free", - "openai/gpt-chat-latest", - "ibm-granite/granite-4.1-8b", - "mistralai/mistral-medium-3-5", - "x-ai/grok-4.3", - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", - "openrouter/owl-alpha", - "poolside/laguna-m.1:free", - "poolside/laguna-xs.2:free", - "~anthropic/claude-haiku-latest", - "~anthropic/claude-sonnet-latest", - "~google/gemini-flash-latest", - "~google/gemini-pro-latest", - "~moonshotai/kimi-latest", - "~openai/gpt-latest", - "~openai/gpt-mini-latest", - "qwen/qwen3.5-plus-20260420", - "qwen/qwen3.6-27b", - "qwen/qwen3.6-35b-a3b", - "qwen/qwen3.6-flash", - "qwen/qwen3.6-max-preview", - "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-flash:free", - "deepseek/deepseek-v4-pro", - "openai/gpt-5.5", - "openai/gpt-5.5-pro", - "inclusionai/ling-2.6-1t", - "tencent/hy3-preview", - "xiaomi/mimo-v2.5", - "xiaomi/mimo-v2.5-pro", - "~anthropic/claude-opus-latest", - "inclusionai/ling-2.6-flash", - "openai/gpt-5.4-image-2", - "openrouter/pareto-code", - "baidu/qianfan-ocr-fast", - "moonshotai/kimi-k2.6", - "anthropic/claude-opus-4.7", - "anthropic/claude-opus-4.6-fast", - "z-ai/glm-5.1", - "google/gemma-4-26b-a4b-it", - "google/gemma-4-26b-a4b-it:free", - "google/gemma-4-31b-it", - "google/gemma-4-31b-it:free", - "qwen/qwen3.6-plus", - "arcee-ai/trinity-large-thinking", - "arcee-ai/trinity-large-thinking:free", - "z-ai/glm-5v-turbo", - "x-ai/grok-4.20", - "x-ai/grok-4.20-multi-agent", - "google/lyria-3-clip-preview", - "google/lyria-3-pro-preview", - "kwaipilot/kat-coder-pro-v2", - "rekaai/reka-edge", - "minimax/minimax-m2.7", - "xiaomi/mimo-v2-omni", - "xiaomi/mimo-v2-pro", - "openai/gpt-5.4-mini", - "openai/gpt-5.4-nano", - "mistralai/mistral-small-2603", - "z-ai/glm-5-turbo", - "nvidia/nemotron-3-super-120b-a12b", - "nvidia/nemotron-3-super-120b-a12b:free", - "bytedance-seed/seed-2.0-lite", - "qwen/qwen3.5-9b", - "openai/gpt-5.4", - "openai/gpt-5.4-pro", - "inception/mercury-2", - "google/gemini-3.1-flash-lite-preview", - "openai/gpt-5.3-chat", - "bytedance-seed/seed-2.0-mini", - "google/gemini-3.1-flash-image-preview", - "google/gemini-3.1-pro-preview-customtools", - "liquid/lfm-2-24b-a2b", - "qwen/qwen3.5-122b-a10b", - "qwen/qwen3.5-27b", - "qwen/qwen3.5-35b-a3b" - ], - "perplexity": [ - "sonar", - "sonar-deep-research", - "sonar-pro", - "sonar-reasoning-pro" - ], - "together": [ - "Qwen/Qwen3.6-Plus", - "deepseek-ai/DeepSeek-V4-Pro", - "moonshotai/Kimi-K2.6", - "google/gemma-4-31B-it", - "zai-org/GLM-5.1", - "MiniMaxAI/MiniMax-M2.7", - "Qwen/Qwen3.5-397B-A17B", - "MiniMaxAI/MiniMax-M2.5", - "Qwen/Qwen3-Coder-Next-FP8", - "moonshotai/Kimi-K2.5", - "essentialai/Rnj-1-Instruct", - "deepseek-ai/DeepSeek-V3-1", - "openai/gpt-oss-120b", - "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", - "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", - "deepseek-ai/DeepSeek-V3", - "deepseek-ai/DeepSeek-R1", - "meta-llama/Llama-3.3-70B-Instruct-Turbo" - ], - "cerebras": [ - "zai-glm-4.7", - "gpt-oss-120b", - "qwen-3-235b-a22b-instruct-2507", - "llama3.1-8b" - ], - "zhipu": [ - "glm-5v-turbo", - "glm-5.1", - "glm-5", - "glm-4.7-flash", - "glm-4.7-flashx", - "glm-4.7", - "glm-4.6v", - "glm-4.6", - "glm-4.5v", - "glm-4.5", - "glm-4.5-air", - "glm-4.5-flash" - ], - "minimax": [ - "MiniMax-M2.7", - "MiniMax-M2.7-highspeed", - "MiniMax-M2.5-highspeed", - "MiniMax-M2.5", - "MiniMax-M2.1", - "MiniMax-M2" - ], - "siliconflow": [ - "deepseek-ai/deepseek-v4-flash", - "deepseek-ai/deepseek-v4-pro", - "moonshotai/Kimi-K2.6", - "zai-org/GLM-5.1", - "zai-org/GLM-5V-Turbo", - "MiniMaxAI/MiniMax-M2.5", - "zai-org/GLM-5", - "stepfun-ai/Step-3.5-Flash", - "moonshotai/Kimi-K2.5", - "MiniMaxAI/MiniMax-M2.1", - "zai-org/GLM-4.7", - "zai-org/GLM-4.6V", - "deepseek-ai/DeepSeek-V3.2", - "baidu/ERNIE-4.5-300B-A47B", - "ByteDance-Seed/Seed-OSS-36B-Instruct", - "deepseek-ai/DeepSeek-R1", - "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "deepseek-ai/DeepSeek-V3", - "deepseek-ai/DeepSeek-V3.1", - "deepseek-ai/DeepSeek-V3.1-Terminus", - "deepseek-ai/DeepSeek-V3.2-Exp", - "deepseek-ai/deepseek-vl2", - "inclusionAI/Ling-flash-2.0", - "inclusionAI/Ling-mini-2.0", - "inclusionAI/Ring-flash-2.0", - "meta-llama/Meta-Llama-3.1-8B-Instruct", - "moonshotai/Kimi-K2-Instruct", - "moonshotai/Kimi-K2-Instruct-0905", - "moonshotai/Kimi-K2-Thinking", - "nex-agi/DeepSeek-V3.1-Nex-N1", - "openai/gpt-oss-120b", - "openai/gpt-oss-20b", - "Qwen/Qwen2.5-14B-Instruct", - "Qwen/Qwen2.5-32B-Instruct", - "Qwen/Qwen2.5-72B-Instruct", - "Qwen/Qwen2.5-72B-Instruct-128K", - "Qwen/Qwen2.5-7B-Instruct", - "Qwen/Qwen2.5-Coder-32B-Instruct", - "Qwen/Qwen2.5-VL-32B-Instruct", - "Qwen/Qwen2.5-VL-72B-Instruct", - "Qwen/Qwen2.5-VL-7B-Instruct", - "Qwen/Qwen3-14B", - "Qwen/Qwen3-235B-A22B", - "Qwen/Qwen3-235B-A22B-Instruct-2507", - "Qwen/Qwen3-235B-A22B-Thinking-2507", - "Qwen/Qwen3-30B-A3B-Instruct-2507", - "Qwen/Qwen3-30B-A3B-Thinking-2507", - "Qwen/Qwen3-32B", - "Qwen/Qwen3-8B", - "Qwen/Qwen3-Coder-30B-A3B-Instruct", - "Qwen/Qwen3-Coder-480B-A35B-Instruct", - "Qwen/Qwen3-Next-80B-A3B-Instruct", - "Qwen/Qwen3-Next-80B-A3B-Thinking", - "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "Qwen/Qwen3-Omni-30B-A3B-Thinking", - "Qwen/Qwen3-VL-235B-A22B-Instruct", - "Qwen/Qwen3-VL-235B-A22B-Thinking", - "Qwen/Qwen3-VL-30B-A3B-Instruct", - "Qwen/Qwen3-VL-30B-A3B-Thinking", - "Qwen/Qwen3-VL-32B-Instruct", - "Qwen/Qwen3-VL-32B-Thinking", - "Qwen/Qwen3-VL-8B-Instruct", - "Qwen/Qwen3-VL-8B-Thinking", - "Qwen/QwQ-32B", - "tencent/Hunyuan-A13B-Instruct", - "tencent/Hunyuan-MT-7B", - "THUDM/GLM-4-32B-0414", - "THUDM/GLM-4-9B-0414", - "THUDM/GLM-Z1-32B-0414", - "THUDM/GLM-Z1-9B-0414", - "zai-org/GLM-4.5", - "zai-org/GLM-4.5-Air", - "zai-org/GLM-4.5V", - "zai-org/GLM-4.6" ] } } diff --git a/Tests/InkletCoreTests/ConfigStoreTests.swift b/Tests/InkletCoreTests/ConfigStoreTests.swift index 046a12d..1d94b41 100644 --- a/Tests/InkletCoreTests/ConfigStoreTests.swift +++ b/Tests/InkletCoreTests/ConfigStoreTests.swift @@ -141,10 +141,6 @@ final class ConfigStoreTests: XCTestCase { XCTAssertEqual(config.hotkey, "⌥Space") XCTAssertEqual(config.appearance, .system) XCTAssertEqual(config.defaultVisibleModeID, PromptMode.translateToEnglishID) - XCTAssertEqual( - config.customOpenAICompatibleEndpoint, - LLMProviderPreset.customOpenAICompatible.endpoint.absoluteString - ) XCTAssertEqual(config.selectionActions, SelectionActionsConfig.defaultConfig()) } @@ -161,7 +157,6 @@ final class ConfigStoreTests: XCTestCase { config.timeoutSeconds = 9 config.hotkey = "⌘Space" config.appearance = .dark - config.customOpenAICompatibleEndpoint = "http://127.0.0.1:1234/v1/chat/completions" config.selectionActions = SelectionActionsConfig( isEnabled: false, translationLanguage: .japanese, @@ -201,10 +196,6 @@ final class ConfigStoreTests: XCTestCase { XCTAssertEqual(config.appearance, AppConfig.defaultConfig().appearance) XCTAssertEqual(config.defaultVisibleModeID, AppConfig.defaultConfig().defaultVisibleModeID) XCTAssertEqual(config.promptModes, AppConfig.defaultConfig().promptModes) - XCTAssertEqual( - config.customOpenAICompatibleEndpoint, - AppConfig.defaultConfig().customOpenAICompatibleEndpoint - ) XCTAssertEqual(config.selectionActions, AppConfig.defaultConfig().selectionActions) } @@ -509,8 +500,7 @@ final class ConfigStoreTests: XCTestCase { func testResolvedProviderPresetAlwaysUsesOpenAI() { var config = AppConfig.defaultConfig() - config.providerID = LLMProviderPreset.customOpenAICompatible.id - config.customOpenAICompatibleEndpoint = "http://127.0.0.1:1234/v1/chat/completions" + config.providerID = "custom-openai-compatible" XCTAssertEqual( config.resolvedProviderPreset, diff --git a/Tests/InkletCoreTests/LLMProviderTests.swift b/Tests/InkletCoreTests/LLMProviderTests.swift deleted file mode 100644 index 7141158..0000000 --- a/Tests/InkletCoreTests/LLMProviderTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -import XCTest -@testable import InkletCore - -final class LLMProviderTests: XCTestCase { - func testProviderPresetCoversMainstreamProviders() { - XCTAssertEqual( - LLMProviderPreset.all.map(\.id), - [ - "openai", - "anthropic", - "gemini", - "deepseek", - "qwen", - "moonshot", - "zhipu", - "minimax", - "siliconflow", - "volcengine", - "tencent-hunyuan", - "baichuan", - "lingyiwanwu", - "xai", - "groq", - "mistral", - "openrouter", - "perplexity", - "together", - "cerebras", - "custom-openai-compatible" - ] - ) - } - - func testProviderDefaultsUseCurrentFastModels() { - let defaults = Dictionary(uniqueKeysWithValues: LLMProviderPreset.all.map { ($0.id, $0.defaultModel) }) - - XCTAssertEqual(defaults["openai"], "gpt-5.6-luna") - XCTAssertEqual(defaults["anthropic"], "claude-haiku-4-5") - XCTAssertEqual(defaults["gemini"], "gemini-flash-latest") - XCTAssertEqual(defaults["deepseek"], "deepseek-v4-flash") - XCTAssertEqual(defaults["qwen"], "qwen3.6-plus") - XCTAssertEqual(defaults["minimax"], "MiniMax-M2.7-highspeed") - XCTAssertEqual(defaults["groq"], "meta-llama/llama-4-scout-17b-16e-instruct") - XCTAssertEqual(defaults["openrouter"], "openai/gpt-5.4-mini") - XCTAssertEqual(defaults["custom-openai-compatible"], "gpt-5-mini") - XCTAssertTrue(LLMProviderPreset.all.allSatisfy { !$0.defaultModel.isEmpty }) - } - - func testChatCompletionBuildsOpenAICompatibleRequestWithoutTemperature() throws { - let body = ChatCompletionProvider.makeRequestBody(for: request) - let json = try encodedJSONObject(body) - let messages = try XCTUnwrap(json["messages"] as? [[String: Any]]) - - XCTAssertNil(json["temperature"]) - XCTAssertEqual(json["model"] as? String, "test-model") - XCTAssertEqual(messages.compactMap { $0["role"] as? String }, ["system", "user"]) - XCTAssertEqual(messages.compactMap { $0["content"] as? String }, ["Rewrite clearly.", "Hello"]) - } - - func testChatCompletionParsesResponseText() throws { - let json = #"{"choices":[{"message":{"content":"Hi there."}}]}"# - let data = try XCTUnwrap(json.data(using: .utf8)) - - XCTAssertEqual(try ChatCompletionProvider.parseOutputText(from: data), "Hi there.") - } - - func testAnthropicBuildsMessagesRequestWithoutTemperature() throws { - let body = AnthropicProvider.makeRequestBody(for: request) - let json = try encodedJSONObject(body) - let messages = try XCTUnwrap(json["messages"] as? [[String: Any]]) - let content = try XCTUnwrap(messages.first?["content"] as? [[String: Any]]) - - XCTAssertNil(json["temperature"]) - XCTAssertEqual(json["model"] as? String, "test-model") - XCTAssertEqual(json["system"] as? String, "Rewrite clearly.") - XCTAssertEqual(messages.first?["role"] as? String, "user") - XCTAssertEqual(content.first?["text"] as? String, "Hello") - } - - func testAnthropicParsesResponseText() throws { - let json = #"{"content":[{"type":"text","text":"Hi there."}]}"# - let data = try XCTUnwrap(json.data(using: .utf8)) - - XCTAssertEqual(try AnthropicProvider.parseOutputText(from: data), "Hi there.") - } - - func testGeminiBuildsGenerateContentRequestWithoutGenerationConfig() throws { - let body = GeminiProvider.makeRequestBody(for: request) - let json = try encodedJSONObject(body) - let systemInstruction = try XCTUnwrap(json["systemInstruction"] as? [String: Any]) - let systemParts = try XCTUnwrap(systemInstruction["parts"] as? [[String: Any]]) - let contents = try XCTUnwrap(json["contents"] as? [[String: Any]]) - let userParts = try XCTUnwrap(contents.first?["parts"] as? [[String: Any]]) - - XCTAssertNil(json["generationConfig"]) - XCTAssertEqual(systemParts.first?["text"] as? String, "Rewrite clearly.") - XCTAssertEqual(contents.first?["role"] as? String, "user") - XCTAssertEqual(userParts.first?["text"] as? String, "Hello") - } - - func testGeminiParsesResponseText() throws { - let json = #"{"candidates":[{"content":{"parts":[{"text":"Hi there."}]}}]}"# - let data = try XCTUnwrap(json.data(using: .utf8)) - - XCTAssertEqual(try GeminiProvider.parseOutputText(from: data), "Hi there.") - } - - private func encodedJSONObject(_ value: T) throws -> [String: Any] { - let data = try JSONEncoder().encode(value) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - } - - private var request: TransformationRequest { - TransformationRequest( - sourceText: "Hello", - systemPrompt: "Rewrite clearly.", - modeID: "polish", - modeName: "Polish", - model: "test-model", - timeoutSeconds: 10 - ) - } -} diff --git a/Tests/InkletCoreTests/ModelCatalogServiceTests.swift b/Tests/InkletCoreTests/ModelCatalogServiceTests.swift index a1f7c43..2b74fc2 100644 --- a/Tests/InkletCoreTests/ModelCatalogServiceTests.swift +++ b/Tests/InkletCoreTests/ModelCatalogServiceTests.swift @@ -28,7 +28,7 @@ final class ModelCatalogServiceTests: XCTestCase { try await service.refreshIfNeeded() XCTAssertEqual(service.cachedModelIDs(for: "openai"), ["gpt-5.2", "gpt-4.1-mini"]) - XCTAssertEqual(service.cachedModelIDs(for: "gemini"), ["gemini-3.5-flash"]) + XCTAssertNil(service.cachedModelIDs(for: "gemini")) XCTAssertNil(service.cachedModelIDs(for: "custom-openai-compatible")) } @@ -86,8 +86,6 @@ final class ModelCatalogServiceTests: XCTestCase { let service = ModelCatalogService(userDefaults: userDefaults) XCTAssertFalse(service.cachedModelIDs(for: "openai")?.isEmpty ?? true) - XCTAssertFalse(service.cachedModelIDs(for: "anthropic")?.isEmpty ?? true) - XCTAssertFalse(service.cachedModelIDs(for: "gemini")?.isEmpty ?? true) } func testBundledFallbackURLFindsAppResourcesBundle() throws { From 71373fb4fbd716175c178f27bca1a5d821e7c53d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:55:10 +0000 Subject: [PATCH 2/6] Run the unit test suite on pull requests The full suite previously ran only inside the DMG release workflow, so a broken test surfaced at release time. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DzisdHuvFVePscX66cn3Lw --- .github/workflows/test.yml | 17 +++++++++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..60573f9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + - name: Run unit tests + run: swift test diff --git a/README.md b/README.md index 3ff9e92..d63a0e0 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ swift test If tests fail because `XCTest` is unavailable, install the full Xcode app instead of using only Command Line Tools. -Check all UI languages with `scripts/check-localization.sh`. Add `--snapshots` to generate a local HTML gallery of synthetic native UI fixtures under `.build/localization-audit/`; the command prints its path. Pull requests run the localization checks automatically. See [localization coverage and remaining manual checks](docs/localization-audit.md). +Check all UI languages with `scripts/check-localization.sh`. Add `--snapshots` to generate a local HTML gallery of synthetic native UI fixtures under `.build/localization-audit/`; the command prints its path. Pull requests and pushes to `main` run the full unit test suite and the localization checks automatically. See [localization coverage and remaining manual checks](docs/localization-audit.md). ## Keyboard Flow diff --git a/README.zh-CN.md b/README.zh-CN.md index 681b49c..3960878 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -131,7 +131,7 @@ swift test 如果测试因为 `XCTest` 不可用而失败,请安装完整 Xcode,而不是只安装 Command Line Tools。 -使用 `scripts/check-localization.sh` 检查所有界面语言。加上 `--snapshots` 可在 `.build/localization-audit/` 下生成使用模拟数据的原生界面 HTML 截图集,命令会输出文件路径。Pull request 会自动运行本地化检查。检查范围和仍需手动验证的项目见[本地化检查说明](docs/localization-audit.md)。 +使用 `scripts/check-localization.sh` 检查所有界面语言。加上 `--snapshots` 可在 `.build/localization-audit/` 下生成使用模拟数据的原生界面 HTML 截图集,命令会输出文件路径。Pull request 和推送到 `main` 的提交会自动运行全部单元测试和本地化检查。检查范围和仍需手动验证的项目见[本地化检查说明](docs/localization-audit.md)。 ## 快捷键 From 8b6dc73b48c031422d06407deaefd895e6f356b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:01:00 +0000 Subject: [PATCH 3/6] Move selection actions out of AppCoordinator The Translate and Pronounce flow (selection reads, translation, speech, copy feedback and their task bookkeeping) now lives in SelectionActionsController. AppCoordinator wires it up, reports its idle state to migration and update gating, and drives its shutdown and maintenance steps in the same order as before. Source tests that pin the selection invariants now read the new file. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DzisdHuvFVePscX66cn3Lw --- Sources/InkletApp/AppCoordinator.swift | 730 +---------------- .../SelectionActionsController.swift | 766 ++++++++++++++++++ .../AppCoordinatorSourceTests.swift | 168 ++-- .../LegacyMigrationAppSourceTests.swift | 35 +- .../SelectionActionMonitorSourceTests.swift | 2 +- 5 files changed, 929 insertions(+), 772 deletions(-) create mode 100644 Sources/InkletApp/SelectionActionsController.swift diff --git a/Sources/InkletApp/AppCoordinator.swift b/Sources/InkletApp/AppCoordinator.swift index 4a03409..86e4f96 100644 --- a/Sources/InkletApp/AppCoordinator.swift +++ b/Sources/InkletApp/AppCoordinator.swift @@ -36,23 +36,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -@MainActor -private enum SelectionPronunciationReturnState: Equatable { - case menu - case translationResult -} - -private struct PendingSelectionRead { - let sourceProcessIdentifier: pid_t - let location: SelectionPoint -} - -private struct PendingUserCopyRead { - let sourceProcessIdentifier: pid_t - let location: SelectionPoint - let clipboardHandoff: SelectionClipboardUserCopyHandoff -} - @MainActor enum UpdateCheckMenuConfiguration { static func apply(to menu: NSMenu) { @@ -74,14 +57,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private let configStore: UserDefaultsConfigStore private let accessibilityPermissionService: AccessibilityPermissionService private let apiKeyStore: LocalAPIKeyStore - private let selectionActionMonitor: SelectionActionMonitor - private let selectionActionWindowController: SelectionActionWindowController - private let selectionSourceValidator: SelectionSourceValidator - private let selectionClipboardReader: SelectionClipboardReader - private let selectionUserCopyReader: SelectionUserCopyReader - private let selectionReadPipeline: SelectionReadPipeline - private let selectionTranslationCache: JSONSelectionTranslationCache - private let speechPlaybackService: SpeechPlaybackService + private let selectionActions: SelectionActionsController private let historyStore: JSONLHistoryStore private var configObserver: NSObjectProtocol? private var accessibilityObserver: NSObjectProtocol? @@ -92,23 +68,8 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private var settingsShortcutMonitor: Any? private var lastTargetApplication: NSRunningApplication? private var isRecordingHotkey = false - private var selectionActionCoordinator: SelectionActionCoordinator - private let selectionTaskRegistry = SelectionTaskRegistry() - private var selectionReadTask: Task? - private var selectionTranslationTask: Task? - private var selectionTTSTask: Task? - private var selectionCopyFeedbackTask: Task? - private var selectionReadTaskID: UUID? - private var selectionTranslationTaskID: UUID? - private var selectionTTSTaskID: UUID? - private var selectionCopyFeedbackTaskID: UUID? - private var isSelectionSpeechPlaying = false private var isMigrationMaintenanceActive = false private var isStopping = false - private var currentSelectionText = "" - private var currentTranslationText = "" - private var selectionPronunciationReturnState = SelectionPronunciationReturnState.menu - private var panelDismissalPolicy = SelectionPanelDismissalPolicy() private var mainUpdateCheckMenuItem: NSMenuItem? private var statusUpdateCheckMenuItem: NSMenuItem? private var trackedMenus: Set = [] @@ -143,17 +104,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { let configStore = UserDefaultsConfigStore() let apiKeyStore = LocalAPIKeyStore() let migrationPresentationModel = LegacyMigrationPresentationModel(outcome: migrationOutcome) - let selectionSourceValidator = SelectionSourceValidator() - let sourceValidator: SelectionReadPipeline.SourceValidator = { - selectionSourceValidator.isCurrent($0) - } - let selectedTextReader = SelectedTextReader() - let selectionClipboardReader = SelectionClipboardReader( - sourceProcessValidator: sourceValidator - ) - let selectionUserCopyReader = SelectionUserCopyReader( - sourceProcessValidator: sourceValidator - ) + let accessibilityPermissionService = AccessibilityPermissionService() self.migrationOutcome = migrationOutcome self.migrator = migrator @@ -173,34 +124,16 @@ final class AppCoordinator: NSObject, NSMenuDelegate { self.aboutController = AboutWindowController() self.hotkeyManager = GlobalHotkeyManager() self.configStore = configStore - self.accessibilityPermissionService = AccessibilityPermissionService() + self.accessibilityPermissionService = accessibilityPermissionService self.apiKeyStore = apiKeyStore - self.selectionActionMonitor = SelectionActionMonitor() - self.selectionActionWindowController = SelectionActionWindowController() - self.selectionSourceValidator = selectionSourceValidator - self.selectionClipboardReader = selectionClipboardReader - self.selectionUserCopyReader = selectionUserCopyReader - self.selectionReadPipeline = SelectionReadPipeline( - sourceValidator: sourceValidator, - accessibilityReader: { sourceProcessIdentifier, mouseLocation in - selectedTextReader.readSelectedText( - sourceProcessIdentifier: sourceProcessIdentifier, - mouseLocation: mouseLocation - ) - }, - clipboardReader: { sourceProcessIdentifier, forceSelectionMode in - await selectionClipboardReader.readSelectedText( - sourceProcessIdentifier: sourceProcessIdentifier, - forceSelectionMode: forceSelectionMode - ) - } - ) - self.selectionTranslationCache = JSONSelectionTranslationCache( - fileURL: storagePaths.translationCacheFileURL - ) - self.speechPlaybackService = SpeechPlaybackService() - self.selectionActionCoordinator = SelectionActionCoordinator( - config: ((try? configStore.load()) ?? AppConfig.defaultConfig()).selectionActions + self.selectionActions = SelectionActionsController( + configStore: configStore, + apiKeyStore: apiKeyStore, + historyStore: historyStore, + accessibilityPermissionService: accessibilityPermissionService, + translationCache: JSONSelectionTranslationCache( + fileURL: storagePaths.translationCacheFileURL + ) ) super.init() @@ -227,77 +160,13 @@ final class AppCoordinator: NSObject, NSMenuDelegate { self.settingsController.onMigrationWorkflowIdleChange = { [weak self] _ in self?.refreshMigrationImportEligibility() } - self.selectionActionMonitor.onCandidateSelection = { [weak self] point in - guard let self, !self.isStopping else { return } - self.handleSelectionActionCandidate(at: point) - } - self.selectionActionMonitor.onCopyTrigger = { [weak self] trigger in - guard let self, - !self.isStopping, - !isMigrationMaintenanceActive, - trigger.sourceProcessIdentifier > 0, - trigger.sourceProcessIdentifier != NSRunningApplication.current.processIdentifier, - selectionSourceValidator.isCurrent(trigger.sourceProcessIdentifier) - else { - return - } - let clipboardHandoff = selectionClipboardReader.beginUserCopyHandoff() - let pendingUserCopyRead = PendingUserCopyRead( - sourceProcessIdentifier: trigger.sourceProcessIdentifier, - location: trigger.point, - clipboardHandoff: clipboardHandoff - ) - self.handleSelectionActionCopyTrigger(pendingUserCopyRead) + self.selectionActions.onWorkStateChange = { [weak self] in + self?.refreshMigrationImportEligibility() } - self.selectionActionMonitor.onDismiss = { [weak self] reason in + self.selectionActions.onInteractionEnded = { [weak self] in guard let self, !self.isStopping else { return } - self.handleSelectionDismissRequest( - reason: String(describing: reason), - bypassingPanelGrace: reason.bypassesPanelGrace - ) - } - self.selectionActionMonitor.onInteractionStateChange = { [weak self] isActive in - guard let self, !self.isStopping, !isActive else { return } self.automaticUpdatePresentationGate.schedule() } - self.selectionActionWindowController.onTranslate = { [weak self] in - Task { @MainActor in - self?.translateCurrentSelection() - } - } - self.selectionActionWindowController.onPronounce = { [weak self] in - Task { @MainActor in - self?.pronounceCurrentSelection() - } - } - self.selectionActionWindowController.onPronounceOriginal = { [weak self] in - Task { @MainActor in - self?.pronounceOriginalFromTranslation() - } - } - self.selectionActionWindowController.onPronounceTranslation = { [weak self] in - Task { @MainActor in - self?.pronounceCurrentTranslation() - } - } - self.selectionActionWindowController.onCopyTranslation = { [weak self] in - self?.copyCurrentTranslation() - } - self.selectionActionWindowController.onRetryTranslation = { [weak self] in - Task { @MainActor in - self?.translateCurrentSelection() - } - } - self.selectionActionWindowController.onDismiss = { [weak self] in - guard let self, !self.isStopping else { return } - self.forceDismissSelectionActions(reason: "selectionPanelEscape") - } - self.speechPlaybackService.onFinish = { [weak self] in - guard let self, !self.isStopping else { return } - self.isSelectionSpeechPlaying = false - self.restoreSelectionPronunciationReturnState() - self.refreshMigrationImportEligibility() - } } private func makeUpdateCheckCoordinator() -> UpdateCheckCoordinator { @@ -350,7 +219,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { } self.registerConfiguredHotkey() self.windowController.reloadDictationConfiguration() - self.configureSelectionActions() + self.selectionActions.configureSelectionActions() } } @@ -361,7 +230,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { ) { [weak self] _ in Task { @MainActor in guard let self, !self.isStopping else { return } - self.configureSelectionActions() + self.selectionActions.configureSelectionActions() } } @@ -402,7 +271,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { registerConfiguredHotkey() windowController.reloadDictationConfiguration() - configureSelectionActions() + selectionActions.configureSelectionActions() showPermissionSettingsIfNeeded() installSettingsShortcutMonitor() refreshMigrationImportEligibility() @@ -443,23 +312,10 @@ final class AppCoordinator: NSObject, NSMenuDelegate { self.settingsShortcutMonitor = nil } hotkeyManager.unregister() - selectionActionMonitor.stop() - let selectionTasks = selectionTaskRegistry.snapshotAndClear() - selectionReadTask = nil - selectionTranslationTask = nil - selectionTTSTask = nil - selectionCopyFeedbackTask = nil - selectionReadTaskID = nil - selectionTranslationTaskID = nil - selectionTTSTaskID = nil - selectionCopyFeedbackTaskID = nil - selectionTasks.cancel() - speechPlaybackService.stop() - isSelectionSpeechPlaying = false + selectionActions.beginStopping() await windowController.cancelDictationAndWait() - await selectionClipboardReader.cancelActiveRead() - await selectionTasks.waitForCompletion() + await selectionActions.finishStopping() } private func configureMainMenu() { @@ -544,10 +400,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private var migrationWorkflowsAreIdle: Bool { !windowController.isBusy && settingsController.isMigrationWorkflowIdle - && selectionReadTask == nil - && selectionTranslationTask == nil - && selectionTTSTask == nil - && !isSelectionSpeechPlaying + && selectionActions.isIdle } private var canRequestAssistedMigration: Bool { @@ -561,8 +414,8 @@ final class AppCoordinator: NSObject, NSMenuDelegate { migrationWorkflowsAreIdle: migrationWorkflowsAreIdle, isSelectingMigrationSource: migrationPresentationModel.phase == .selecting, hasModalWindow: NSApp.modalWindow != nil, - isSelectionPanelVisible: selectionActionWindowController.isPanelVisible, - isSelectionInteractionActive: selectionActionMonitor.isInteractionActive, + isSelectionPanelVisible: selectionActions.isPanelVisible, + isSelectionInteractionActive: selectionActions.isInteractionActive, isMenuTracking: !trackedMenus.isEmpty, isUpdateAlertPresenting: updateCheckAlertPresenter.isPresentingAlert, isStopping: isStopping @@ -651,23 +504,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private func enterMigrationMaintenance() async { isMigrationMaintenanceActive = true hotkeyManager.unregister() - selectionActionMonitor.stop() - selectionReadTask?.cancel() - selectionReadTask = nil - selectionReadTaskID = nil - selectionTranslationTask?.cancel() - selectionTranslationTask = nil - selectionTranslationTaskID = nil - selectionTTSTask?.cancel() - selectionTTSTask = nil - selectionTTSTaskID = nil - selectionCopyFeedbackTask?.cancel() - selectionCopyFeedbackTask = nil - selectionCopyFeedbackTaskID = nil - forceDismissSelectionActions(reason: "migrationMaintenance") - selectionActionWindowController.hidePanel() - speechPlaybackService.stop() - isSelectionSpeechPlaying = false + selectionActions.enterMigrationMaintenance() await windowController.cancelForMigrationMaintenance() await settingsController.waitForMigrationMaintenanceQuiescence() refreshMigrationImportEligibility() @@ -678,7 +515,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { isMigrationMaintenanceActive = false registerConfiguredHotkey() windowController.reloadDictationConfiguration() - configureSelectionActions() + selectionActions.leaveMigrationMaintenance() refreshMigrationImportEligibility() } @@ -720,17 +557,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private func handleActivatedApplication(_ application: NSRunningApplication?) { guard !isStopping else { return } rememberTargetApplication(application) - if SelectionActivationDismissalPolicy.shouldDismiss( - activatedProcessIdentifier: application?.processIdentifier, - currentProcessIdentifier: NSRunningApplication.current.processIdentifier - ) { - SelectionActionDiagnostics.log( - "activated external app dismiss pid=\(application?.processIdentifier ?? -1)" - ) - handleSelectionDismissRequest(reason: "externalActivation", bypassingPanelGrace: true) - } else { - SelectionActionDiagnostics.log("activated current app ignored for selection dismiss") - } + selectionActions.handleActivatedApplication(application) } private func registerConfiguredHotkey() { @@ -759,507 +586,6 @@ final class AppCoordinator: NSObject, NSMenuDelegate { } } - private func configureSelectionActions() { - guard !isStopping, !isMigrationMaintenanceActive else { - selectionActionMonitor.stop() - return - } - let config = (try? configStore.load()) ?? AppConfig.defaultConfig() - handleSelectionActionEffects(selectionActionCoordinator.handle(.updateConfig(config.selectionActions))) - let isAccessibilityTrusted = accessibilityPermissionService.isTrusted - SelectionActionDiagnostics.log( - "configure enabled=\(config.selectionActions.isEnabled) accessibilityTrusted=\(isAccessibilityTrusted)" - ) - if config.selectionActions.isEnabled, isAccessibilityTrusted { - if !selectionActionMonitor.start() { - SelectionActionDiagnostics.log("selection action monitor installation unavailable") - } - } else { - selectionActionMonitor.stop() - } - } - - private func handleSelectionActionCandidate(at point: SelectionPoint) { - guard !isStopping, !isMigrationMaintenanceActive else { return } - guard let sourceApp = NSWorkspace.shared.frontmostApplication, - sourceApp.processIdentifier != NSRunningApplication.current.processIdentifier - else { - return - } - - let bundleID = sourceApp.bundleIdentifier ?? "pid-\(sourceApp.processIdentifier)" - let request = PendingSelectionRead( - sourceProcessIdentifier: sourceApp.processIdentifier, - location: point - ) - SelectionActionDiagnostics.logRateLimited("candidate sourceApp=\(bundleID)") - handleSelectionActionEffects(selectionActionCoordinator.handle( - .candidateSelection(sourceAppBundleID: bundleID, mouseLocation: point) - ), pendingSelectionRead: request) - } - - private func handleSelectionActionCopyTrigger(_ pendingUserCopyRead: PendingUserCopyRead) { - guard !isStopping, !isMigrationMaintenanceActive else { return } - - handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) - let taskID = UUID() - selectionReadTaskID = taskID - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionReadTaskID == taskID { - self.selectionReadTask = nil - self.selectionReadTaskID = nil - self.refreshMigrationImportEligibility() - } - } - let handoffOutcome = await selectionClipboardReader.finishUserCopyHandoff( - pendingUserCopyRead.clipboardHandoff - ) - guard !Task.isCancelled, - !isStopping, - !isMigrationMaintenanceActive, - selectionReadTaskID == taskID - else { - return - } - switch handoffOutcome { - case .unobservedSyntheticAction: - SelectionActionDiagnostics.log("copy trigger ignored unobserved synthetic action") - return - case .noActiveRead, .restorationRelinquished, .completedWithoutPasteboardMutation: - break - } - let result = await selectionUserCopyReader.readCopiedText( - sourceProcessIdentifier: pendingUserCopyRead.sourceProcessIdentifier, - after: pendingUserCopyRead.clipboardHandoff.boundaryChangeCount - ) - guard !Task.isCancelled, - !isStopping, - !isMigrationMaintenanceActive, - selectionReadTaskID == taskID, - selectionSourceValidator.isCurrent(pendingUserCopyRead.sourceProcessIdentifier) - else { - return - } - guard case .success(let text) = result, !text.isEmpty else { - SelectionActionDiagnostics.log("copy trigger empty clipboard") - return - } - - SelectionActionDiagnostics.log("copy trigger showPanel length=\(text.count)") - panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) - selectionActionMonitor.recordPanelShown() - currentSelectionText = text - currentTranslationText = "" - selectionPronunciationReturnState = .menu - selectionCopyFeedbackTask?.cancel() - selectionActionWindowController.showMenu(at: pendingUserCopyRead.location) - } - selectionReadTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - } - - private func handleSelectionActionEffects( - _ effects: [SelectionActionEffect], - pendingSelectionRead: PendingSelectionRead? = nil - ) { - guard !isStopping else { return } - for effect in effects { - switch effect { - case .scheduleRead(let delayMilliseconds): - guard let pendingSelectionRead else { continue } - SelectionActionDiagnostics.logRateLimited("effect scheduleRead delayMs=\(delayMilliseconds)") - selectionReadTask?.cancel() - let taskID = UUID() - selectionReadTaskID = taskID - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionReadTaskID == taskID { - self.selectionReadTask = nil - self.selectionReadTaskID = nil - self.refreshMigrationImportEligibility() - } - } - do { - try await Task.sleep(nanoseconds: UInt64(delayMilliseconds) * 1_000_000) - } catch { - return - } - guard !Task.isCancelled, !isStopping else { return } - await self.completeScheduledSelectionRead(pendingSelectionRead) - } - selectionReadTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - case .cancelRead: - SelectionActionDiagnostics.log("effect cancelRead") - selectionReadTask?.cancel() - selectionReadTask = nil - selectionReadTaskID = nil - refreshMigrationImportEligibility() - case .hidePanel: - SelectionActionDiagnostics.log("effect hidePanel") - selectionActionWindowController.hidePanel() - refreshMigrationImportEligibility() - case .cancelWork: - SelectionActionDiagnostics.log("effect cancelWork") - selectionTranslationTask?.cancel() - selectionTranslationTask = nil - selectionTranslationTaskID = nil - selectionTTSTask?.cancel() - selectionTTSTask = nil - selectionTTSTaskID = nil - selectionCopyFeedbackTask?.cancel() - speechPlaybackService.stop() - isSelectionSpeechPlaying = false - refreshMigrationImportEligibility() - case .showPanel(let text, let location): - SelectionActionDiagnostics.log("effect showPanel length=\(text.count)") - panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) - selectionActionMonitor.recordPanelShown() - currentSelectionText = text - currentTranslationText = "" - selectionPronunciationReturnState = .menu - selectionCopyFeedbackTask?.cancel() - selectionActionWindowController.showMenu(at: location) - case .showUnsupportedNotice: - SelectionActionDiagnostics.log("effect showUnsupportedNotice") - panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) - selectionActionMonitor.recordPanelShown() - showSelectionUnsupportedNotice() - } - } - } - - private func handleSelectionDismissRequest(reason: String = "unknown", bypassingPanelGrace: Bool = false) { - guard !isStopping else { return } - guard panelDismissalPolicy.shouldDismiss( - at: Date().timeIntervalSinceReferenceDate, - bypassingGrace: bypassingPanelGrace - ) else { - SelectionActionDiagnostics.log("panel dismiss suppressed during visibility grace reason=\(reason)") - return - } - - forceDismissSelectionActions(reason: reason) - } - - private func forceDismissSelectionActions(reason: String = "force") { - guard !isStopping else { return } - SelectionActionDiagnostics.log("force dismiss selection actions reason=\(reason)") - handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) - } - - private func diagnosticSummary(for result: SelectedTextReadResult) -> String { - switch result { - case .success(let text): - "success length=\(text.count)" - case .permissionDenied: - "permissionDenied" - case .emptySelection: - "emptySelection" - case .unsupported: - "unsupported" - case .missingFocusedElement: - "missingFocusedElement" - case .failed(let message): - "failed \(message)" - } - } - - private func completeScheduledSelectionRead(_ pendingSelectionRead: PendingSelectionRead) async { - guard !isStopping, !isMigrationMaintenanceActive else { return } - let config = (try? configStore.load()) ?? AppConfig.defaultConfig() - let configuredForceSelectionMode = config.selectionActions.forceSelectionMode - let forceSelectionMode: SelectionForceSelectionMode = if configuredForceSelectionMode == .menuCopyOnly, - config.selectionActions.allowsSimulatedCopyFallback { - .menuCopyThenShortcut - } else { - configuredForceSelectionMode - } - let readSelectedTextForAutomaticSelection = { - await self.readSelectedTextForAutomaticSelection( - pendingSelectionRead, - forceSelectionMode: forceSelectionMode - ) - } - let result = await readSelectedTextForAutomaticSelection() - guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } - SelectionActionDiagnostics.logRateLimited("read result \(diagnosticSummary(for: result))") - handleSelectionActionEffects(selectionActionCoordinator.handle(.readCompleted(result))) - } - - private func readSelectedTextForAutomaticSelection( - _ pendingSelectionRead: PendingSelectionRead, - forceSelectionMode: SelectionForceSelectionMode - ) async -> SelectedTextReadResult { - await selectionReadPipeline.readSelectedText( - sourceProcessIdentifier: pendingSelectionRead.sourceProcessIdentifier, - mouseLocation: pendingSelectionRead.location, - forceSelectionMode: forceSelectionMode - ) - } - - private func showSelectionUnsupportedNotice() { - let point = SelectionPoint(x: NSEvent.mouseLocation.x, y: NSEvent.mouseLocation.y) - currentSelectionText = "" - currentTranslationText = "" - selectionActionWindowController.showNotice(L10n.text("selection.action.unsupportedMessage"), at: point) - } - - private func translateCurrentSelection() { - guard !isStopping, !isMigrationMaintenanceActive else { return } - let sourceText = currentSelectionText - guard !sourceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return - } - - selectionTranslationTask?.cancel() - let taskID = UUID() - selectionTranslationTaskID = taskID - selectionActionWindowController.showTranslating() - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionTranslationTaskID == taskID { - self.selectionTranslationTask = nil - self.selectionTranslationTaskID = nil - self.refreshMigrationImportEligibility() - } - } - do { - let config = try self.configStore.load() - let providerPreset = config.resolvedProviderPreset - let providerID = config.providerID - let apiKeyStore = self.apiKeyStore - let provider = OpenAIProvider( - apiKeyProvider: { - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) - } - return apiKey - }, - endpoint: providerPreset.endpoint - ) - let targetLanguageName = config.selectionActions.translationLanguage.resolvedPromptTargetName( - interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier - ) - let systemPrompt = config.selectionActions.effectiveTranslationPrompt( - targetLanguageName: targetLanguageName - ) - let service = CachedSelectionTranslationService( - service: SelectionTranslationService(provider: provider), - cache: selectionTranslationCache - ) - let translated = try await service.translate( - sourceText: sourceText, - targetLanguageName: targetLanguageName, - systemPrompt: systemPrompt, - model: config.model, - providerID: providerID, - timeoutSeconds: config.timeoutSeconds - ) - guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } - try? self.historyStore.append(HistoryItem( - source: .selection, - inputText: sourceText, - outputText: translated, - modeName: nil, - targetLanguageName: targetLanguageName, - model: config.model, - metadata: ["providerID": providerID] - )) - await MainActor.run { - guard !self.isStopping else { return } - self.currentTranslationText = translated - self.selectionActionWindowController.showTranslation(translated) - } - } catch is CancellationError { - } catch { - if !self.isStopping, !self.isMigrationMaintenanceActive { - self.selectionActionWindowController.showTranslationError( - L10n.text("selection.action.translationFailed") - ) - } - } - } - selectionTranslationTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - } - - private func pronounceCurrentSelection() { - pronounceSelectionText(currentSelectionText, returnState: .menu) - } - - private func pronounceOriginalFromTranslation() { - pronounceSelectionText( - currentSelectionText, - returnState: .translationResult, - loadingFeedback: .loadingOriginalPronunciation, - playingFeedback: .playingOriginalPronunciation - ) - } - - private func pronounceCurrentTranslation() { - pronounceSelectionText( - currentTranslationText, - returnState: .translationResult, - loadingFeedback: .loadingTranslationPronunciation, - playingFeedback: .playingTranslationPronunciation - ) - } - - private func pronounceSelectionText( - _ text: String, - returnState: SelectionPronunciationReturnState, - loadingFeedback: SelectionActionFeedback? = nil, - playingFeedback: SelectionActionFeedback? = nil - ) { - guard !isStopping, !isMigrationMaintenanceActive else { return } - let sourceText = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !sourceText.isEmpty else { return } - - selectionPronunciationReturnState = returnState - selectionTTSTask?.cancel() - let taskID = UUID() - selectionTTSTaskID = taskID - if returnState == .translationResult, !currentTranslationText.isEmpty { - selectionActionWindowController.showTranslation(currentTranslationText, feedback: loadingFeedback) - } else { - selectionActionWindowController.showPreparingPronunciation() - } - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionTTSTaskID == taskID { - self.selectionTTSTask = nil - self.selectionTTSTaskID = nil - self.refreshMigrationImportEligibility() - } - } - do { - let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) - } - return apiKey - }) - let config = (try? self.configStore.load()) ?? AppConfig.defaultConfig() - let audioData = try await provider.speechAudio(OpenAITTSRequest( - input: sourceText, - voice: config.selectionActions.pronunciationVoice.rawValue, - speed: config.selectionActions.pronunciationSpeed, - timeoutSeconds: config.timeoutSeconds - )) - guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } - await MainActor.run { - guard !self.isStopping else { return } - do { - try self.speechPlaybackService.play(audioData: audioData) - self.isSelectionSpeechPlaying = true - self.refreshMigrationImportEligibility() - if returnState == .menu { - self.selectionActionWindowController.showPlayingPronunciation() - } else if !self.currentTranslationText.isEmpty { - self.selectionActionWindowController.showTranslation( - self.currentTranslationText, - feedback: playingFeedback - ) - } - } catch { - self.isSelectionSpeechPlaying = false - self.showSelectionPronunciationError(returnState: returnState) - } - } - } catch is CancellationError { - } catch { - if !self.isStopping, !self.isMigrationMaintenanceActive { - self.showSelectionPronunciationError(returnState: returnState) - } - } - } - selectionTTSTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - } - - private func restoreSelectionPronunciationReturnState() { - guard !isStopping, !isMigrationMaintenanceActive else { return } - switch selectionPronunciationReturnState { - case .menu: - selectionActionWindowController.restoreMenu() - case .translationResult: - if currentTranslationText.isEmpty { - selectionActionWindowController.restoreMenu() - } else { - selectionActionWindowController.showTranslation(currentTranslationText) - } - } - } - - private func showSelectionPronunciationError(returnState: SelectionPronunciationReturnState) { - guard !isStopping else { return } - let message = L10n.text("selection.action.pronunciationFailed") - switch returnState { - case .menu: - selectionActionWindowController.showPronunciationError(message) - case .translationResult: - if currentTranslationText.isEmpty { - selectionActionWindowController.showPronunciationError(message) - } else { - selectionActionWindowController.showTranslation(currentTranslationText, errorMessage: message) - } - } - } - - private func copyCurrentTranslation() { - guard !isStopping, !isMigrationMaintenanceActive, !currentTranslationText.isEmpty else { - return - } - - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(currentTranslationText, forType: .string) - selectionCopyFeedbackTask?.cancel() - selectionActionWindowController.showTranslation(currentTranslationText, feedback: .copiedTranslation) - let taskID = UUID() - selectionCopyFeedbackTaskID = taskID - let task = Task { [weak self, copiedText = currentTranslationText] in - defer { - if let self { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionCopyFeedbackTaskID == taskID { - self.selectionCopyFeedbackTask = nil - self.selectionCopyFeedbackTaskID = nil - } - } - } - try? await Task.sleep(for: .milliseconds(900)) - await MainActor.run { - guard let self, - !self.isStopping, - self.currentTranslationText == copiedText - else { - return - } - self.selectionActionWindowController.showTranslation(self.currentTranslationText) - } - } - selectionCopyFeedbackTask = task - selectionTaskRegistry.register(task, id: taskID) - } - private func setHotkeyRecording(_ isRecording: Bool) { guard !isStopping, isRecordingHotkey != isRecording else { return @@ -1413,19 +739,19 @@ final class AppCoordinator: NSObject, NSMenuDelegate { @objc func openPopover() { guard !isStopping, !isMigrationMaintenanceActive else { return } - forceDismissSelectionActions(reason: "openPopover") + selectionActions.forceDismissSelectionActions(reason: "openPopover") windowController.show(fallbackApplication: lastTargetApplication) } @objc func openSettings() { guard !isStopping else { return } - forceDismissSelectionActions(reason: "openSettings") + selectionActions.forceDismissSelectionActions(reason: "openSettings") showSettings(section: .general) } @objc func openAbout() { guard !isStopping, !isMigrationMaintenanceActive else { return } - forceDismissSelectionActions(reason: "openAbout") + selectionActions.forceDismissSelectionActions(reason: "openAbout") aboutController.show() } diff --git a/Sources/InkletApp/SelectionActionsController.swift b/Sources/InkletApp/SelectionActionsController.swift new file mode 100644 index 0000000..6bebf8e --- /dev/null +++ b/Sources/InkletApp/SelectionActionsController.swift @@ -0,0 +1,766 @@ +import AppKit +import InkletCore + +@MainActor +private enum SelectionPronunciationReturnState: Equatable { + case menu + case translationResult +} + +private struct PendingSelectionRead { + let sourceProcessIdentifier: pid_t + let location: SelectionPoint +} + +private struct PendingUserCopyRead { + let sourceProcessIdentifier: pid_t + let location: SelectionPoint + let clipboardHandoff: SelectionClipboardUserCopyHandoff +} + +@MainActor +final class SelectionActionsController { + var onWorkStateChange: (() -> Void)? + var onInteractionEnded: (() -> Void)? + + private let configStore: UserDefaultsConfigStore + private let apiKeyStore: LocalAPIKeyStore + private let historyStore: JSONLHistoryStore + private let accessibilityPermissionService: AccessibilityPermissionService + private let selectionActionMonitor: SelectionActionMonitor + private let selectionActionWindowController: SelectionActionWindowController + private let selectionSourceValidator: SelectionSourceValidator + private let selectionClipboardReader: SelectionClipboardReader + private let selectionUserCopyReader: SelectionUserCopyReader + private let selectionReadPipeline: SelectionReadPipeline + private let selectionTranslationCache: JSONSelectionTranslationCache + private let speechPlaybackService: SpeechPlaybackService + private var selectionActionCoordinator: SelectionActionCoordinator + private let selectionTaskRegistry = SelectionTaskRegistry() + private var stoppedSelectionTasks: SelectionShutdownTaskSnapshot? + private var selectionReadTask: Task? + private var selectionTranslationTask: Task? + private var selectionTTSTask: Task? + private var selectionCopyFeedbackTask: Task? + private var selectionReadTaskID: UUID? + private var selectionTranslationTaskID: UUID? + private var selectionTTSTaskID: UUID? + private var selectionCopyFeedbackTaskID: UUID? + private var isSelectionSpeechPlaying = false + private var isMigrationMaintenanceActive = false + private var isStopping = false + private var currentSelectionText = "" + private var currentTranslationText = "" + private var selectionPronunciationReturnState = SelectionPronunciationReturnState.menu + private var panelDismissalPolicy = SelectionPanelDismissalPolicy() + + init( + configStore: UserDefaultsConfigStore, + apiKeyStore: LocalAPIKeyStore, + historyStore: JSONLHistoryStore, + accessibilityPermissionService: AccessibilityPermissionService, + translationCache: JSONSelectionTranslationCache + ) { + let selectionSourceValidator = SelectionSourceValidator() + let sourceValidator: SelectionReadPipeline.SourceValidator = { + selectionSourceValidator.isCurrent($0) + } + let selectedTextReader = SelectedTextReader() + let selectionClipboardReader = SelectionClipboardReader( + sourceProcessValidator: sourceValidator + ) + let selectionUserCopyReader = SelectionUserCopyReader( + sourceProcessValidator: sourceValidator + ) + + self.configStore = configStore + self.apiKeyStore = apiKeyStore + self.historyStore = historyStore + self.accessibilityPermissionService = accessibilityPermissionService + self.selectionActionMonitor = SelectionActionMonitor() + self.selectionActionWindowController = SelectionActionWindowController() + self.selectionSourceValidator = selectionSourceValidator + self.selectionClipboardReader = selectionClipboardReader + self.selectionUserCopyReader = selectionUserCopyReader + self.selectionReadPipeline = SelectionReadPipeline( + sourceValidator: sourceValidator, + accessibilityReader: { sourceProcessIdentifier, mouseLocation in + selectedTextReader.readSelectedText( + sourceProcessIdentifier: sourceProcessIdentifier, + mouseLocation: mouseLocation + ) + }, + clipboardReader: { sourceProcessIdentifier, forceSelectionMode in + await selectionClipboardReader.readSelectedText( + sourceProcessIdentifier: sourceProcessIdentifier, + forceSelectionMode: forceSelectionMode + ) + } + ) + self.selectionTranslationCache = translationCache + self.speechPlaybackService = SpeechPlaybackService() + self.selectionActionCoordinator = SelectionActionCoordinator( + config: ((try? configStore.load()) ?? AppConfig.defaultConfig()).selectionActions + ) + + self.selectionActionMonitor.onCandidateSelection = { [weak self] point in + guard let self, !self.isStopping else { return } + self.handleSelectionActionCandidate(at: point) + } + self.selectionActionMonitor.onCopyTrigger = { [weak self] trigger in + guard let self, + !self.isStopping, + !isMigrationMaintenanceActive, + trigger.sourceProcessIdentifier > 0, + trigger.sourceProcessIdentifier != NSRunningApplication.current.processIdentifier, + selectionSourceValidator.isCurrent(trigger.sourceProcessIdentifier) + else { + return + } + let clipboardHandoff = selectionClipboardReader.beginUserCopyHandoff() + let pendingUserCopyRead = PendingUserCopyRead( + sourceProcessIdentifier: trigger.sourceProcessIdentifier, + location: trigger.point, + clipboardHandoff: clipboardHandoff + ) + self.handleSelectionActionCopyTrigger(pendingUserCopyRead) + } + self.selectionActionMonitor.onDismiss = { [weak self] reason in + guard let self, !self.isStopping else { return } + self.handleSelectionDismissRequest( + reason: String(describing: reason), + bypassingPanelGrace: reason.bypassesPanelGrace + ) + } + self.selectionActionMonitor.onInteractionStateChange = { [weak self] isActive in + guard let self, !self.isStopping, !isActive else { return } + self.onInteractionEnded?() + } + self.selectionActionWindowController.onTranslate = { [weak self] in + Task { @MainActor in + self?.translateCurrentSelection() + } + } + self.selectionActionWindowController.onPronounce = { [weak self] in + Task { @MainActor in + self?.pronounceCurrentSelection() + } + } + self.selectionActionWindowController.onPronounceOriginal = { [weak self] in + Task { @MainActor in + self?.pronounceOriginalFromTranslation() + } + } + self.selectionActionWindowController.onPronounceTranslation = { [weak self] in + Task { @MainActor in + self?.pronounceCurrentTranslation() + } + } + self.selectionActionWindowController.onCopyTranslation = { [weak self] in + self?.copyCurrentTranslation() + } + self.selectionActionWindowController.onRetryTranslation = { [weak self] in + Task { @MainActor in + self?.translateCurrentSelection() + } + } + self.selectionActionWindowController.onDismiss = { [weak self] in + guard let self, !self.isStopping else { return } + self.forceDismissSelectionActions(reason: "selectionPanelEscape") + } + self.speechPlaybackService.onFinish = { [weak self] in + guard let self, !self.isStopping else { return } + self.isSelectionSpeechPlaying = false + self.restoreSelectionPronunciationReturnState() + self.notifyWorkStateChange() + } + } + + /// True when no selection read, translation, or pronunciation is running. + var isIdle: Bool { + selectionReadTask == nil + && selectionTranslationTask == nil + && selectionTTSTask == nil + && !isSelectionSpeechPlaying + } + + var isPanelVisible: Bool { + selectionActionWindowController.isPanelVisible + } + + var isInteractionActive: Bool { + selectionActionMonitor.isInteractionActive + } + + /// Stops new selection work and cancels running tasks without suspending. + func beginStopping() { + isStopping = true + selectionActionMonitor.stop() + let selectionTasks = selectionTaskRegistry.snapshotAndClear() + stoppedSelectionTasks = selectionTasks + selectionReadTask = nil + selectionTranslationTask = nil + selectionTTSTask = nil + selectionCopyFeedbackTask = nil + selectionReadTaskID = nil + selectionTranslationTaskID = nil + selectionTTSTaskID = nil + selectionCopyFeedbackTaskID = nil + selectionTasks.cancel() + speechPlaybackService.stop() + isSelectionSpeechPlaying = false + } + + /// Joins the clipboard read and the tasks captured by `beginStopping()`. + func finishStopping() async { + await selectionClipboardReader.cancelActiveRead() + await stoppedSelectionTasks?.waitForCompletion() + stoppedSelectionTasks = nil + } + + func enterMigrationMaintenance() { + isMigrationMaintenanceActive = true + selectionActionMonitor.stop() + selectionReadTask?.cancel() + selectionReadTask = nil + selectionReadTaskID = nil + selectionTranslationTask?.cancel() + selectionTranslationTask = nil + selectionTranslationTaskID = nil + selectionTTSTask?.cancel() + selectionTTSTask = nil + selectionTTSTaskID = nil + selectionCopyFeedbackTask?.cancel() + selectionCopyFeedbackTask = nil + selectionCopyFeedbackTaskID = nil + forceDismissSelectionActions(reason: "migrationMaintenance") + selectionActionWindowController.hidePanel() + speechPlaybackService.stop() + isSelectionSpeechPlaying = false + } + + func leaveMigrationMaintenance() { + isMigrationMaintenanceActive = false + configureSelectionActions() + } + + func handleActivatedApplication(_ application: NSRunningApplication?) { + guard !isStopping else { return } + if SelectionActivationDismissalPolicy.shouldDismiss( + activatedProcessIdentifier: application?.processIdentifier, + currentProcessIdentifier: NSRunningApplication.current.processIdentifier + ) { + SelectionActionDiagnostics.log( + "activated external app dismiss pid=\(application?.processIdentifier ?? -1)" + ) + handleSelectionDismissRequest(reason: "externalActivation", bypassingPanelGrace: true) + } else { + SelectionActionDiagnostics.log("activated current app ignored for selection dismiss") + } + } + + private func notifyWorkStateChange() { + onWorkStateChange?() + } + + func configureSelectionActions() { + guard !isStopping, !isMigrationMaintenanceActive else { + selectionActionMonitor.stop() + return + } + let config = (try? configStore.load()) ?? AppConfig.defaultConfig() + handleSelectionActionEffects(selectionActionCoordinator.handle(.updateConfig(config.selectionActions))) + let isAccessibilityTrusted = accessibilityPermissionService.isTrusted + SelectionActionDiagnostics.log( + "configure enabled=\(config.selectionActions.isEnabled) accessibilityTrusted=\(isAccessibilityTrusted)" + ) + if config.selectionActions.isEnabled, isAccessibilityTrusted { + if !selectionActionMonitor.start() { + SelectionActionDiagnostics.log("selection action monitor installation unavailable") + } + } else { + selectionActionMonitor.stop() + } + } + + private func handleSelectionActionCandidate(at point: SelectionPoint) { + guard !isStopping, !isMigrationMaintenanceActive else { return } + guard let sourceApp = NSWorkspace.shared.frontmostApplication, + sourceApp.processIdentifier != NSRunningApplication.current.processIdentifier + else { + return + } + + let bundleID = sourceApp.bundleIdentifier ?? "pid-\(sourceApp.processIdentifier)" + let request = PendingSelectionRead( + sourceProcessIdentifier: sourceApp.processIdentifier, + location: point + ) + SelectionActionDiagnostics.logRateLimited("candidate sourceApp=\(bundleID)") + handleSelectionActionEffects(selectionActionCoordinator.handle( + .candidateSelection(sourceAppBundleID: bundleID, mouseLocation: point) + ), pendingSelectionRead: request) + } + + private func handleSelectionActionCopyTrigger(_ pendingUserCopyRead: PendingUserCopyRead) { + guard !isStopping, !isMigrationMaintenanceActive else { return } + + handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) + let taskID = UUID() + selectionReadTaskID = taskID + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionReadTaskID == taskID { + self.selectionReadTask = nil + self.selectionReadTaskID = nil + self.notifyWorkStateChange() + } + } + let handoffOutcome = await selectionClipboardReader.finishUserCopyHandoff( + pendingUserCopyRead.clipboardHandoff + ) + guard !Task.isCancelled, + !isStopping, + !isMigrationMaintenanceActive, + selectionReadTaskID == taskID + else { + return + } + switch handoffOutcome { + case .unobservedSyntheticAction: + SelectionActionDiagnostics.log("copy trigger ignored unobserved synthetic action") + return + case .noActiveRead, .restorationRelinquished, .completedWithoutPasteboardMutation: + break + } + let result = await selectionUserCopyReader.readCopiedText( + sourceProcessIdentifier: pendingUserCopyRead.sourceProcessIdentifier, + after: pendingUserCopyRead.clipboardHandoff.boundaryChangeCount + ) + guard !Task.isCancelled, + !isStopping, + !isMigrationMaintenanceActive, + selectionReadTaskID == taskID, + selectionSourceValidator.isCurrent(pendingUserCopyRead.sourceProcessIdentifier) + else { + return + } + guard case .success(let text) = result, !text.isEmpty else { + SelectionActionDiagnostics.log("copy trigger empty clipboard") + return + } + + SelectionActionDiagnostics.log("copy trigger showPanel length=\(text.count)") + panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) + selectionActionMonitor.recordPanelShown() + currentSelectionText = text + currentTranslationText = "" + selectionPronunciationReturnState = .menu + selectionCopyFeedbackTask?.cancel() + selectionActionWindowController.showMenu(at: pendingUserCopyRead.location) + } + selectionReadTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + } + + private func handleSelectionActionEffects( + _ effects: [SelectionActionEffect], + pendingSelectionRead: PendingSelectionRead? = nil + ) { + guard !isStopping else { return } + for effect in effects { + switch effect { + case .scheduleRead(let delayMilliseconds): + guard let pendingSelectionRead else { continue } + SelectionActionDiagnostics.logRateLimited("effect scheduleRead delayMs=\(delayMilliseconds)") + selectionReadTask?.cancel() + let taskID = UUID() + selectionReadTaskID = taskID + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionReadTaskID == taskID { + self.selectionReadTask = nil + self.selectionReadTaskID = nil + self.notifyWorkStateChange() + } + } + do { + try await Task.sleep(nanoseconds: UInt64(delayMilliseconds) * 1_000_000) + } catch { + return + } + guard !Task.isCancelled, !isStopping else { return } + await self.completeScheduledSelectionRead(pendingSelectionRead) + } + selectionReadTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + case .cancelRead: + SelectionActionDiagnostics.log("effect cancelRead") + selectionReadTask?.cancel() + selectionReadTask = nil + selectionReadTaskID = nil + notifyWorkStateChange() + case .hidePanel: + SelectionActionDiagnostics.log("effect hidePanel") + selectionActionWindowController.hidePanel() + notifyWorkStateChange() + case .cancelWork: + SelectionActionDiagnostics.log("effect cancelWork") + selectionTranslationTask?.cancel() + selectionTranslationTask = nil + selectionTranslationTaskID = nil + selectionTTSTask?.cancel() + selectionTTSTask = nil + selectionTTSTaskID = nil + selectionCopyFeedbackTask?.cancel() + speechPlaybackService.stop() + isSelectionSpeechPlaying = false + notifyWorkStateChange() + case .showPanel(let text, let location): + SelectionActionDiagnostics.log("effect showPanel length=\(text.count)") + panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) + selectionActionMonitor.recordPanelShown() + currentSelectionText = text + currentTranslationText = "" + selectionPronunciationReturnState = .menu + selectionCopyFeedbackTask?.cancel() + selectionActionWindowController.showMenu(at: location) + case .showUnsupportedNotice: + SelectionActionDiagnostics.log("effect showUnsupportedNotice") + panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) + selectionActionMonitor.recordPanelShown() + showSelectionUnsupportedNotice() + } + } + } + + private func handleSelectionDismissRequest(reason: String = "unknown", bypassingPanelGrace: Bool = false) { + guard !isStopping else { return } + guard panelDismissalPolicy.shouldDismiss( + at: Date().timeIntervalSinceReferenceDate, + bypassingGrace: bypassingPanelGrace + ) else { + SelectionActionDiagnostics.log("panel dismiss suppressed during visibility grace reason=\(reason)") + return + } + + forceDismissSelectionActions(reason: reason) + } + + func forceDismissSelectionActions(reason: String = "force") { + guard !isStopping else { return } + SelectionActionDiagnostics.log("force dismiss selection actions reason=\(reason)") + handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) + } + + private func diagnosticSummary(for result: SelectedTextReadResult) -> String { + switch result { + case .success(let text): + "success length=\(text.count)" + case .permissionDenied: + "permissionDenied" + case .emptySelection: + "emptySelection" + case .unsupported: + "unsupported" + case .missingFocusedElement: + "missingFocusedElement" + case .failed(let message): + "failed \(message)" + } + } + + private func completeScheduledSelectionRead(_ pendingSelectionRead: PendingSelectionRead) async { + guard !isStopping, !isMigrationMaintenanceActive else { return } + let config = (try? configStore.load()) ?? AppConfig.defaultConfig() + let configuredForceSelectionMode = config.selectionActions.forceSelectionMode + let forceSelectionMode: SelectionForceSelectionMode = if configuredForceSelectionMode == .menuCopyOnly, + config.selectionActions.allowsSimulatedCopyFallback { + .menuCopyThenShortcut + } else { + configuredForceSelectionMode + } + let readSelectedTextForAutomaticSelection = { + await self.readSelectedTextForAutomaticSelection( + pendingSelectionRead, + forceSelectionMode: forceSelectionMode + ) + } + let result = await readSelectedTextForAutomaticSelection() + guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } + SelectionActionDiagnostics.logRateLimited("read result \(diagnosticSummary(for: result))") + handleSelectionActionEffects(selectionActionCoordinator.handle(.readCompleted(result))) + } + + private func readSelectedTextForAutomaticSelection( + _ pendingSelectionRead: PendingSelectionRead, + forceSelectionMode: SelectionForceSelectionMode + ) async -> SelectedTextReadResult { + await selectionReadPipeline.readSelectedText( + sourceProcessIdentifier: pendingSelectionRead.sourceProcessIdentifier, + mouseLocation: pendingSelectionRead.location, + forceSelectionMode: forceSelectionMode + ) + } + + private func showSelectionUnsupportedNotice() { + let point = SelectionPoint(x: NSEvent.mouseLocation.x, y: NSEvent.mouseLocation.y) + currentSelectionText = "" + currentTranslationText = "" + selectionActionWindowController.showNotice(L10n.text("selection.action.unsupportedMessage"), at: point) + } + + private func translateCurrentSelection() { + guard !isStopping, !isMigrationMaintenanceActive else { return } + let sourceText = currentSelectionText + guard !sourceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + selectionTranslationTask?.cancel() + let taskID = UUID() + selectionTranslationTaskID = taskID + selectionActionWindowController.showTranslating() + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionTranslationTaskID == taskID { + self.selectionTranslationTask = nil + self.selectionTranslationTaskID = nil + self.notifyWorkStateChange() + } + } + do { + let config = try self.configStore.load() + let providerPreset = config.resolvedProviderPreset + let providerID = config.providerID + let apiKeyStore = self.apiKeyStore + let provider = OpenAIProvider( + apiKeyProvider: { + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) + } + return apiKey + }, + endpoint: providerPreset.endpoint + ) + let targetLanguageName = config.selectionActions.translationLanguage.resolvedPromptTargetName( + interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier + ) + let systemPrompt = config.selectionActions.effectiveTranslationPrompt( + targetLanguageName: targetLanguageName + ) + let service = CachedSelectionTranslationService( + service: SelectionTranslationService(provider: provider), + cache: selectionTranslationCache + ) + let translated = try await service.translate( + sourceText: sourceText, + targetLanguageName: targetLanguageName, + systemPrompt: systemPrompt, + model: config.model, + providerID: providerID, + timeoutSeconds: config.timeoutSeconds + ) + guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } + try? self.historyStore.append(HistoryItem( + source: .selection, + inputText: sourceText, + outputText: translated, + modeName: nil, + targetLanguageName: targetLanguageName, + model: config.model, + metadata: ["providerID": providerID] + )) + await MainActor.run { + guard !self.isStopping else { return } + self.currentTranslationText = translated + self.selectionActionWindowController.showTranslation(translated) + } + } catch is CancellationError { + } catch { + if !self.isStopping, !self.isMigrationMaintenanceActive { + self.selectionActionWindowController.showTranslationError( + L10n.text("selection.action.translationFailed") + ) + } + } + } + selectionTranslationTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + } + + private func pronounceCurrentSelection() { + pronounceSelectionText(currentSelectionText, returnState: .menu) + } + + private func pronounceOriginalFromTranslation() { + pronounceSelectionText( + currentSelectionText, + returnState: .translationResult, + loadingFeedback: .loadingOriginalPronunciation, + playingFeedback: .playingOriginalPronunciation + ) + } + + private func pronounceCurrentTranslation() { + pronounceSelectionText( + currentTranslationText, + returnState: .translationResult, + loadingFeedback: .loadingTranslationPronunciation, + playingFeedback: .playingTranslationPronunciation + ) + } + + private func pronounceSelectionText( + _ text: String, + returnState: SelectionPronunciationReturnState, + loadingFeedback: SelectionActionFeedback? = nil, + playingFeedback: SelectionActionFeedback? = nil + ) { + guard !isStopping, !isMigrationMaintenanceActive else { return } + let sourceText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceText.isEmpty else { return } + + selectionPronunciationReturnState = returnState + selectionTTSTask?.cancel() + let taskID = UUID() + selectionTTSTaskID = taskID + if returnState == .translationResult, !currentTranslationText.isEmpty { + selectionActionWindowController.showTranslation(currentTranslationText, feedback: loadingFeedback) + } else { + selectionActionWindowController.showPreparingPronunciation() + } + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionTTSTaskID == taskID { + self.selectionTTSTask = nil + self.selectionTTSTaskID = nil + self.notifyWorkStateChange() + } + } + do { + let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) + } + return apiKey + }) + let config = (try? self.configStore.load()) ?? AppConfig.defaultConfig() + let audioData = try await provider.speechAudio(OpenAITTSRequest( + input: sourceText, + voice: config.selectionActions.pronunciationVoice.rawValue, + speed: config.selectionActions.pronunciationSpeed, + timeoutSeconds: config.timeoutSeconds + )) + guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } + await MainActor.run { + guard !self.isStopping else { return } + do { + try self.speechPlaybackService.play(audioData: audioData) + self.isSelectionSpeechPlaying = true + self.notifyWorkStateChange() + if returnState == .menu { + self.selectionActionWindowController.showPlayingPronunciation() + } else if !self.currentTranslationText.isEmpty { + self.selectionActionWindowController.showTranslation( + self.currentTranslationText, + feedback: playingFeedback + ) + } + } catch { + self.isSelectionSpeechPlaying = false + self.showSelectionPronunciationError(returnState: returnState) + } + } + } catch is CancellationError { + } catch { + if !self.isStopping, !self.isMigrationMaintenanceActive { + self.showSelectionPronunciationError(returnState: returnState) + } + } + } + selectionTTSTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + } + + private func restoreSelectionPronunciationReturnState() { + guard !isStopping, !isMigrationMaintenanceActive else { return } + switch selectionPronunciationReturnState { + case .menu: + selectionActionWindowController.restoreMenu() + case .translationResult: + if currentTranslationText.isEmpty { + selectionActionWindowController.restoreMenu() + } else { + selectionActionWindowController.showTranslation(currentTranslationText) + } + } + } + + private func showSelectionPronunciationError(returnState: SelectionPronunciationReturnState) { + guard !isStopping else { return } + let message = L10n.text("selection.action.pronunciationFailed") + switch returnState { + case .menu: + selectionActionWindowController.showPronunciationError(message) + case .translationResult: + if currentTranslationText.isEmpty { + selectionActionWindowController.showPronunciationError(message) + } else { + selectionActionWindowController.showTranslation(currentTranslationText, errorMessage: message) + } + } + } + + private func copyCurrentTranslation() { + guard !isStopping, !isMigrationMaintenanceActive, !currentTranslationText.isEmpty else { + return + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(currentTranslationText, forType: .string) + selectionCopyFeedbackTask?.cancel() + selectionActionWindowController.showTranslation(currentTranslationText, feedback: .copiedTranslation) + let taskID = UUID() + selectionCopyFeedbackTaskID = taskID + let task = Task { [weak self, copiedText = currentTranslationText] in + defer { + if let self { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionCopyFeedbackTaskID == taskID { + self.selectionCopyFeedbackTask = nil + self.selectionCopyFeedbackTaskID = nil + } + } + } + try? await Task.sleep(for: .milliseconds(900)) + await MainActor.run { + guard let self, + !self.isStopping, + self.currentTranslationText == copiedText + else { + return + } + self.selectionActionWindowController.showTranslation(self.currentTranslationText) + } + } + selectionCopyFeedbackTask = task + selectionTaskRegistry.register(task, id: taskID) + } +} diff --git a/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift b/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift index ef5073a..9afcac1 100644 --- a/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift +++ b/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift @@ -264,8 +264,8 @@ final class AppCoordinatorSourceTests: XCTestCase { XCTAssertTrue(automaticGateBlock.contains("migrationWorkflowsAreIdle: migrationWorkflowsAreIdle")) XCTAssertTrue(automaticGateBlock.contains("isSelectingMigrationSource: migrationPresentationModel.phase == .selecting")) XCTAssertTrue(automaticGateBlock.contains("hasModalWindow: NSApp.modalWindow != nil")) - XCTAssertTrue(automaticGateBlock.contains("isSelectionPanelVisible: selectionActionWindowController.isPanelVisible")) - XCTAssertTrue(automaticGateBlock.contains("isSelectionInteractionActive: selectionActionMonitor.isInteractionActive")) + XCTAssertTrue(automaticGateBlock.contains("isSelectionPanelVisible: selectionActions.isPanelVisible")) + XCTAssertTrue(automaticGateBlock.contains("isSelectionInteractionActive: selectionActions.isInteractionActive")) XCTAssertTrue(automaticGateBlock.contains("isMenuTracking: !trackedMenus.isEmpty")) XCTAssertTrue(automaticGateBlock.contains("isUpdateAlertPresenting: updateCheckAlertPresenter.isPresentingAlert")) XCTAssertTrue(automaticGateBlock.contains("isStopping: isStopping")) @@ -349,7 +349,7 @@ final class AppCoordinatorSourceTests: XCTestCase { } func testSelectionCallbacksRegisterWorkSynchronouslyAndIdleSchedulesUpdateGate() throws { - let source = try appCoordinatorSource() + let source = try selectionActionsControllerSource() let candidateStart = try XCTUnwrap(source.range( of: "self.selectionActionMonitor.onCandidateSelection =" )) @@ -381,7 +381,22 @@ final class AppCoordinatorSourceTests: XCTestCase { XCTAssertTrue(interactionBlock.contains("!self.isStopping")) XCTAssertTrue(interactionBlock.contains("!isActive")) XCTAssertEqual( - interactionBlock.components(separatedBy: "automaticUpdatePresentationGate.schedule()").count - 1, + interactionBlock.components(separatedBy: "onInteractionEnded?()").count - 1, + 1 + ) + + let coordinatorSource = try appCoordinatorSource() + let interactionEnded = try sourceBlock( + startingAt: "self.selectionActions.onInteractionEnded =", + endingBefore: "private func makeUpdateCheckCoordinator()", + in: coordinatorSource + ) + try assertTokensAppearInOrder( + ["!self.isStopping", "automaticUpdatePresentationGate.schedule()"], + in: String(interactionEnded) + ) + XCTAssertEqual( + interactionEnded.components(separatedBy: "automaticUpdatePresentationGate.schedule()").count - 1, 1 ) @@ -424,13 +439,20 @@ final class AppCoordinatorSourceTests: XCTestCase { let panelBegin = try XCTUnwrap(migrationBlock.range(of: "await panel.begin()")) XCTAssertLessThan(beginSelecting.lowerBound, finalRefresh.lowerBound) XCTAssertLessThan(finalRefresh.lowerBound, panelBegin.lowerBound) + let workStateCallback = try sourceBlock( + startingAt: "self.selectionActions.onWorkStateChange =", + endingBefore: "self.selectionActions.onInteractionEnded =", + in: source + ) + XCTAssertTrue(workStateCallback.contains("self?.refreshMigrationImportEligibility()")) - let effectsStart = try XCTUnwrap(source.range(of: "private func handleSelectionActionEffects")) - let dismissStart = try XCTUnwrap(source.range( + let selectionSource = try selectionActionsControllerSource() + let effectsStart = try XCTUnwrap(selectionSource.range(of: "private func handleSelectionActionEffects")) + let dismissStart = try XCTUnwrap(selectionSource.range( of: "\n private func handleSelectionDismissRequest", - range: effectsStart.upperBound.. String { + try appSource(named: "AppCoordinator.swift") + } + + private func selectionActionsControllerSource() throws -> String { + try appSource(named: "SelectionActionsController.swift") + } + + private func appSource(named fileName: String) throws -> String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/AppCoordinator.swift") + let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent(fileName) return try String(contentsOf: sourceURL, encoding: .utf8) } } diff --git a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift index 0d475ee..b14341f 100644 --- a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift +++ b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift @@ -154,6 +154,7 @@ final class LegacyMigrationAppSourceTests: XCTestCase { func testCoordinatorFreezesAllMutationSurfacesAndRechecksBusyState() throws { let source = try appSource(named: "AppCoordinator.swift") + let selectionSource = try appSource(named: "SelectionActionsController.swift") let importFlow = try sourceScope( startingAt: "private func requestAssistedMigrationImport() async", endingBefore: "private func enterMigrationMaintenance() async", @@ -167,51 +168,54 @@ final class LegacyMigrationAppSourceTests: XCTestCase { let copyTrigger = try sourceScope( startingAt: "private func handleSelectionActionCopyTrigger", endingBefore: "private func handleSelectionActionEffects", - in: source + in: selectionSource ) let scheduledRead = try sourceScope( startingAt: "private func completeScheduledSelectionRead", endingBefore: "private func readSelectedTextForAutomaticSelection", - in: source + in: selectionSource ) let translation = try sourceScope( startingAt: "private func translateCurrentSelection", endingBefore: "private func pronounceCurrentSelection", - in: source + in: selectionSource ) let pronunciation = try sourceScope( startingAt: "private func pronounceSelectionText", endingBefore: "private func restoreSelectionPronunciationReturnState", - in: source + in: selectionSource ) let pronunciationRestore = try sourceScope( startingAt: "private func restoreSelectionPronunciationReturnState", endingBefore: "private func showSelectionPronunciationError", - in: source + in: selectionSource ) XCTAssertTrue(source.contains("private var isMigrationMaintenanceActive")) XCTAssertTrue(source.contains("windowController.isBusy")) XCTAssertTrue(source.contains("settingsController.isMigrationWorkflowIdle")) XCTAssertTrue(source.contains("settingsController.onMigrationWorkflowIdleChange")) - XCTAssertTrue(source.contains("selectionReadTask == nil")) - XCTAssertTrue(source.contains("selectionTranslationTask == nil")) - XCTAssertTrue(source.contains("selectionTTSTask == nil")) - XCTAssertTrue(source.contains("!isSelectionSpeechPlaying")) + XCTAssertTrue(source.contains("selectionActions.isIdle")) + XCTAssertTrue(selectionSource.contains("selectionReadTask == nil")) + XCTAssertTrue(selectionSource.contains("selectionTranslationTask == nil")) + XCTAssertTrue(selectionSource.contains("selectionTTSTask == nil")) + XCTAssertTrue(selectionSource.contains("!isSelectionSpeechPlaying")) XCTAssertTrue(source.contains("guard canRequestAssistedMigration")) XCTAssertGreaterThanOrEqual( source.components(separatedBy: "guard canRequestAssistedMigration").count - 1, 2 ) XCTAssertTrue(source.contains("hotkeyManager.unregister()")) - XCTAssertTrue(source.contains("selectionActionMonitor.stop()")) + XCTAssertTrue(source.contains("selectionActions.enterMigrationMaintenance()")) + XCTAssertTrue(selectionSource.contains("selectionActionMonitor.stop()")) XCTAssertTrue(source.contains("await settingsController.waitForMigrationMaintenanceQuiescence()")) XCTAssertTrue(source.contains("await windowController.cancelForMigrationMaintenance()")) - XCTAssertTrue(source.contains("selectionReadTask?.cancel()")) - XCTAssertTrue(source.contains("selectionTranslationTask?.cancel()")) - XCTAssertTrue(source.contains("selectionTTSTask?.cancel()")) - XCTAssertTrue(source.contains("speechPlaybackService.stop()")) + XCTAssertTrue(selectionSource.contains("selectionReadTask?.cancel()")) + XCTAssertTrue(selectionSource.contains("selectionTranslationTask?.cancel()")) + XCTAssertTrue(selectionSource.contains("selectionTTSTask?.cancel()")) + XCTAssertTrue(selectionSource.contains("speechPlaybackService.stop()")) XCTAssertTrue(source.contains("guard !isStopping, !isMigrationMaintenanceActive")) + XCTAssertTrue(selectionSource.contains("guard !isStopping, !isMigrationMaintenanceActive")) XCTAssertTrue(copyTrigger.contains("let task = Task")) XCTAssertTrue(copyTrigger.contains("selectionTaskRegistry.register(task, id: taskID)")) try assertTokensAppearInOrder( @@ -225,6 +229,7 @@ final class LegacyMigrationAppSourceTests: XCTestCase { try assertTokensAppearInOrder( [ "isMigrationMaintenanceActive = true", + "selectionActions.enterMigrationMaintenance()", "await windowController.cancelForMigrationMaintenance()", "await settingsController.waitForMigrationMaintenanceQuiescence()", ], @@ -332,7 +337,7 @@ final class LegacyMigrationAppSourceTests: XCTestCase { let source = try appSource(named: "AppCoordinator.swift") let delegate = try sourceScope( startingAt: "final class AppDelegate", - endingBefore: "private enum SelectionPronunciationReturnState", + endingBefore: "enum UpdateCheckMenuConfiguration", in: source ) diff --git a/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift b/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift index 1735318..2c3f27e 100644 --- a/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift +++ b/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift @@ -231,7 +231,7 @@ final class SelectionActionMonitorSourceTests: XCTestCase { func testSelectionEventDiagnosticsAreRateLimitedWithoutReadingContent() throws { let monitorSource = try monitorSource() let diagnosticsSource = try appSource(named: "SelectionActionDiagnostics.swift") - let coordinatorSource = try appSource(named: "AppCoordinator.swift") + let coordinatorSource = try appSource(named: "SelectionActionsController.swift") XCTAssertTrue(diagnosticsSource.contains("SelectionActionDiagnosticRateLimiter")) XCTAssertTrue(diagnosticsSource.contains("static func logRateLimited")) From 86d856a94f603108df4f717e38173fc287caa143 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:04:24 +0000 Subject: [PATCH 4/6] Split SettingsView into view model, view and controls SettingsViewModel and the notification names move to SettingsViewModel.swift, and the AppKit-backed prompt mode table, prompt editor and hotkey recorder move to SettingsControls.swift. SettingsView.swift keeps the SwiftUI layout. The unused private PromptMode.builtInIDs set is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DzisdHuvFVePscX66cn3Lw --- Sources/InkletApp/SettingsControls.swift | 664 +++++++++ Sources/InkletApp/SettingsView.swift | 1265 ----------------- Sources/InkletApp/SettingsViewModel.swift | 598 ++++++++ .../LegacyMigrationAppSourceTests.swift | 4 +- .../SettingsViewSourceTests.swift | 13 +- 5 files changed, 1273 insertions(+), 1271 deletions(-) create mode 100644 Sources/InkletApp/SettingsControls.swift create mode 100644 Sources/InkletApp/SettingsViewModel.swift diff --git a/Sources/InkletApp/SettingsControls.swift b/Sources/InkletApp/SettingsControls.swift new file mode 100644 index 0000000..b543f64 --- /dev/null +++ b/Sources/InkletApp/SettingsControls.swift @@ -0,0 +1,664 @@ +import AppKit +import Carbon +import SwiftUI +import InkletCore + +struct PromptModeTableView: NSViewRepresentable { + let modes: [PromptMode] + @Binding var selectedModeID: String + let canDelete: Bool + let onMove: (Int, Int) -> Void + let onToggleVisibility: (String) -> Void + let onDelete: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + modes: modes, + selectedModeID: $selectedModeID, + canDelete: canDelete, + onMove: onMove, + onToggleVisibility: onToggleVisibility, + onDelete: onDelete + ) + } + + func makeNSView(context: Context) -> NSScrollView { + let tableView = NSTableView() + let column = NSTableColumn(identifier: Coordinator.columnIdentifier) + column.resizingMask = .autoresizingMask + tableView.addTableColumn(column) + tableView.headerView = nil + tableView.rowHeight = 38 + tableView.intercellSpacing = NSSize(width: 0, height: 2) + tableView.backgroundColor = .clear + tableView.selectionHighlightStyle = .none + tableView.usesAlternatingRowBackgroundColors = false + tableView.delegate = context.coordinator + tableView.dataSource = context.coordinator + tableView.registerForDraggedTypes([Coordinator.dragPasteboardType]) + + let scrollView = PromptModeTableScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.horizontalScrollElasticity = .none + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.documentView = tableView + context.coordinator.tableView = tableView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.modes = modes + context.coordinator.selectedModeID = $selectedModeID + context.coordinator.canDelete = canDelete + context.coordinator.onMove = onMove + context.coordinator.onToggleVisibility = onToggleVisibility + context.coordinator.onDelete = onDelete + + guard let tableView = scrollView.documentView as? NSTableView else { + return + } + + PromptModeTableScrollView.syncTableWidth(tableView, to: scrollView.contentView.bounds.width) + + tableView.reloadData() + context.coordinator.syncSelection() + } + + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { + static let columnIdentifier = NSUserInterfaceItemIdentifier("PromptModeColumn") + static let rowIdentifier = NSUserInterfaceItemIdentifier("PromptModeRow") + static let dragPasteboardType = NSPasteboard.PasteboardType("com.inklet.prompt-mode") + + var modes: [PromptMode] + var selectedModeID: Binding + var canDelete: Bool + var onMove: (Int, Int) -> Void + var onToggleVisibility: (String) -> Void + var onDelete: (String) -> Void + weak var tableView: NSTableView? + private var isSyncingSelection = false + + init( + modes: [PromptMode], + selectedModeID: Binding, + canDelete: Bool, + onMove: @escaping (Int, Int) -> Void, + onToggleVisibility: @escaping (String) -> Void, + onDelete: @escaping (String) -> Void + ) { + self.modes = modes + self.selectedModeID = selectedModeID + self.canDelete = canDelete + self.onMove = onMove + self.onToggleVisibility = onToggleVisibility + self.onDelete = onDelete + } + + func numberOfRows(in tableView: NSTableView) -> Int { + modes.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard modes.indices.contains(row) else { + return nil + } + + let cell = (tableView.makeView(withIdentifier: Self.rowIdentifier, owner: self) as? PromptModeTableCellView) + ?? PromptModeTableCellView(identifier: Self.rowIdentifier) + let mode = modes[row] + cell.configure( + mode: mode, + isSelected: mode.id == selectedModeID.wrappedValue, + canDelete: canDelete, + target: self + ) + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + guard !isSyncingSelection, + let tableView, + modes.indices.contains(tableView.selectedRow) + else { + return + } + + selectedModeID.wrappedValue = modes[tableView.selectedRow].id + } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { + guard modes.indices.contains(row) else { + return nil + } + + let item = NSPasteboardItem() + item.setString(modes[row].id, forType: Self.dragPasteboardType) + return item + } + + func tableView( + _ tableView: NSTableView, + validateDrop info: NSDraggingInfo, + proposedRow row: Int, + proposedDropOperation dropOperation: NSTableView.DropOperation + ) -> NSDragOperation { + tableView.setDropRow(row, dropOperation: .above) + return .move + } + + func tableView( + _ tableView: NSTableView, + acceptDrop info: NSDraggingInfo, + row: Int, + dropOperation: NSTableView.DropOperation + ) -> Bool { + guard let draggedModeID = info.draggingPasteboard.string(forType: Self.dragPasteboardType), + let sourceIndex = modes.firstIndex(where: { $0.id == draggedModeID }) + else { + return false + } + + let destination = max(0, min(row, modes.count)) + guard sourceIndex != destination, sourceIndex + 1 != destination else { + return false + } + + onMove(sourceIndex, destination) + selectedModeID.wrappedValue = draggedModeID + return true + } + + @MainActor + func syncSelection() { + guard let tableView else { + return + } + + isSyncingSelection = true + defer { isSyncingSelection = false } + + if let selectedIndex = modes.firstIndex(where: { $0.id == selectedModeID.wrappedValue }) { + tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false) + } else { + tableView.deselectAll(nil) + } + } + + @MainActor + @objc func toggleVisibility(_ sender: NSButton) { + guard let modeID = sender.identifier?.rawValue else { + return + } + onToggleVisibility(modeID) + } + + @MainActor + @objc func deleteMode(_ sender: NSButton) { + guard let modeID = sender.identifier?.rawValue else { + return + } + onDelete(modeID) + } + } +} + +private final class PromptModeTableScrollView: NSScrollView { + override func layout() { + super.layout() + guard let tableView = documentView as? NSTableView else { + return + } + Self.syncTableWidth(tableView, to: contentView.bounds.width) + } + + static func syncTableWidth(_ tableView: NSTableView, to width: CGFloat) { + let width = max(width, 1) + guard tableView.tableColumns.first?.width != width else { + return + } + + tableView.tableColumns.first?.width = width + var frame = tableView.frame + frame.size.width = width + tableView.frame = frame + } +} + +private final class PromptModeTableCellView: NSTableCellView { + private static let rowWidth: CGFloat = 190 + private let rowContainer = NSView() + private let selectionBackground = NSView() + private let dragHandle = NSImageView() + private let titleField = NSTextField(labelWithString: "") + private let visibilityButton = NSButton() + private let deleteButton = NSButton() + private var trackingArea: NSTrackingArea? + private var isHovered = false + private var isCellSelected = false + private var isModeVisible = true + private var canDeleteMode = true + + init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + buildView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + mode: PromptMode, + isSelected: Bool, + canDelete: Bool, + target: PromptModeTableView.Coordinator + ) { + let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) + isCellSelected = isSelected + isModeVisible = mode.isVisible + canDeleteMode = canDelete + titleField.stringValue = mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName + titleField.font = .systemFont(ofSize: 13, weight: isSelected ? .semibold : .regular) + visibilityButton.image = NSImage(systemSymbolName: mode.isVisible ? "eye" : "eye.slash", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfiguration) + visibilityButton.toolTip = mode.isVisible ? L10n.text("settings.mode.visible") : L10n.text("settings.mode.hidden") + visibilityButton.identifier = NSUserInterfaceItemIdentifier(mode.id) + visibilityButton.target = target + visibilityButton.action = #selector(PromptModeTableView.Coordinator.toggleVisibility(_:)) + + deleteButton.identifier = NSUserInterfaceItemIdentifier(mode.id) + deleteButton.target = target + deleteButton.action = #selector(PromptModeTableView.Coordinator.deleteMode(_:)) + deleteButton.isEnabled = canDelete + deleteButton.toolTip = L10n.text("settings.mode.delete") + applyAppearance() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self + ) + trackingArea = area + addTrackingArea(area) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + applyAppearance() + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + applyAppearance() + } + + private func buildView() { + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + rowContainer.translatesAutoresizingMaskIntoConstraints = false + addSubview(rowContainer) + + selectionBackground.wantsLayer = true + selectionBackground.layer?.cornerRadius = 12 + selectionBackground.layer?.cornerCurve = .continuous + selectionBackground.layer?.backgroundColor = NSColor.clear.cgColor + selectionBackground.translatesAutoresizingMaskIntoConstraints = false + rowContainer.addSubview(selectionBackground) + + let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) + dragHandle.image = NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfiguration) + dragHandle.contentTintColor = NSColor(white: 1, alpha: 0.18) + dragHandle.setContentHuggingPriority(.required, for: .horizontal) + dragHandle.setContentCompressionResistancePriority(.required, for: .horizontal) + dragHandle.toolTip = L10n.text("settings.mode.dragToSort") + dragHandle.translatesAutoresizingMaskIntoConstraints = false + + titleField.lineBreakMode = .byTruncatingTail + titleField.textColor = NSColor(white: 0.74, alpha: 1) + titleField.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + titleField.translatesAutoresizingMaskIntoConstraints = false + + configureIconButton(visibilityButton) + configureIconButton(deleteButton) + deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfiguration) + deleteButton.contentTintColor = NSColor(white: 1, alpha: 0.32) + + rowContainer.addSubview(dragHandle) + rowContainer.addSubview(titleField) + rowContainer.addSubview(visibilityButton) + rowContainer.addSubview(deleteButton) + + NSLayoutConstraint.activate([ + rowContainer.widthAnchor.constraint(equalToConstant: Self.rowWidth), + rowContainer.centerXAnchor.constraint(equalTo: centerXAnchor, constant: -16), + rowContainer.topAnchor.constraint(equalTo: topAnchor, constant: 3), + rowContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3), + selectionBackground.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor), + selectionBackground.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor), + selectionBackground.topAnchor.constraint(equalTo: rowContainer.topAnchor), + selectionBackground.bottomAnchor.constraint(equalTo: rowContainer.bottomAnchor), + dragHandle.widthAnchor.constraint(equalToConstant: 18), + visibilityButton.widthAnchor.constraint(equalToConstant: 24), + visibilityButton.heightAnchor.constraint(equalToConstant: 24), + deleteButton.widthAnchor.constraint(equalToConstant: 24), + deleteButton.heightAnchor.constraint(equalToConstant: 24), + dragHandle.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: 14), + dragHandle.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), + titleField.leadingAnchor.constraint(equalTo: dragHandle.trailingAnchor, constant: 10), + titleField.trailingAnchor.constraint(equalTo: visibilityButton.leadingAnchor, constant: -8), + titleField.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), + visibilityButton.trailingAnchor.constraint(equalTo: deleteButton.leadingAnchor, constant: -4), + visibilityButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), + deleteButton.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor, constant: -10), + deleteButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor) + ]) + } + + private func applyAppearance() { + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + selectionBackground.layer?.backgroundColor = isCellSelected + ? NSColor.controlAccentColor.withAlphaComponent(isDark ? 0.20 : 0.14).cgColor + : (isHovered ? NSColor.labelColor.withAlphaComponent(isDark ? 0.04 : 0.045).cgColor : NSColor.clear.cgColor) + titleField.textColor = isCellSelected + ? NSColor.controlAccentColor + : (isHovered ? .labelColor : .secondaryLabelColor) + dragHandle.contentTintColor = isHovered || isCellSelected + ? .secondaryLabelColor + : .tertiaryLabelColor + visibilityButton.contentTintColor = isModeVisible + ? .secondaryLabelColor + : .tertiaryLabelColor + visibilityButton.alphaValue = isHovered || isCellSelected ? 1 : 0.78 + deleteButton.contentTintColor = canDeleteMode + ? .secondaryLabelColor + : .tertiaryLabelColor + deleteButton.alphaValue = isHovered || isCellSelected ? 1 : 0.64 + } + + private func configureIconButton(_ button: NSButton) { + button.translatesAutoresizingMaskIntoConstraints = false + button.isBordered = false + button.bezelStyle = .regularSquare + button.setButtonType(.momentaryChange) + button.imagePosition = .imageOnly + button.focusRingType = .none + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + } +} + +struct SettingsPromptTextView: NSViewRepresentable { + @Binding var text: String + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.contentInsets = NSEdgeInsetsZero + scrollView.automaticallyAdjustsContentInsets = false + scrollView.horizontalScrollElasticity = .none + + let textView = NSTextView() + textView.string = text + textView.delegate = context.coordinator + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.allowsUndo = true + textView.usesFindBar = false + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.font = .systemFont(ofSize: 13) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: scrollView.contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + + scrollView.documentView = textView + context.coordinator.textView = textView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { + return + } + + context.coordinator.text = $text + context.coordinator.textView = textView + textView.font = .systemFont(ofSize: 13) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + + if !textView.hasMarkedText(), textView.string != text { + textView.string = text + } + } + + final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { + var text: Binding + weak var textView: NSTextView? + + init(text: Binding) { + self.text = text + super.init() + } + + @MainActor + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { + return + } + + text.wrappedValue = textView.string + } + } +} + +struct HotkeyRecorderField: NSViewRepresentable { + @Binding var hotkey: String + + func makeCoordinator() -> Coordinator { + Coordinator(hotkey: $hotkey) + } + + func makeNSView(context: Context) -> RecorderView { + let view = RecorderView() + view.onChange = { context.coordinator.hotkey.wrappedValue = $0 } + view.hotkey = hotkey + return view + } + + func updateNSView(_ nsView: RecorderView, context: Context) { + nsView.hotkey = hotkey + nsView.onChange = { context.coordinator.hotkey.wrappedValue = $0 } + nsView.updateDisplay() + } + + final class Coordinator { + var hotkey: Binding + + init(hotkey: Binding) { + self.hotkey = hotkey + } + } + + final class RecorderView: NSView { + var hotkey = "" + var onChange: ((String) -> Void)? + private var isRecording = false + private let label = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 8 + layer?.borderWidth = 1 + label.translatesAutoresizingMaskIntoConstraints = false + label.alignment = .center + label.font = .monospacedSystemFont(ofSize: 13, weight: .medium) + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + label.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + updateDisplay() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { true } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateDisplay() + } + + override func mouseDown(with event: NSEvent) { + isRecording = true + publishRecordingState() + window?.makeFirstResponder(self) + updateDisplay() + } + + override func resignFirstResponder() -> Bool { + isRecording = false + publishRecordingState() + updateDisplay() + return super.resignFirstResponder() + } + + override func keyDown(with event: NSEvent) { + guard isRecording else { + super.keyDown(with: event) + return + } + + if event.keyCode == UInt16(kVK_Escape) { + isRecording = false + publishRecordingState() + window?.makeFirstResponder(nil) + updateDisplay() + return + } + + guard let recordedHotkey = recordedHotkey(from: event) else { + NSSound.beep() + return + } + + hotkey = recordedHotkey.displayString + onChange?(hotkey) + isRecording = false + publishRecordingState() + window?.makeFirstResponder(nil) + updateDisplay() + } + + override func flagsChanged(with event: NSEvent) { + if isRecording { + updateDisplay(pressedModifiers: modifierDisplayString(from: event.modifierFlags)) + } else { + super.flagsChanged(with: event) + } + } + + func updateDisplay(pressedModifiers: String = "") { + let backgroundColor = isRecording ? NSColor.controlAccentColor.withAlphaComponent(0.16) : NSColor.controlBackgroundColor + layer?.backgroundColor = resolvedCGColor(backgroundColor) + layer?.borderColor = resolvedCGColor(isRecording ? .controlAccentColor : .separatorColor) + label.textColor = isRecording ? .controlAccentColor : .labelColor + if isRecording { + label.stringValue = pressedModifiers.isEmpty + ? L10n.text("settings.hotkey.recording") + : "\(pressedModifiers)\(L10n.text("settings.hotkey.pressKey"))" + } else { + label.stringValue = hotkey.isEmpty ? L10n.text("settings.hotkey.record") : hotkey + } + } + + private func resolvedCGColor(_ color: NSColor) -> CGColor { + var resolvedColor = color.cgColor + effectiveAppearance.performAsCurrentDrawingAppearance { + resolvedColor = color.cgColor + } + return resolvedColor + } + + private func recordedHotkey(from event: NSEvent) -> Hotkey? { + var modifiers: Hotkey.Modifier = [] + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command) { modifiers.insert(.command) } + if flags.contains(.option) { modifiers.insert(.option) } + if flags.contains(.control) { modifiers.insert(.control) } + if flags.contains(.shift) { modifiers.insert(.shift) } + + guard !modifiers.isEmpty, + modifiers != [.shift], + Hotkey.displayName(for: UInt32(event.keyCode)) != nil + else { + return nil + } + + return Hotkey(keyCode: UInt32(event.keyCode), modifiers: modifiers) + } + + private func modifierDisplayString(from flags: NSEvent.ModifierFlags) -> String { + var modifiers: Hotkey.Modifier = [] + let filteredFlags = flags.intersection(.deviceIndependentFlagsMask) + if filteredFlags.contains(.command) { modifiers.insert(.command) } + if filteredFlags.contains(.option) { modifiers.insert(.option) } + if filteredFlags.contains(.control) { modifiers.insert(.control) } + if filteredFlags.contains(.shift) { modifiers.insert(.shift) } + return modifiers.displayString + } + + private func publishRecordingState() { + NotificationCenter.default.post( + name: .hotkeyRecordingDidChange, + object: nil, + userInfo: ["isRecording": isRecording] + ) + } + } +} diff --git a/Sources/InkletApp/SettingsView.swift b/Sources/InkletApp/SettingsView.swift index e421c43..12652dc 100644 --- a/Sources/InkletApp/SettingsView.swift +++ b/Sources/InkletApp/SettingsView.swift @@ -1,612 +1,7 @@ import AppKit -import Carbon -import Combine import SwiftUI import InkletCore -private enum PronunciationPreviewState: Equatable { - case loading(SelectionPronunciationVoice) - case playing(SelectionPronunciationVoice) - - func matches(_ voice: SelectionPronunciationVoice) -> Bool { - switch self { - case .loading(let activeVoice), .playing(let activeVoice): - activeVoice == voice - } - } -} - -@MainActor -final class SettingsViewModel: ObservableObject { - @Published var config: AppConfig - @Published var message: String - @Published var providerAPIKey: String - @Published var selectedPromptModeID: String - @Published var interfaceLanguage: InterfaceLanguage - @Published var cachedProviderModels: [String: [String]] - @Published var isRefreshingModelCatalog: Bool - @Published var isEditingCustomModel: Bool - @Published var microphoneOptions: [MicrophoneDeviceOption] - @Published fileprivate var pronunciationPreviewState: PronunciationPreviewState? - @Published var permissionRefreshID: UUID - @Published var historyItems: [HistoryItem] - @Published var historyFilter: HistorySource? - @Published private(set) var isMigrationMaintenanceActive = false - @Published private(set) var isMigrationWorkflowIdle = true - - private let configStore: UserDefaultsConfigStore - private let apiKeyStore: LocalAPIKeyStore - private let modelCatalogService: ModelCatalogService - private let historyStore: any HistoryStore - private let microphoneDeviceCatalog: MicrophoneDeviceCatalog - private let pronunciationPreviewPlaybackService: SpeechPlaybackService - private var autoSaveCancellable: AnyCancellable? - private var pronunciationPreviewTasks: [UUID: Task] = [:] - private var pronunciationPreviewTaskID: UUID? - private var modelCatalogRefreshTask: Task? - private var modelCatalogRefreshTaskID: UUID? - private var savedConfig: AppConfig - private var savedProviderAPIKey: String - private var savedInterfaceLanguage: InterfaceLanguage - - static let customModelMenuID = "__custom_model__" - - init( - configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), - apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), - modelCatalogService: ModelCatalogService = ModelCatalogService(), - historyStore: any HistoryStore = JSONLHistoryStore(), - microphoneDeviceCatalog: MicrophoneDeviceCatalog = MicrophoneDeviceCatalog() - ) { - var loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() - self.configStore = configStore - self.apiKeyStore = apiKeyStore - self.modelCatalogService = modelCatalogService - self.historyStore = historyStore - self.microphoneDeviceCatalog = microphoneDeviceCatalog - self.pronunciationPreviewPlaybackService = SpeechPlaybackService() - loadedConfig.providerID = LLMProviderPreset.openAI.id - self.config = loadedConfig - self.savedConfig = loadedConfig - self.message = "" - self.interfaceLanguage = InkletLanguageStore.selectedLanguage - self.savedInterfaceLanguage = InkletLanguageStore.selectedLanguage - let loadedProviderAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) ?? "" - self.providerAPIKey = loadedProviderAPIKey - self.savedProviderAPIKey = loadedProviderAPIKey - self.selectedPromptModeID = loadedConfig.promptModes.sorted { $0.sortOrder < $1.sortOrder }.first?.id - ?? PromptMode.translateToEnglishID - self.cachedProviderModels = Dictionary( - uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in - guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { - return nil - } - return (preset.id, modelIDs) - } - ) - self.isRefreshingModelCatalog = false - self.isEditingCustomModel = false - self.microphoneOptions = microphoneDeviceCatalog.options() - self.pronunciationPreviewState = nil - self.permissionRefreshID = UUID() - self.historyItems = (try? historyStore.load()).map { Array($0.reversed()) } ?? [] - self.historyFilter = nil - - self.pronunciationPreviewPlaybackService.onFinish = { [weak self] in - self?.pronunciationPreviewState = nil - self?.refreshMigrationWorkflowIdle() - } - installAutoSave() - } - - var selectedProvider: LLMProviderPreset { - LLMProviderPreset.openAI - } - - var filteredHistoryItems: [HistoryItem] { - guard let historyFilter else { - return historyItems - } - return historyItems.filter { $0.source == historyFilter } - } - - var selectedProviderModelOptions: [String] { - var seen = Set() - var options: [String] = [] - - for modelID in cachedProviderModels[LLMProviderPreset.openAI.id] ?? [] { - if seen.insert(modelID).inserted { - options.append(modelID) - } - } - - if seen.insert(selectedProvider.defaultModel).inserted { - options.insert(selectedProvider.defaultModel, at: 0) - } - - return options - } - - var selectedModelMenuValue: String { - if isEditingCustomModel { - return Self.customModelMenuID - } - - return selectedProviderModelOptions.contains(config.model) ? config.model : Self.customModelMenuID - } - - var shouldShowCustomModelField: Bool { - selectedModelMenuValue == Self.customModelMenuID - } - - var selectedModelIsDefault: Bool { - config.model.trimmingCharacters(in: .whitespacesAndNewlines) == selectedProvider.defaultModel - } - - var selectedPromptModeIndex: Int? { - config.promptModes.firstIndex { $0.id == selectedPromptModeID } - } - - var orderedPromptModes: [PromptMode] { - config.promptModes.sorted { lhs, rhs in - if lhs.sortOrder == rhs.sortOrder { - return lhs.name < rhs.name - } - return lhs.sortOrder < rhs.sortOrder - } - } - - var isAccessibilityTrusted: Bool { - AccessibilityPermissionService().isTrusted - } - - func refreshPermissions() { - permissionRefreshID = UUID() - } - - func reloadHistory() { - do { - historyItems = Array(try historyStore.load().reversed()) - } catch { - historyItems = [] - message = L10n.text("settings.history.error.loadFailed") - } - } - - func clearHistory() { - guard !isMigrationMaintenanceActive else { return } - do { - try historyStore.clear() - historyItems = [] - message = L10n.text("settings.history.cleared") - } catch { - message = L10n.text("settings.history.error.clearFailed") - } - } - - func refreshMicrophoneOptions() { - microphoneOptions = microphoneDeviceCatalog.options() - if let microphoneDeviceID = config.voiceInput.microphoneDeviceID, - !microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) { - config.voiceInput.microphoneDeviceID = nil - } - } - - var selectedMicrophoneMenuID: String { - get { - guard let microphoneDeviceID = config.voiceInput.microphoneDeviceID, - microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) - else { - return MicrophoneDeviceOption.systemDefaultID - } - return microphoneDeviceID - } - set { - config.voiceInput.microphoneDeviceID = newValue == MicrophoneDeviceOption.systemDefaultID ? nil : newValue - } - } - - func modelMenuTitle(for modelID: String) -> String { - if modelID == config.model, !selectedModelIsDefault { - return "\(modelID) *" - } - return modelID - } - - func selectModelMenuValue(_ value: String) { - if value == Self.customModelMenuID { - isEditingCustomModel = true - return - } - - isEditingCustomModel = false - config.model = value - save() - } - - func previewPronunciationVoice() { - guard !isMigrationMaintenanceActive else { return } - let voice = config.selectionActions.pronunciationVoice - let speed = config.selectionActions.pronunciationSpeed - if let pronunciationPreviewTaskID { - pronunciationPreviewTasks[pronunciationPreviewTaskID]?.cancel() - } - pronunciationPreviewPlaybackService.stop() - pronunciationPreviewState = .loading(voice) - let taskID = UUID() - pronunciationPreviewTaskID = taskID - - let task = Task { [weak self] in - defer { - self?.pronunciationPreviewTasks[taskID] = nil - if self?.pronunciationPreviewTaskID == taskID { - self?.pronunciationPreviewTaskID = nil - } - self?.refreshMigrationWorkflowIdle() - } - do { - guard let self else { return } - let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) - } - return apiKey - }) - let audioData = try await provider.speechAudio(OpenAITTSRequest( - input: SelectionPronunciationVoice.previewText( - interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier - ), - voice: voice.rawValue, - speed: speed, - timeoutSeconds: config.timeoutSeconds - )) - guard !Task.isCancelled, - pronunciationPreviewTaskID == taskID, - !isMigrationMaintenanceActive - else { - return - } - await MainActor.run { - do { - try self.pronunciationPreviewPlaybackService.play(audioData: audioData) - self.pronunciationPreviewState = .playing(voice) - } catch { - self.pronunciationPreviewState = nil - self.message = L10n.text("selection.action.pronunciationFailed") - } - } - } catch is CancellationError { - } catch { - guard !Task.isCancelled, self?.pronunciationPreviewTaskID == taskID else { return } - await MainActor.run { - self?.pronunciationPreviewState = nil - self?.message = L10n.text("selection.action.pronunciationFailed") - } - } - } - pronunciationPreviewTasks[taskID] = task - refreshMigrationWorkflowIdle() - } - - func resetSelectionTranslationPrompt() { - config.selectionActions.translationPrompt = SelectionActionsConfig.defaultTranslationPrompt - save() - } - - func refreshModelCatalogIfNeeded() async { - guard !isMigrationMaintenanceActive else { return } - if let modelCatalogRefreshTask { - await modelCatalogRefreshTask.value - return - } - - isRefreshingModelCatalog = true - let taskID = UUID() - modelCatalogRefreshTaskID = taskID - let modelCatalogService = self.modelCatalogService - let task = Task { - do { - try await modelCatalogService.refreshIfNeeded() - } catch { - // The model picker still works with saved/default/custom values when refresh fails. - } - } - modelCatalogRefreshTask = task - refreshMigrationWorkflowIdle() - await task.value - - guard modelCatalogRefreshTaskID == taskID else { return } - modelCatalogRefreshTask = nil - modelCatalogRefreshTaskID = nil - isRefreshingModelCatalog = false - refreshMigrationWorkflowIdle() - guard !isMigrationMaintenanceActive else { return } - cachedProviderModels = Dictionary( - uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in - guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { - return nil - } - return (preset.id, modelIDs) - } - ) - } - - func addPromptMode() { - let mode = PromptMode( - id: "custom-\(Int(Date().timeIntervalSince1970))", - name: L10n.text("settings.mode.newName"), - description: "", - systemPrompt: "", - shortcut: nil, - participatesInAuto: false, - autoRule: .none, - sortOrder: (config.promptModes.map(\.sortOrder).max() ?? 0) + 1, - isVisible: true - ) - config.promptModes.append(mode) - selectedPromptModeID = mode.id - normalizePromptModeSortOrder() - save() - } - - func deleteSelectedPromptMode() { - deletePromptMode(modeID: selectedPromptModeID) - } - - func deletePromptMode(modeID: String) { - guard config.promptModes.count > 1 else { - message = L10n.text("settings.error.promptModeRequired") - return - } - - config.promptModes.removeAll { $0.id == modeID } - normalizePromptModeSortOrder() - - if !config.promptModes.contains(where: \.isVisible), - let firstIndex = config.promptModes.firstIndex(where: { _ in true }) { - config.promptModes[firstIndex].isVisible = true - } - - if selectedPromptModeID == modeID { - selectedPromptModeID = config.promptModes.first?.id ?? PromptMode.translateToEnglishID - } - save() - } - - func movePromptModes(from source: IndexSet, to destination: Int) { - var orderedModes = orderedPromptModes - orderedModes.move(fromOffsets: source, toOffset: destination) - - for (sortOrder, mode) in orderedModes.enumerated() { - guard let configIndex = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { - continue - } - config.promptModes[configIndex].sortOrder = sortOrder - } - normalizePromptModeSortOrder() - save() - } - - func promptModeName(modeID: String) -> String { - guard let mode = config.promptModes.first(where: { $0.id == modeID }) else { - return L10n.text("settings.mode.untitled") - } - return mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName - } - - func togglePromptModeVisibility(modeID: String) { - guard let index = config.promptModes.firstIndex(where: { $0.id == modeID }) else { - return - } - - if config.promptModes[index].isVisible, - config.promptModes.filter(\.isVisible).count <= 1 { - message = L10n.text("settings.error.visibleModeRequired") - return - } - - config.promptModes[index].isVisible.toggle() - save() - } - - func promptModeVisibilityBinding(modeID: String) -> Binding { - Binding( - get: { [weak self] in - self?.config.promptModes.first(where: { $0.id == modeID })?.isVisible ?? false - }, - set: { [weak self] newValue in - guard let self, - let mode = self.config.promptModes.first(where: { $0.id == modeID }), - mode.isVisible != newValue - else { - return - } - self.togglePromptModeVisibility(modeID: modeID) - } - ) - } - - func canMovePromptMode(modeID: String, direction: Int) -> Bool { - guard let currentIndex = orderedPromptModes.firstIndex(where: { $0.id == modeID }) else { - return false - } - - return orderedPromptModes.indices.contains(currentIndex + direction) - } - - private func normalizePromptModeSortOrder() { - let orderedModes = orderedPromptModes - for (sortOrder, mode) in orderedModes.enumerated() { - guard let index = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { - continue - } - config.promptModes[index].sortOrder = sortOrder - } - config.promptModes.sort { lhs, rhs in - if lhs.sortOrder == rhs.sortOrder { - return lhs.name < rhs.name - } - return lhs.sortOrder < rhs.sortOrder - } - } - - @discardableResult - func save() -> Bool { - guard !isMigrationMaintenanceActive else { return false } - let trimmedKey = providerAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) - let shouldSaveConfig = config != savedConfig - let shouldSaveProviderAPIKey = providerAPIKey != savedProviderAPIKey - let shouldSaveInterfaceLanguage = interfaceLanguage != savedInterfaceLanguage - guard shouldSaveConfig || shouldSaveProviderAPIKey || shouldSaveInterfaceLanguage else { - return true - } - - do { - if shouldSaveConfig { - guard config.promptModes.contains(where: \.isVisible) else { - message = L10n.text("settings.error.visibleModeRequired") - return false - } - guard !config.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - message = L10n.text("settings.error.modelRequired") - return false - } - config.providerID = LLMProviderPreset.openAI.id - - _ = try Hotkey.parse(config.hotkey) - try configStore.save(config) - savedConfig = config - } - - if shouldSaveInterfaceLanguage { - InkletLanguageStore.selectedLanguage = interfaceLanguage - savedInterfaceLanguage = interfaceLanguage - } - - if shouldSaveProviderAPIKey { - if trimmedKey.isEmpty { - try apiKeyStore.deleteAPIKey(forProviderID: LLMProviderPreset.openAI.id) - } else { - try apiKeyStore.saveAPIKey(trimmedKey, forProviderID: LLMProviderPreset.openAI.id) - } - providerAPIKey = trimmedKey - savedProviderAPIKey = trimmedKey - } - message = L10n.text("settings.saved") - NotificationCenter.default.post(name: .appConfigDidSave, object: nil) - return true - } catch let error as HotkeyError { - message = error.userFacingMessage - return false - } catch { - message = L10n.format("settings.error.saveFailed", String(describing: error)) - return false - } - } - - private func installAutoSave() { - guard !isMigrationMaintenanceActive, autoSaveCancellable == nil else { - return - } - autoSaveCancellable = Publishers.CombineLatest3($config, $providerAPIKey, $interfaceLanguage) - .dropFirst() - .debounce(for: .milliseconds(450), scheduler: RunLoop.main) - .sink { [weak self] _, _, _ in - guard let self else { return } - self.save() - } - } - - func flushPendingEdits() -> Bool { - autoSaveCancellable?.cancel() - autoSaveCancellable = nil - let didSave = hasPendingEdits ? save() : true - installAutoSave() - return didSave - } - - private var hasPendingEdits: Bool { - config != savedConfig - || providerAPIKey != savedProviderAPIKey - || interfaceLanguage != savedInterfaceLanguage - } - - func setMigrationMaintenanceActive(_ isActive: Bool) { - guard isMigrationMaintenanceActive != isActive else { return } - isMigrationMaintenanceActive = isActive - if isActive { - autoSaveCancellable?.cancel() - autoSaveCancellable = nil - pronunciationPreviewTasks.values.forEach { $0.cancel() } - modelCatalogRefreshTask?.cancel() - pronunciationPreviewPlaybackService.stop() - pronunciationPreviewState = nil - refreshMigrationWorkflowIdle() - } else { - installAutoSave() - refreshMigrationWorkflowIdle() - } - } - - func waitForMigrationMaintenanceQuiescence() async { - let previewTasks = Array(pronunciationPreviewTasks.values) - for task in previewTasks { - await task.value - } - - let catalogTaskID = modelCatalogRefreshTaskID - await modelCatalogRefreshTask?.value - if modelCatalogRefreshTaskID == catalogTaskID { - modelCatalogRefreshTask = nil - modelCatalogRefreshTaskID = nil - isRefreshingModelCatalog = false - } - refreshMigrationWorkflowIdle() - } - - private func refreshMigrationWorkflowIdle() { - isMigrationWorkflowIdle = pronunciationPreviewState == nil - && pronunciationPreviewTasks.isEmpty - && modelCatalogRefreshTask == nil - && !isRefreshingModelCatalog - } - - func openAccessibilitySettings() { - guard let url = URL( - string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - ) else { - message = L10n.text("settings.error.openAccessibility") - return - } - - NotificationCenter.default.post( - name: .inkletDidOpenPermissionSettings, - object: nil, - userInfo: ["permission": PermissionSettingsDestination.accessibility.rawValue] - ) - NSWorkspace.shared.open(url) - } - -} - -extension Notification.Name { - static let appConfigDidSave = Notification.Name("InkletAppConfigDidSave") - static let hotkeyRecordingDidChange = Notification.Name("InkletHotkeyRecordingDidChange") - static let inkletDidOpenPermissionSettings = Notification.Name("InkletDidOpenPermissionSettings") - static let inkletAccessibilityDidBecomeTrusted = Notification.Name("InkletAccessibilityDidBecomeTrusted") - static let inkletDidCompleteOnboarding = Notification.Name("InkletDidCompleteOnboarding") -} - -private extension PromptMode { - static let builtInIDs: Set = [ - PromptMode.translateToEnglishID, - PromptMode.chineseSummaryID, - PromptMode.voiceCleanupID - ] -} - private extension SelectionTranslationLanguage { var localizedDisplayName: String { switch self { @@ -1980,663 +1375,3 @@ enum SettingsLayoutMetrics { return max(168, ceil(textWidth) + 22) } } - -private struct PromptModeTableView: NSViewRepresentable { - let modes: [PromptMode] - @Binding var selectedModeID: String - let canDelete: Bool - let onMove: (Int, Int) -> Void - let onToggleVisibility: (String) -> Void - let onDelete: (String) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator( - modes: modes, - selectedModeID: $selectedModeID, - canDelete: canDelete, - onMove: onMove, - onToggleVisibility: onToggleVisibility, - onDelete: onDelete - ) - } - - func makeNSView(context: Context) -> NSScrollView { - let tableView = NSTableView() - let column = NSTableColumn(identifier: Coordinator.columnIdentifier) - column.resizingMask = .autoresizingMask - tableView.addTableColumn(column) - tableView.headerView = nil - tableView.rowHeight = 38 - tableView.intercellSpacing = NSSize(width: 0, height: 2) - tableView.backgroundColor = .clear - tableView.selectionHighlightStyle = .none - tableView.usesAlternatingRowBackgroundColors = false - tableView.delegate = context.coordinator - tableView.dataSource = context.coordinator - tableView.registerForDraggedTypes([Coordinator.dragPasteboardType]) - - let scrollView = PromptModeTableScrollView() - scrollView.drawsBackground = false - scrollView.hasVerticalScroller = false - scrollView.hasHorizontalScroller = false - scrollView.horizontalScrollElasticity = .none - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.documentView = tableView - context.coordinator.tableView = tableView - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - context.coordinator.modes = modes - context.coordinator.selectedModeID = $selectedModeID - context.coordinator.canDelete = canDelete - context.coordinator.onMove = onMove - context.coordinator.onToggleVisibility = onToggleVisibility - context.coordinator.onDelete = onDelete - - guard let tableView = scrollView.documentView as? NSTableView else { - return - } - - PromptModeTableScrollView.syncTableWidth(tableView, to: scrollView.contentView.bounds.width) - - tableView.reloadData() - context.coordinator.syncSelection() - } - - final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { - static let columnIdentifier = NSUserInterfaceItemIdentifier("PromptModeColumn") - static let rowIdentifier = NSUserInterfaceItemIdentifier("PromptModeRow") - static let dragPasteboardType = NSPasteboard.PasteboardType("com.inklet.prompt-mode") - - var modes: [PromptMode] - var selectedModeID: Binding - var canDelete: Bool - var onMove: (Int, Int) -> Void - var onToggleVisibility: (String) -> Void - var onDelete: (String) -> Void - weak var tableView: NSTableView? - private var isSyncingSelection = false - - init( - modes: [PromptMode], - selectedModeID: Binding, - canDelete: Bool, - onMove: @escaping (Int, Int) -> Void, - onToggleVisibility: @escaping (String) -> Void, - onDelete: @escaping (String) -> Void - ) { - self.modes = modes - self.selectedModeID = selectedModeID - self.canDelete = canDelete - self.onMove = onMove - self.onToggleVisibility = onToggleVisibility - self.onDelete = onDelete - } - - func numberOfRows(in tableView: NSTableView) -> Int { - modes.count - } - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard modes.indices.contains(row) else { - return nil - } - - let cell = (tableView.makeView(withIdentifier: Self.rowIdentifier, owner: self) as? PromptModeTableCellView) - ?? PromptModeTableCellView(identifier: Self.rowIdentifier) - let mode = modes[row] - cell.configure( - mode: mode, - isSelected: mode.id == selectedModeID.wrappedValue, - canDelete: canDelete, - target: self - ) - return cell - } - - func tableViewSelectionDidChange(_ notification: Notification) { - guard !isSyncingSelection, - let tableView, - modes.indices.contains(tableView.selectedRow) - else { - return - } - - selectedModeID.wrappedValue = modes[tableView.selectedRow].id - } - - func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { - guard modes.indices.contains(row) else { - return nil - } - - let item = NSPasteboardItem() - item.setString(modes[row].id, forType: Self.dragPasteboardType) - return item - } - - func tableView( - _ tableView: NSTableView, - validateDrop info: NSDraggingInfo, - proposedRow row: Int, - proposedDropOperation dropOperation: NSTableView.DropOperation - ) -> NSDragOperation { - tableView.setDropRow(row, dropOperation: .above) - return .move - } - - func tableView( - _ tableView: NSTableView, - acceptDrop info: NSDraggingInfo, - row: Int, - dropOperation: NSTableView.DropOperation - ) -> Bool { - guard let draggedModeID = info.draggingPasteboard.string(forType: Self.dragPasteboardType), - let sourceIndex = modes.firstIndex(where: { $0.id == draggedModeID }) - else { - return false - } - - let destination = max(0, min(row, modes.count)) - guard sourceIndex != destination, sourceIndex + 1 != destination else { - return false - } - - onMove(sourceIndex, destination) - selectedModeID.wrappedValue = draggedModeID - return true - } - - @MainActor - func syncSelection() { - guard let tableView else { - return - } - - isSyncingSelection = true - defer { isSyncingSelection = false } - - if let selectedIndex = modes.firstIndex(where: { $0.id == selectedModeID.wrappedValue }) { - tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false) - } else { - tableView.deselectAll(nil) - } - } - - @MainActor - @objc func toggleVisibility(_ sender: NSButton) { - guard let modeID = sender.identifier?.rawValue else { - return - } - onToggleVisibility(modeID) - } - - @MainActor - @objc func deleteMode(_ sender: NSButton) { - guard let modeID = sender.identifier?.rawValue else { - return - } - onDelete(modeID) - } - } -} - -private final class PromptModeTableScrollView: NSScrollView { - override func layout() { - super.layout() - guard let tableView = documentView as? NSTableView else { - return - } - Self.syncTableWidth(tableView, to: contentView.bounds.width) - } - - static func syncTableWidth(_ tableView: NSTableView, to width: CGFloat) { - let width = max(width, 1) - guard tableView.tableColumns.first?.width != width else { - return - } - - tableView.tableColumns.first?.width = width - var frame = tableView.frame - frame.size.width = width - tableView.frame = frame - } -} - -private final class PromptModeTableCellView: NSTableCellView { - private static let rowWidth: CGFloat = 190 - private let rowContainer = NSView() - private let selectionBackground = NSView() - private let dragHandle = NSImageView() - private let titleField = NSTextField(labelWithString: "") - private let visibilityButton = NSButton() - private let deleteButton = NSButton() - private var trackingArea: NSTrackingArea? - private var isHovered = false - private var isCellSelected = false - private var isModeVisible = true - private var canDeleteMode = true - - init(identifier: NSUserInterfaceItemIdentifier) { - super.init(frame: .zero) - self.identifier = identifier - buildView() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure( - mode: PromptMode, - isSelected: Bool, - canDelete: Bool, - target: PromptModeTableView.Coordinator - ) { - let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) - isCellSelected = isSelected - isModeVisible = mode.isVisible - canDeleteMode = canDelete - titleField.stringValue = mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName - titleField.font = .systemFont(ofSize: 13, weight: isSelected ? .semibold : .regular) - visibilityButton.image = NSImage(systemSymbolName: mode.isVisible ? "eye" : "eye.slash", accessibilityDescription: nil)? - .withSymbolConfiguration(symbolConfiguration) - visibilityButton.toolTip = mode.isVisible ? L10n.text("settings.mode.visible") : L10n.text("settings.mode.hidden") - visibilityButton.identifier = NSUserInterfaceItemIdentifier(mode.id) - visibilityButton.target = target - visibilityButton.action = #selector(PromptModeTableView.Coordinator.toggleVisibility(_:)) - - deleteButton.identifier = NSUserInterfaceItemIdentifier(mode.id) - deleteButton.target = target - deleteButton.action = #selector(PromptModeTableView.Coordinator.deleteMode(_:)) - deleteButton.isEnabled = canDelete - deleteButton.toolTip = L10n.text("settings.mode.delete") - applyAppearance() - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let trackingArea { - removeTrackingArea(trackingArea) - } - let area = NSTrackingArea( - rect: bounds, - options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], - owner: self - ) - trackingArea = area - addTrackingArea(area) - } - - override func mouseEntered(with event: NSEvent) { - isHovered = true - applyAppearance() - } - - override func mouseExited(with event: NSEvent) { - isHovered = false - applyAppearance() - } - - private func buildView() { - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - rowContainer.translatesAutoresizingMaskIntoConstraints = false - addSubview(rowContainer) - - selectionBackground.wantsLayer = true - selectionBackground.layer?.cornerRadius = 12 - selectionBackground.layer?.cornerCurve = .continuous - selectionBackground.layer?.backgroundColor = NSColor.clear.cgColor - selectionBackground.translatesAutoresizingMaskIntoConstraints = false - rowContainer.addSubview(selectionBackground) - - let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) - dragHandle.image = NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: nil)? - .withSymbolConfiguration(symbolConfiguration) - dragHandle.contentTintColor = NSColor(white: 1, alpha: 0.18) - dragHandle.setContentHuggingPriority(.required, for: .horizontal) - dragHandle.setContentCompressionResistancePriority(.required, for: .horizontal) - dragHandle.toolTip = L10n.text("settings.mode.dragToSort") - dragHandle.translatesAutoresizingMaskIntoConstraints = false - - titleField.lineBreakMode = .byTruncatingTail - titleField.textColor = NSColor(white: 0.74, alpha: 1) - titleField.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - titleField.translatesAutoresizingMaskIntoConstraints = false - - configureIconButton(visibilityButton) - configureIconButton(deleteButton) - deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil)? - .withSymbolConfiguration(symbolConfiguration) - deleteButton.contentTintColor = NSColor(white: 1, alpha: 0.32) - - rowContainer.addSubview(dragHandle) - rowContainer.addSubview(titleField) - rowContainer.addSubview(visibilityButton) - rowContainer.addSubview(deleteButton) - - NSLayoutConstraint.activate([ - rowContainer.widthAnchor.constraint(equalToConstant: Self.rowWidth), - rowContainer.centerXAnchor.constraint(equalTo: centerXAnchor, constant: -16), - rowContainer.topAnchor.constraint(equalTo: topAnchor, constant: 3), - rowContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3), - selectionBackground.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor), - selectionBackground.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor), - selectionBackground.topAnchor.constraint(equalTo: rowContainer.topAnchor), - selectionBackground.bottomAnchor.constraint(equalTo: rowContainer.bottomAnchor), - dragHandle.widthAnchor.constraint(equalToConstant: 18), - visibilityButton.widthAnchor.constraint(equalToConstant: 24), - visibilityButton.heightAnchor.constraint(equalToConstant: 24), - deleteButton.widthAnchor.constraint(equalToConstant: 24), - deleteButton.heightAnchor.constraint(equalToConstant: 24), - dragHandle.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: 14), - dragHandle.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), - titleField.leadingAnchor.constraint(equalTo: dragHandle.trailingAnchor, constant: 10), - titleField.trailingAnchor.constraint(equalTo: visibilityButton.leadingAnchor, constant: -8), - titleField.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), - visibilityButton.trailingAnchor.constraint(equalTo: deleteButton.leadingAnchor, constant: -4), - visibilityButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), - deleteButton.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor, constant: -10), - deleteButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor) - ]) - } - - private func applyAppearance() { - let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - selectionBackground.layer?.backgroundColor = isCellSelected - ? NSColor.controlAccentColor.withAlphaComponent(isDark ? 0.20 : 0.14).cgColor - : (isHovered ? NSColor.labelColor.withAlphaComponent(isDark ? 0.04 : 0.045).cgColor : NSColor.clear.cgColor) - titleField.textColor = isCellSelected - ? NSColor.controlAccentColor - : (isHovered ? .labelColor : .secondaryLabelColor) - dragHandle.contentTintColor = isHovered || isCellSelected - ? .secondaryLabelColor - : .tertiaryLabelColor - visibilityButton.contentTintColor = isModeVisible - ? .secondaryLabelColor - : .tertiaryLabelColor - visibilityButton.alphaValue = isHovered || isCellSelected ? 1 : 0.78 - deleteButton.contentTintColor = canDeleteMode - ? .secondaryLabelColor - : .tertiaryLabelColor - deleteButton.alphaValue = isHovered || isCellSelected ? 1 : 0.64 - } - - private func configureIconButton(_ button: NSButton) { - button.translatesAutoresizingMaskIntoConstraints = false - button.isBordered = false - button.bezelStyle = .regularSquare - button.setButtonType(.momentaryChange) - button.imagePosition = .imageOnly - button.focusRingType = .none - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - } -} - -private struct SettingsPromptTextView: NSViewRepresentable { - @Binding var text: String - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.contentInsets = NSEdgeInsetsZero - scrollView.automaticallyAdjustsContentInsets = false - scrollView.horizontalScrollElasticity = .none - - let textView = NSTextView() - textView.string = text - textView.delegate = context.coordinator - textView.isEditable = true - textView.isSelectable = true - textView.isRichText = false - textView.importsGraphics = false - textView.allowsUndo = true - textView.usesFindBar = false - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.font = .systemFont(ofSize: 13) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize( - width: scrollView.contentSize.width, - height: CGFloat.greatestFiniteMagnitude - ) - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - - scrollView.documentView = textView - context.coordinator.textView = textView - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { - return - } - - context.coordinator.text = $text - context.coordinator.textView = textView - textView.font = .systemFont(ofSize: 13) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - - if !textView.hasMarkedText(), textView.string != text { - textView.string = text - } - } - - final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { - var text: Binding - weak var textView: NSTextView? - - init(text: Binding) { - self.text = text - super.init() - } - - @MainActor - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - text.wrappedValue = textView.string - } - } -} - -private struct HotkeyRecorderField: NSViewRepresentable { - @Binding var hotkey: String - - func makeCoordinator() -> Coordinator { - Coordinator(hotkey: $hotkey) - } - - func makeNSView(context: Context) -> RecorderView { - let view = RecorderView() - view.onChange = { context.coordinator.hotkey.wrappedValue = $0 } - view.hotkey = hotkey - return view - } - - func updateNSView(_ nsView: RecorderView, context: Context) { - nsView.hotkey = hotkey - nsView.onChange = { context.coordinator.hotkey.wrappedValue = $0 } - nsView.updateDisplay() - } - - final class Coordinator { - var hotkey: Binding - - init(hotkey: Binding) { - self.hotkey = hotkey - } - } - - final class RecorderView: NSView { - var hotkey = "" - var onChange: ((String) -> Void)? - private var isRecording = false - private let label = NSTextField(labelWithString: "") - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.cornerRadius = 8 - layer?.borderWidth = 1 - label.translatesAutoresizingMaskIntoConstraints = false - label.alignment = .center - label.font = .monospacedSystemFont(ofSize: 13, weight: .medium) - addSubview(label) - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - label.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - updateDisplay() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var acceptsFirstResponder: Bool { true } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - updateDisplay() - } - - override func mouseDown(with event: NSEvent) { - isRecording = true - publishRecordingState() - window?.makeFirstResponder(self) - updateDisplay() - } - - override func resignFirstResponder() -> Bool { - isRecording = false - publishRecordingState() - updateDisplay() - return super.resignFirstResponder() - } - - override func keyDown(with event: NSEvent) { - guard isRecording else { - super.keyDown(with: event) - return - } - - if event.keyCode == UInt16(kVK_Escape) { - isRecording = false - publishRecordingState() - window?.makeFirstResponder(nil) - updateDisplay() - return - } - - guard let recordedHotkey = recordedHotkey(from: event) else { - NSSound.beep() - return - } - - hotkey = recordedHotkey.displayString - onChange?(hotkey) - isRecording = false - publishRecordingState() - window?.makeFirstResponder(nil) - updateDisplay() - } - - override func flagsChanged(with event: NSEvent) { - if isRecording { - updateDisplay(pressedModifiers: modifierDisplayString(from: event.modifierFlags)) - } else { - super.flagsChanged(with: event) - } - } - - func updateDisplay(pressedModifiers: String = "") { - let backgroundColor = isRecording ? NSColor.controlAccentColor.withAlphaComponent(0.16) : NSColor.controlBackgroundColor - layer?.backgroundColor = resolvedCGColor(backgroundColor) - layer?.borderColor = resolvedCGColor(isRecording ? .controlAccentColor : .separatorColor) - label.textColor = isRecording ? .controlAccentColor : .labelColor - if isRecording { - label.stringValue = pressedModifiers.isEmpty - ? L10n.text("settings.hotkey.recording") - : "\(pressedModifiers)\(L10n.text("settings.hotkey.pressKey"))" - } else { - label.stringValue = hotkey.isEmpty ? L10n.text("settings.hotkey.record") : hotkey - } - } - - private func resolvedCGColor(_ color: NSColor) -> CGColor { - var resolvedColor = color.cgColor - effectiveAppearance.performAsCurrentDrawingAppearance { - resolvedColor = color.cgColor - } - return resolvedColor - } - - private func recordedHotkey(from event: NSEvent) -> Hotkey? { - var modifiers: Hotkey.Modifier = [] - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command) { modifiers.insert(.command) } - if flags.contains(.option) { modifiers.insert(.option) } - if flags.contains(.control) { modifiers.insert(.control) } - if flags.contains(.shift) { modifiers.insert(.shift) } - - guard !modifiers.isEmpty, - modifiers != [.shift], - Hotkey.displayName(for: UInt32(event.keyCode)) != nil - else { - return nil - } - - return Hotkey(keyCode: UInt32(event.keyCode), modifiers: modifiers) - } - - private func modifierDisplayString(from flags: NSEvent.ModifierFlags) -> String { - var modifiers: Hotkey.Modifier = [] - let filteredFlags = flags.intersection(.deviceIndependentFlagsMask) - if filteredFlags.contains(.command) { modifiers.insert(.command) } - if filteredFlags.contains(.option) { modifiers.insert(.option) } - if filteredFlags.contains(.control) { modifiers.insert(.control) } - if filteredFlags.contains(.shift) { modifiers.insert(.shift) } - return modifiers.displayString - } - - private func publishRecordingState() { - NotificationCenter.default.post( - name: .hotkeyRecordingDidChange, - object: nil, - userInfo: ["isRecording": isRecording] - ) - } - } -} diff --git a/Sources/InkletApp/SettingsViewModel.swift b/Sources/InkletApp/SettingsViewModel.swift new file mode 100644 index 0000000..49ae7c5 --- /dev/null +++ b/Sources/InkletApp/SettingsViewModel.swift @@ -0,0 +1,598 @@ +import AppKit +import Combine +import SwiftUI +import InkletCore + +enum PronunciationPreviewState: Equatable { + case loading(SelectionPronunciationVoice) + case playing(SelectionPronunciationVoice) + + func matches(_ voice: SelectionPronunciationVoice) -> Bool { + switch self { + case .loading(let activeVoice), .playing(let activeVoice): + activeVoice == voice + } + } +} + +@MainActor +final class SettingsViewModel: ObservableObject { + @Published var config: AppConfig + @Published var message: String + @Published var providerAPIKey: String + @Published var selectedPromptModeID: String + @Published var interfaceLanguage: InterfaceLanguage + @Published var cachedProviderModels: [String: [String]] + @Published var isRefreshingModelCatalog: Bool + @Published var isEditingCustomModel: Bool + @Published var microphoneOptions: [MicrophoneDeviceOption] + @Published private(set) var pronunciationPreviewState: PronunciationPreviewState? + @Published var permissionRefreshID: UUID + @Published var historyItems: [HistoryItem] + @Published var historyFilter: HistorySource? + @Published private(set) var isMigrationMaintenanceActive = false + @Published private(set) var isMigrationWorkflowIdle = true + + private let configStore: UserDefaultsConfigStore + private let apiKeyStore: LocalAPIKeyStore + private let modelCatalogService: ModelCatalogService + private let historyStore: any HistoryStore + private let microphoneDeviceCatalog: MicrophoneDeviceCatalog + private let pronunciationPreviewPlaybackService: SpeechPlaybackService + private var autoSaveCancellable: AnyCancellable? + private var pronunciationPreviewTasks: [UUID: Task] = [:] + private var pronunciationPreviewTaskID: UUID? + private var modelCatalogRefreshTask: Task? + private var modelCatalogRefreshTaskID: UUID? + private var savedConfig: AppConfig + private var savedProviderAPIKey: String + private var savedInterfaceLanguage: InterfaceLanguage + + static let customModelMenuID = "__custom_model__" + + init( + configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), + apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), + modelCatalogService: ModelCatalogService = ModelCatalogService(), + historyStore: any HistoryStore = JSONLHistoryStore(), + microphoneDeviceCatalog: MicrophoneDeviceCatalog = MicrophoneDeviceCatalog() + ) { + var loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() + self.configStore = configStore + self.apiKeyStore = apiKeyStore + self.modelCatalogService = modelCatalogService + self.historyStore = historyStore + self.microphoneDeviceCatalog = microphoneDeviceCatalog + self.pronunciationPreviewPlaybackService = SpeechPlaybackService() + loadedConfig.providerID = LLMProviderPreset.openAI.id + self.config = loadedConfig + self.savedConfig = loadedConfig + self.message = "" + self.interfaceLanguage = InkletLanguageStore.selectedLanguage + self.savedInterfaceLanguage = InkletLanguageStore.selectedLanguage + let loadedProviderAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) ?? "" + self.providerAPIKey = loadedProviderAPIKey + self.savedProviderAPIKey = loadedProviderAPIKey + self.selectedPromptModeID = loadedConfig.promptModes.sorted { $0.sortOrder < $1.sortOrder }.first?.id + ?? PromptMode.translateToEnglishID + self.cachedProviderModels = Dictionary( + uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in + guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { + return nil + } + return (preset.id, modelIDs) + } + ) + self.isRefreshingModelCatalog = false + self.isEditingCustomModel = false + self.microphoneOptions = microphoneDeviceCatalog.options() + self.pronunciationPreviewState = nil + self.permissionRefreshID = UUID() + self.historyItems = (try? historyStore.load()).map { Array($0.reversed()) } ?? [] + self.historyFilter = nil + + self.pronunciationPreviewPlaybackService.onFinish = { [weak self] in + self?.pronunciationPreviewState = nil + self?.refreshMigrationWorkflowIdle() + } + installAutoSave() + } + + var selectedProvider: LLMProviderPreset { + LLMProviderPreset.openAI + } + + var filteredHistoryItems: [HistoryItem] { + guard let historyFilter else { + return historyItems + } + return historyItems.filter { $0.source == historyFilter } + } + + var selectedProviderModelOptions: [String] { + var seen = Set() + var options: [String] = [] + + for modelID in cachedProviderModels[LLMProviderPreset.openAI.id] ?? [] { + if seen.insert(modelID).inserted { + options.append(modelID) + } + } + + if seen.insert(selectedProvider.defaultModel).inserted { + options.insert(selectedProvider.defaultModel, at: 0) + } + + return options + } + + var selectedModelMenuValue: String { + if isEditingCustomModel { + return Self.customModelMenuID + } + + return selectedProviderModelOptions.contains(config.model) ? config.model : Self.customModelMenuID + } + + var shouldShowCustomModelField: Bool { + selectedModelMenuValue == Self.customModelMenuID + } + + var selectedModelIsDefault: Bool { + config.model.trimmingCharacters(in: .whitespacesAndNewlines) == selectedProvider.defaultModel + } + + var selectedPromptModeIndex: Int? { + config.promptModes.firstIndex { $0.id == selectedPromptModeID } + } + + var orderedPromptModes: [PromptMode] { + config.promptModes.sorted { lhs, rhs in + if lhs.sortOrder == rhs.sortOrder { + return lhs.name < rhs.name + } + return lhs.sortOrder < rhs.sortOrder + } + } + + var isAccessibilityTrusted: Bool { + AccessibilityPermissionService().isTrusted + } + + func refreshPermissions() { + permissionRefreshID = UUID() + } + + func reloadHistory() { + do { + historyItems = Array(try historyStore.load().reversed()) + } catch { + historyItems = [] + message = L10n.text("settings.history.error.loadFailed") + } + } + + func clearHistory() { + guard !isMigrationMaintenanceActive else { return } + do { + try historyStore.clear() + historyItems = [] + message = L10n.text("settings.history.cleared") + } catch { + message = L10n.text("settings.history.error.clearFailed") + } + } + + func refreshMicrophoneOptions() { + microphoneOptions = microphoneDeviceCatalog.options() + if let microphoneDeviceID = config.voiceInput.microphoneDeviceID, + !microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) { + config.voiceInput.microphoneDeviceID = nil + } + } + + var selectedMicrophoneMenuID: String { + get { + guard let microphoneDeviceID = config.voiceInput.microphoneDeviceID, + microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) + else { + return MicrophoneDeviceOption.systemDefaultID + } + return microphoneDeviceID + } + set { + config.voiceInput.microphoneDeviceID = newValue == MicrophoneDeviceOption.systemDefaultID ? nil : newValue + } + } + + func modelMenuTitle(for modelID: String) -> String { + if modelID == config.model, !selectedModelIsDefault { + return "\(modelID) *" + } + return modelID + } + + func selectModelMenuValue(_ value: String) { + if value == Self.customModelMenuID { + isEditingCustomModel = true + return + } + + isEditingCustomModel = false + config.model = value + save() + } + + func previewPronunciationVoice() { + guard !isMigrationMaintenanceActive else { return } + let voice = config.selectionActions.pronunciationVoice + let speed = config.selectionActions.pronunciationSpeed + if let pronunciationPreviewTaskID { + pronunciationPreviewTasks[pronunciationPreviewTaskID]?.cancel() + } + pronunciationPreviewPlaybackService.stop() + pronunciationPreviewState = .loading(voice) + let taskID = UUID() + pronunciationPreviewTaskID = taskID + + let task = Task { [weak self] in + defer { + self?.pronunciationPreviewTasks[taskID] = nil + if self?.pronunciationPreviewTaskID == taskID { + self?.pronunciationPreviewTaskID = nil + } + self?.refreshMigrationWorkflowIdle() + } + do { + guard let self else { return } + let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) + } + return apiKey + }) + let audioData = try await provider.speechAudio(OpenAITTSRequest( + input: SelectionPronunciationVoice.previewText( + interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier + ), + voice: voice.rawValue, + speed: speed, + timeoutSeconds: config.timeoutSeconds + )) + guard !Task.isCancelled, + pronunciationPreviewTaskID == taskID, + !isMigrationMaintenanceActive + else { + return + } + await MainActor.run { + do { + try self.pronunciationPreviewPlaybackService.play(audioData: audioData) + self.pronunciationPreviewState = .playing(voice) + } catch { + self.pronunciationPreviewState = nil + self.message = L10n.text("selection.action.pronunciationFailed") + } + } + } catch is CancellationError { + } catch { + guard !Task.isCancelled, self?.pronunciationPreviewTaskID == taskID else { return } + await MainActor.run { + self?.pronunciationPreviewState = nil + self?.message = L10n.text("selection.action.pronunciationFailed") + } + } + } + pronunciationPreviewTasks[taskID] = task + refreshMigrationWorkflowIdle() + } + + func resetSelectionTranslationPrompt() { + config.selectionActions.translationPrompt = SelectionActionsConfig.defaultTranslationPrompt + save() + } + + func refreshModelCatalogIfNeeded() async { + guard !isMigrationMaintenanceActive else { return } + if let modelCatalogRefreshTask { + await modelCatalogRefreshTask.value + return + } + + isRefreshingModelCatalog = true + let taskID = UUID() + modelCatalogRefreshTaskID = taskID + let modelCatalogService = self.modelCatalogService + let task = Task { + do { + try await modelCatalogService.refreshIfNeeded() + } catch { + // The model picker still works with saved/default/custom values when refresh fails. + } + } + modelCatalogRefreshTask = task + refreshMigrationWorkflowIdle() + await task.value + + guard modelCatalogRefreshTaskID == taskID else { return } + modelCatalogRefreshTask = nil + modelCatalogRefreshTaskID = nil + isRefreshingModelCatalog = false + refreshMigrationWorkflowIdle() + guard !isMigrationMaintenanceActive else { return } + cachedProviderModels = Dictionary( + uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in + guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { + return nil + } + return (preset.id, modelIDs) + } + ) + } + + func addPromptMode() { + let mode = PromptMode( + id: "custom-\(Int(Date().timeIntervalSince1970))", + name: L10n.text("settings.mode.newName"), + description: "", + systemPrompt: "", + shortcut: nil, + participatesInAuto: false, + autoRule: .none, + sortOrder: (config.promptModes.map(\.sortOrder).max() ?? 0) + 1, + isVisible: true + ) + config.promptModes.append(mode) + selectedPromptModeID = mode.id + normalizePromptModeSortOrder() + save() + } + + func deleteSelectedPromptMode() { + deletePromptMode(modeID: selectedPromptModeID) + } + + func deletePromptMode(modeID: String) { + guard config.promptModes.count > 1 else { + message = L10n.text("settings.error.promptModeRequired") + return + } + + config.promptModes.removeAll { $0.id == modeID } + normalizePromptModeSortOrder() + + if !config.promptModes.contains(where: \.isVisible), + let firstIndex = config.promptModes.firstIndex(where: { _ in true }) { + config.promptModes[firstIndex].isVisible = true + } + + if selectedPromptModeID == modeID { + selectedPromptModeID = config.promptModes.first?.id ?? PromptMode.translateToEnglishID + } + save() + } + + func movePromptModes(from source: IndexSet, to destination: Int) { + var orderedModes = orderedPromptModes + orderedModes.move(fromOffsets: source, toOffset: destination) + + for (sortOrder, mode) in orderedModes.enumerated() { + guard let configIndex = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { + continue + } + config.promptModes[configIndex].sortOrder = sortOrder + } + normalizePromptModeSortOrder() + save() + } + + func promptModeName(modeID: String) -> String { + guard let mode = config.promptModes.first(where: { $0.id == modeID }) else { + return L10n.text("settings.mode.untitled") + } + return mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName + } + + func togglePromptModeVisibility(modeID: String) { + guard let index = config.promptModes.firstIndex(where: { $0.id == modeID }) else { + return + } + + if config.promptModes[index].isVisible, + config.promptModes.filter(\.isVisible).count <= 1 { + message = L10n.text("settings.error.visibleModeRequired") + return + } + + config.promptModes[index].isVisible.toggle() + save() + } + + func promptModeVisibilityBinding(modeID: String) -> Binding { + Binding( + get: { [weak self] in + self?.config.promptModes.first(where: { $0.id == modeID })?.isVisible ?? false + }, + set: { [weak self] newValue in + guard let self, + let mode = self.config.promptModes.first(where: { $0.id == modeID }), + mode.isVisible != newValue + else { + return + } + self.togglePromptModeVisibility(modeID: modeID) + } + ) + } + + func canMovePromptMode(modeID: String, direction: Int) -> Bool { + guard let currentIndex = orderedPromptModes.firstIndex(where: { $0.id == modeID }) else { + return false + } + + return orderedPromptModes.indices.contains(currentIndex + direction) + } + + private func normalizePromptModeSortOrder() { + let orderedModes = orderedPromptModes + for (sortOrder, mode) in orderedModes.enumerated() { + guard let index = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { + continue + } + config.promptModes[index].sortOrder = sortOrder + } + config.promptModes.sort { lhs, rhs in + if lhs.sortOrder == rhs.sortOrder { + return lhs.name < rhs.name + } + return lhs.sortOrder < rhs.sortOrder + } + } + + @discardableResult + func save() -> Bool { + guard !isMigrationMaintenanceActive else { return false } + let trimmedKey = providerAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldSaveConfig = config != savedConfig + let shouldSaveProviderAPIKey = providerAPIKey != savedProviderAPIKey + let shouldSaveInterfaceLanguage = interfaceLanguage != savedInterfaceLanguage + guard shouldSaveConfig || shouldSaveProviderAPIKey || shouldSaveInterfaceLanguage else { + return true + } + + do { + if shouldSaveConfig { + guard config.promptModes.contains(where: \.isVisible) else { + message = L10n.text("settings.error.visibleModeRequired") + return false + } + guard !config.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + message = L10n.text("settings.error.modelRequired") + return false + } + config.providerID = LLMProviderPreset.openAI.id + + _ = try Hotkey.parse(config.hotkey) + try configStore.save(config) + savedConfig = config + } + + if shouldSaveInterfaceLanguage { + InkletLanguageStore.selectedLanguage = interfaceLanguage + savedInterfaceLanguage = interfaceLanguage + } + + if shouldSaveProviderAPIKey { + if trimmedKey.isEmpty { + try apiKeyStore.deleteAPIKey(forProviderID: LLMProviderPreset.openAI.id) + } else { + try apiKeyStore.saveAPIKey(trimmedKey, forProviderID: LLMProviderPreset.openAI.id) + } + providerAPIKey = trimmedKey + savedProviderAPIKey = trimmedKey + } + message = L10n.text("settings.saved") + NotificationCenter.default.post(name: .appConfigDidSave, object: nil) + return true + } catch let error as HotkeyError { + message = error.userFacingMessage + return false + } catch { + message = L10n.format("settings.error.saveFailed", String(describing: error)) + return false + } + } + + private func installAutoSave() { + guard !isMigrationMaintenanceActive, autoSaveCancellable == nil else { + return + } + autoSaveCancellable = Publishers.CombineLatest3($config, $providerAPIKey, $interfaceLanguage) + .dropFirst() + .debounce(for: .milliseconds(450), scheduler: RunLoop.main) + .sink { [weak self] _, _, _ in + guard let self else { return } + self.save() + } + } + + func flushPendingEdits() -> Bool { + autoSaveCancellable?.cancel() + autoSaveCancellable = nil + let didSave = hasPendingEdits ? save() : true + installAutoSave() + return didSave + } + + private var hasPendingEdits: Bool { + config != savedConfig + || providerAPIKey != savedProviderAPIKey + || interfaceLanguage != savedInterfaceLanguage + } + + func setMigrationMaintenanceActive(_ isActive: Bool) { + guard isMigrationMaintenanceActive != isActive else { return } + isMigrationMaintenanceActive = isActive + if isActive { + autoSaveCancellable?.cancel() + autoSaveCancellable = nil + pronunciationPreviewTasks.values.forEach { $0.cancel() } + modelCatalogRefreshTask?.cancel() + pronunciationPreviewPlaybackService.stop() + pronunciationPreviewState = nil + refreshMigrationWorkflowIdle() + } else { + installAutoSave() + refreshMigrationWorkflowIdle() + } + } + + func waitForMigrationMaintenanceQuiescence() async { + let previewTasks = Array(pronunciationPreviewTasks.values) + for task in previewTasks { + await task.value + } + + let catalogTaskID = modelCatalogRefreshTaskID + await modelCatalogRefreshTask?.value + if modelCatalogRefreshTaskID == catalogTaskID { + modelCatalogRefreshTask = nil + modelCatalogRefreshTaskID = nil + isRefreshingModelCatalog = false + } + refreshMigrationWorkflowIdle() + } + + private func refreshMigrationWorkflowIdle() { + isMigrationWorkflowIdle = pronunciationPreviewState == nil + && pronunciationPreviewTasks.isEmpty + && modelCatalogRefreshTask == nil + && !isRefreshingModelCatalog + } + + func openAccessibilitySettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) else { + message = L10n.text("settings.error.openAccessibility") + return + } + + NotificationCenter.default.post( + name: .inkletDidOpenPermissionSettings, + object: nil, + userInfo: ["permission": PermissionSettingsDestination.accessibility.rawValue] + ) + NSWorkspace.shared.open(url) + } +} + +extension Notification.Name { + static let appConfigDidSave = Notification.Name("InkletAppConfigDidSave") + static let hotkeyRecordingDidChange = Notification.Name("InkletHotkeyRecordingDidChange") + static let inkletDidOpenPermissionSettings = Notification.Name("InkletDidOpenPermissionSettings") + static let inkletAccessibilityDidBecomeTrusted = Notification.Name("InkletAccessibilityDidBecomeTrusted") + static let inkletDidCompleteOnboarding = Notification.Name("InkletDidCompleteOnboarding") +} diff --git a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift index b14341f..5e375e4 100644 --- a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift +++ b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift @@ -33,7 +33,9 @@ final class LegacyMigrationAppSourceTests: XCTestCase { func testSettingsRetainsOneModelAndFreezesAutosaveDuringMigration() throws { let controller = try appSource(named: "SettingsWindowController.swift") - let view = try appSource(named: "SettingsView.swift") + let view = try ["SettingsViewModel.swift", "SettingsView.swift"] + .map { try appSource(named: $0) } + .joined(separator: "\n") let compactController = controller.filter { !$0.isWhitespace } let pronunciationPreview = try sourceScope( startingAt: "func previewPronunciationVoice()", diff --git a/Tests/InkletCoreTests/SettingsViewSourceTests.swift b/Tests/InkletCoreTests/SettingsViewSourceTests.swift index 9654a13..d4e6819 100644 --- a/Tests/InkletCoreTests/SettingsViewSourceTests.swift +++ b/Tests/InkletCoreTests/SettingsViewSourceTests.swift @@ -5,9 +5,7 @@ import Security final class SettingsViewSourceTests: XCTestCase { func testTemperatureSettingIsRemoved() throws { - let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/SettingsView.swift") - let source = try String(contentsOf: sourceURL, encoding: .utf8) + let source = try settingsViewSource() XCTAssertFalse(source.contains("settings.row.temperature")) XCTAssertFalse(source.contains("settings.help.temperature")) @@ -228,10 +226,15 @@ final class SettingsViewSourceTests: XCTestCase { XCTAssertFalse(rowBlock.contains("settings.history.copyOriginal")) } + /// The settings sources in their original single-file order: view model, view, controls. private func settingsViewSource() throws -> String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/SettingsView.swift") - return try String(contentsOf: sourceURL, encoding: .utf8) + return try ["SettingsViewModel.swift", "SettingsView.swift", "SettingsControls.swift"].map { + try String( + contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent($0), + encoding: .utf8 + ) + }.joined(separator: "\n") } private func sourceScope( From 581167868997eeb2e0e7150ddd2fe4e7064a9c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:06:19 +0000 Subject: [PATCH 5/6] Split the writing popover view into smaller files InkletPopoverViewModel moves to its own file, and the AppKit text editor and key handling (InkletTextView, its container and native text view, PopoverKeyEventHandler) move to InkletTextView.swift. InkletPopoverView.swift keeps the SwiftUI layout. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DzisdHuvFVePscX66cn3Lw --- Sources/InkletApp/InkletPopoverView.swift | 1284 ----------------- .../InkletApp/InkletPopoverViewModel.swift | 844 +++++++++++ Sources/InkletApp/InkletTextView.swift | 446 ++++++ .../LegacyMigrationAppSourceTests.swift | 4 +- .../WritingDictationLocalizationTests.swift | 4 +- .../WritingModeLauncherSourceTests.swift | 19 +- .../WritingPopoverKeyboardPolicyTests.swift | 17 +- 7 files changed, 1318 insertions(+), 1300 deletions(-) create mode 100644 Sources/InkletApp/InkletPopoverViewModel.swift create mode 100644 Sources/InkletApp/InkletTextView.swift diff --git a/Sources/InkletApp/InkletPopoverView.swift b/Sources/InkletApp/InkletPopoverView.swift index eb23f5c..6e3c871 100644 --- a/Sources/InkletApp/InkletPopoverView.swift +++ b/Sources/InkletApp/InkletPopoverView.swift @@ -1,848 +1,7 @@ -import Combine import AppKit import SwiftUI import InkletCore -@MainActor -final class InkletPopoverViewModel: ObservableObject { - @Published var sourceText = "" - @Published var resultText = "" - @Published var errorMessage: String? - @Published var isTransforming = false - @Published var isInserting = false - @Published private(set) var modePickerState: WritingModePickerState - @Published private(set) var popoverSession: WritingPopoverSessionState - @Published private(set) var modeSearchFocusRevision = 0 - @Published private(set) var modes: [PromptMode] - @Published var preferredPopoverHeight: CGFloat = 168 - @Published var appearance: AppAppearance - @Published var voiceShortcutHint: VoiceInputConfig.Shortcut? - @Published private(set) var dictationPhase: WritingDictationCoordinator.Phase = .idle - - var onHidePopover: (() -> Void)? - var onFocusPopover: (() -> Void)? - var onFocusSourceInput: ((FocusRequestGeneration.Request) -> Void)? - var onOpenSettings: (() -> Void)? - var onCancelDictation: (() -> Void)? - - var route: WritingPopoverSessionState.Route { - popoverSession.route - } - - var selectedModeID: String { - popoverSession.selectedModeID - } - - var isResultStale: Bool { - popoverSession.isResultStale && !resultText.isEmpty - } - - var resultModeDisplayName: String? { - guard let resultModeID = popoverSession.resultModeID else { - return nil - } - - return modes.first(where: { $0.id == resultModeID })?.localizedName ?? resultModeID - } - - var currentProviderName: String { - config.resolvedProviderPreset.name - } - - var currentModelName: String { - config.model - } - - var currentVoiceInputConfig: VoiceInputConfig { - config.voiceInput - } - - var shouldShowDictationStatus: Bool { - voiceShortcutHint != nil || dictationPhase.isActive - } - - var dictationStatusText: String { - switch dictationPhase { - case .connecting: - L10n.text("dictation.status.connecting") - case .listening: - L10n.text("dictation.status.listening") - case .recordingForFallback: - L10n.text("dictation.status.recordingFallback") - case .finalizing: - L10n.text("dictation.status.finalizing") - case .recovering: - L10n.text("dictation.status.recovering") - case .idle, .complete, .failed: - L10n.format("dictation.hint.hold", voiceShortcutHint?.localizedName ?? "") - } - } - - var dictationStatusAccessibilityLabel: String { - switch dictationPhase { - case .listening: - L10n.text("dictation.accessibility.listening") - case .recordingForFallback: - L10n.text("dictation.accessibility.recordingFallback") - case .connecting, .finalizing, .recovering: - dictationStatusText - case .idle, .complete, .failed: - L10n.text("dictation.accessibility.ready") - } - } - - private struct SourceDictationPresentationSnapshot { - let sourceText: String - let resultText: String - let errorMessage: String? - let popoverSession: WritingPopoverSessionState - let stateMachineState: PopoverStateMachine.State - let draftSourceText: String - let hasTransformedInSession: Bool - } - - private var stateMachine: PopoverStateMachine - private let configStore: UserDefaultsConfigStore - private let apiKeyStore: LocalAPIKeyStore - private let insertionService: InsertionService - private let transformationServiceFactory: (any LLMProvider) -> TransformationService - private let historyStore: any HistoryStore - private let writingModePreferenceStore: WritingModePreferenceStore - private var config: AppConfig - private var previousApplication: NSRunningApplication? - private var transformationTask: Task? - private var insertionTask: Task? - private var sessionID = 0 - private var draftSourceText = "" - private var hasTransformedInSession = false - private var sourceFocusGeneration = FocusRequestGeneration() - private var sourceDictationPresentationSnapshot: SourceDictationPresentationSnapshot? - private var isRestoringSourceDictationPresentation = false - private var languageCancellable: AnyCancellable? - private var dictationErrorMessage: String? - - init( - stateMachine: PopoverStateMachine = PopoverStateMachine(), - configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), - apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), - transformationServiceFactory: @escaping (any LLMProvider) -> TransformationService = { TransformationService(provider: $0) }, - insertionService: InsertionService = InsertionService(), - historyStore: any HistoryStore = JSONLHistoryStore(), - writingModePreferenceStore: WritingModePreferenceStore = WritingModePreferenceStore() - ) { - self.stateMachine = stateMachine - self.configStore = configStore - self.apiKeyStore = apiKeyStore - self.transformationServiceFactory = transformationServiceFactory - self.insertionService = insertionService - self.historyStore = historyStore - self.writingModePreferenceStore = writingModePreferenceStore - - let loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() - let selectedModeID = Self.resolvedModeID( - preferredModeID: writingModePreferenceStore.loadLastModeID(), - config: loadedConfig - ) - let visibleModes = Self.resolvedVisibleModes(from: loadedConfig) - self.config = loadedConfig - self.modes = visibleModes - self.modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: visibleModes), - preferredModeID: selectedModeID - ) - self.popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) - self.appearance = loadedConfig.appearance - self.voiceShortcutHint = nil - languageCancellable = NotificationCenter.default.publisher(for: .inkletLanguageDidChange) - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.refreshLocalizedContent() - } - } - - private func refreshLocalizedContent() { - modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: modes), - preferredModeID: modePickerState.highlightedModeID, - query: modePickerState.query - ) - if case .failed(let errorKey) = dictationPhase, errorMessage == dictationErrorMessage { - errorMessage = L10n.text(errorKey) - dictationErrorMessage = errorMessage - } - } - - func resetForOpen(previousApplication: NSRunningApplication?) { - invalidateSourceInputFocusRequests() - transformationTask?.cancel() - transformationTask = nil - insertionTask?.cancel() - insertionTask = nil - sessionID += 1 - self.previousApplication = previousApplication - stateMachine = PopoverStateMachine() - - config = (try? configStore.load()) ?? AppConfig.defaultConfig() - let selectedModeID = Self.resolvedModeID( - preferredModeID: writingModePreferenceStore.loadLastModeID(), - config: config - ) - modes = Self.resolvedVisibleModes(from: config) - popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) - modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: modes), - preferredModeID: selectedModeID - ) - modeSearchFocusRevision += 1 - appearance = config.appearance - refreshVoiceShortcutHint() - sourceText = draftSourceText - resultText = "" - errorMessage = nil - isTransforming = false - isInserting = false - hasTransformedInSession = false - sourceDictationPresentationSnapshot = nil - dictationPhase = .idle - preferredPopoverHeight = WritingModePickerView.preferredHeight( - resultCount: modePickerState.filteredItems.count - ) - - handle(actions: stateMachine.send(.open)) - if !sourceText.isEmpty { - _ = stateMachine.send(.sourceChanged(sourceText)) - } - } - - var isBusy: Bool { - isTransforming || isInserting || dictationPhase.isActive - } - - func cancelForMigrationMaintenance() { - sessionID += 1 - transformationTask?.cancel() - transformationTask = nil - insertionTask?.cancel() - insertionTask = nil - isTransforming = false - isInserting = false - } - - private func refreshVoiceShortcutHint() { - let shortcut = config.voiceInput.shortcut - voiceShortcutHint = shortcut == .disabled ? nil : shortcut - } - - func beginSourceDictationPresentation() -> Bool { - guard route == .editor, - !isBusy, - !isRestoringSourceDictationPresentation, - sourceDictationPresentationSnapshot == nil - else { - return false - } - - sourceDictationPresentationSnapshot = SourceDictationPresentationSnapshot( - sourceText: sourceText, - resultText: resultText, - errorMessage: errorMessage, - popoverSession: popoverSession, - stateMachineState: stateMachine.state, - draftSourceText: draftSourceText, - hasTransformedInSession: hasTransformedInSession - ) - errorMessage = nil - return true - } - - func synchronizeSourceTextDuringDictation(_ text: String) { - guard sourceDictationPresentationSnapshot != nil else { - return - } - sourceText = text - } - - func commitSourceDictationPresentation() { - guard sourceDictationPresentationSnapshot != nil else { - return - } - sourceDictationPresentationSnapshot = nil - resultText = "" - errorMessage = nil - mutatePopoverSession { $0.clearResult() } - draftSourceText = sourceText - hasTransformedInSession = false - stateMachine = PopoverStateMachine( - state: .editingSource(source: sourceText, errorMessage: nil) - ) - } - - func synchronizeSourceTextAfterDictation(_ text: String) { - sourceText = text - resultText = "" - errorMessage = nil - mutatePopoverSession { $0.clearResult() } - draftSourceText = text - hasTransformedInSession = false - stateMachine = PopoverStateMachine( - state: .editingSource(source: text, errorMessage: nil) - ) - } - - func restoreSourceDictationPresentation() { - guard let snapshot = sourceDictationPresentationSnapshot, - !isRestoringSourceDictationPresentation - else { - return - } - isRestoringSourceDictationPresentation = true - defer { - sourceDictationPresentationSnapshot = nil - isRestoringSourceDictationPresentation = false - } - sourceText = snapshot.sourceText - resultText = snapshot.resultText - errorMessage = snapshot.errorMessage - popoverSession = snapshot.popoverSession - stateMachine = PopoverStateMachine(state: snapshot.stateMachineState) - draftSourceText = snapshot.draftSourceText - hasTransformedInSession = snapshot.hasTransformedInSession - } - - func setDictationPhase(_ phase: WritingDictationCoordinator.Phase) { - dictationPhase = phase - if case .failed(let errorKey) = phase { - errorMessage = L10n.text(errorKey) - dictationErrorMessage = errorMessage - } - } - - func updateSourceText(_ text: String) { - guard !isBusy else { - return - } - - sourceText = text - if !resultText.isEmpty { - resultText = "" - mutatePopoverSession { $0.clearResult() } - } - - _ = stateMachine.send(.sourceChanged(text)) - } - - func updateResultText(_ text: String) { - guard !isBusy else { - return - } - - resultText = text - guard !text.isEmpty else { - mutatePopoverSession { $0.clearResult() } - stateMachine = PopoverStateMachine( - state: .editingSource(source: sourceText, errorMessage: nil) - ) - return - } - - _ = stateMachine.send(.resultChanged(text)) - } - - func updateModeSearchQuery(_ query: String) { - guard !isBusy else { - return - } - mutateModePickerState { $0.setQuery(query) } - } - - func moveModeHighlight(by offset: Int) { - guard !isBusy else { - return - } - mutateModePickerState { $0.moveHighlight(by: offset) } - } - - func highlightMode(modeID: String) { - guard !isBusy else { - return - } - mutateModePickerState { _ = $0.highlight(modeID: modeID) } - } - - func commitHighlightedMode() { - guard let highlightedModeID = modePickerState.highlightedModeID else { - return - } - - commitMode(modeID: highlightedModeID) - } - - func commitMode(modeID: String) { - guard !isBusy, - modes.contains(where: { $0.id == modeID }) - else { - return - } - - mutatePopoverSession { $0.enterEditor(modeID: modeID) } - writingModePreferenceStore.saveLastModeID(modeID) - requestSourceInputFocus() - } - - func returnToModePicker() { - guard !isBusy else { - return - } - - invalidateSourceInputFocusRequests() - mutatePopoverSession { $0.showModePicker() } - modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: modes), - preferredModeID: selectedModeID - ) - modeSearchFocusRevision += 1 - } - - func cyclePromptMode(direction: Int) { - guard !modes.isEmpty else { - return - } - - let currentIndex = modes.firstIndex { $0.id == selectedModeID } ?? 0 - let nextIndex = (currentIndex + direction + modes.count) % modes.count - commitMode(modeID: modes[nextIndex].id) - } - - func submit() { - guard !isBusy else { - return - } - - errorMessage = nil - if !resultText.isEmpty, isResultStale { - removeSingleTrailingNewlineFromSource() - _ = stateMachine.send(.sourceChanged(sourceText)) - handle(actions: stateMachine.send(.submit)) - return - } - - if !resultText.isEmpty { - removeSingleTrailingNewlineFromResult() - let currentResult = resultText - _ = stateMachine.send(.resultChanged(currentResult)) - let actions = stateMachine.send(.submit) - if actions.isEmpty { - insert( - text: currentResult, - fallbackState: .previewingResult(source: sourceText, result: currentResult) - ) - } else { - handle(actions: actions) - } - return - } - - removeSingleTrailingNewlineFromSource() - _ = stateMachine.send(.sourceChanged(sourceText)) - handle(actions: stateMachine.send(.submit)) - } - - private func removeSingleTrailingNewlineFromSource() { - if sourceText.hasSuffix("\r\n") { - sourceText.removeLast(2) - } else if sourceText.hasSuffix("\n") || sourceText.hasSuffix("\r") { - sourceText.removeLast() - } - } - - private func removeSingleTrailingNewlineFromResult() { - if resultText.hasSuffix("\r\n") { - resultText.removeLast(2) - } else if resultText.hasSuffix("\n") || resultText.hasSuffix("\r") { - resultText.removeLast() - } - } - - func insertOriginal() { - guard !isBusy else { - return - } - - let trimmedSource = sourceText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedSource.isEmpty else { - errorMessage = L10n.text("popover.error.emptyOriginal") - return - } - - errorMessage = nil - let fallbackState: PopoverStateMachine.State = resultText.isEmpty - ? .editingSource(source: sourceText, errorMessage: nil) - : .previewingResult(source: sourceText, result: resultText) - - if resultText.isEmpty { - _ = stateMachine.send(.sourceChanged(sourceText)) - let actions = stateMachine.send(.insertOriginal) - if !actions.isEmpty { - handle(actions: actions) - return - } - } - - insert(text: sourceText, fallbackState: fallbackState) - } - - func escape() { - if dictationPhase.isActive { - onCancelDictation?() - return - } - - if isTransforming { - cancelTransformationAndStayInEditor() - return - } - - guard !isInserting else { - return - } - - if route == .modePicker { - draftSourceText = hasTransformedInSession ? "" : sourceText - handle(actions: stateMachine.send(.close)) - return - } - - if !resultText.isEmpty { - resultText = "" - errorMessage = nil - mutatePopoverSession { $0.clearResult() } - stateMachine = PopoverStateMachine( - state: .editingSource(source: sourceText, errorMessage: nil) - ) - requestSourceInputFocus() - return - } - - returnToModePicker() - } - - func openSettings() { - guard !isBusy else { - return - } - onHidePopover?() - onOpenSettings?() - } - - func isCurrentSourceFocusRequest(_ request: FocusRequestGeneration.Request) -> Bool { - sourceFocusGeneration.isCurrent(request) - } - - private func requestSourceInputFocus() { - guard route == .editor else { - return - } - - let request = sourceFocusGeneration.issue() - onFocusSourceInput?(request) - } - - private func invalidateSourceInputFocusRequests() { - sourceFocusGeneration.invalidate() - } - - private func handle(actions: [PopoverStateMachine.Action]) { - for action in actions { - switch action { - case .showPopover: - onFocusPopover?() - case .hidePopover: - onHidePopover?() - case .focusSourceInput: - requestSourceInputFocus() - case .startTransformation(let source): - startTransformation(source: source) - case .showResult(let result): - hasTransformedInSession = true - draftSourceText = "" - resultText = result - case .showError(let message): - errorMessage = localizedStateMachineMessage(message) - case .insertText(let text): - let fallbackState: PopoverStateMachine.State - if !resultText.isEmpty { - fallbackState = .previewingResult(source: sourceText, result: resultText) - } else { - fallbackState = .editingSource(source: sourceText, errorMessage: nil) - } - insert(text: text, fallbackState: fallbackState) - } - } - } - - private func startTransformation(source: String) { - transformationTask?.cancel() - let preservesExistingResult = isResultStale - if !preservesExistingResult { - resultText = "" - mutatePopoverSession { $0.clearResult() } - } - errorMessage = nil - isTransforming = true - - let resolvedModeID = Self.resolvedModeID( - preferredModeID: selectedModeID, - visibleModes: modes - ) - let mode = PromptModeStore(modes: modes).resolve( - modeID: resolvedModeID, - sourceText: source - ) - let transformationModeID = mode.id - let model = config.model - let timeoutSeconds = config.timeoutSeconds - let providerPreset = config.resolvedProviderPreset - let providerID = config.providerID - let apiKeyStore = self.apiKeyStore - let provider = OpenAIProvider( - apiKeyProvider: { - try LocalAPIKeyProvider( - apiKeyStore: apiKeyStore, - providerID: providerID, - providerName: providerPreset.name - ).loadAPIKey() - }, - endpoint: providerPreset.endpoint - ) - let transformationService = transformationServiceFactory(provider) - - transformationTask = Task { [weak self] in - guard let self else { return } - do { - let result = try await transformationService.transform( - sourceText: source, - mode: mode, - model: model, - timeoutSeconds: timeoutSeconds - ) - guard !Task.isCancelled else { return } - try? historyStore.append(HistoryItem( - source: .write, - inputText: source, - outputText: result.outputText, - modeName: mode.localizedName, - targetLanguageName: nil, - model: model, - metadata: [ - "modeID": mode.id, - "providerID": providerID - ] - )) - transformationTask = nil - isTransforming = false - mutatePopoverSession { $0.recordResult(modeID: transformationModeID) } - handle(actions: stateMachine.send(.transformationSucceeded(result: result.outputText))) - } catch { - guard !Task.isCancelled else { return } - transformationTask = nil - isTransforming = false - if !preservesExistingResult { - resultText = "" - mutatePopoverSession { $0.clearResult() } - } - handle(actions: stateMachine.send(.transformationFailed(message: error.userFacingMessage))) - if preservesExistingResult { - synchronizeStateMachineWithVisibleContent() - } - } - } - } - - private func cancelTransformationAndStayInEditor() { - transformationTask?.cancel() - transformationTask = nil - isTransforming = false - errorMessage = nil - mutatePopoverSession { $0.enterEditor(modeID: selectedModeID) } - synchronizeStateMachineWithVisibleContent() - } - - private func synchronizeStateMachineWithVisibleContent() { - let visibleState: PopoverStateMachine.State = resultText.isEmpty - ? .editingSource(source: sourceText, errorMessage: nil) - : .previewingResult(source: sourceText, result: resultText) - stateMachine = PopoverStateMachine(state: visibleState) - } - - private func mutateModePickerState(_ mutation: (inout WritingModePickerState) -> Void) { - var updatedState = modePickerState - mutation(&updatedState) - modePickerState = updatedState - } - - private func mutatePopoverSession(_ mutation: (inout WritingPopoverSessionState) -> Void) { - var updatedSession = popoverSession - mutation(&updatedSession) - popoverSession = updatedSession - } - - private static func resolvedModeID( - preferredModeID: String?, - config: AppConfig - ) -> String { - guard let preferredModeID else { - return config.defaultVisibleModeID - } - - return config.visibleModeID(preferredModeID: preferredModeID) - } - - private static func resolvedVisibleModes(from config: AppConfig) -> [PromptMode] { - let visibleModes = config.visiblePromptModes - if !visibleModes.isEmpty { - return visibleModes - } - - return AppConfig.defaultConfig().visiblePromptModes - } - - private static func resolvedModeID( - preferredModeID: String?, - visibleModes: [PromptMode] - ) -> String { - if let preferredModeID, - visibleModes.contains(where: { $0.id == preferredModeID }) { - return preferredModeID - } - - return visibleModes.first?.id ?? PromptMode.translateToEnglishID - } - - private static func modePickerItems(for modes: [PromptMode]) -> [WritingModePickerItem] { - modes.map { WritingModePickerItem(id: $0.id, title: $0.localizedName) } - } - - private func localizedStateMachineMessage(_ message: String) -> String { - switch message { - case "请输入要转换的文本": - L10n.text("error.emptySource") - default: - message - } - } - - private func insert(text: String, fallbackState: PopoverStateMachine.State) { - guard let previousApplication else { - stateMachine = PopoverStateMachine(state: fallbackState) - errorMessage = L10n.text("popover.error.missingTarget") - return - } - - errorMessage = nil - onHidePopover?() - isInserting = true - - let insertionSessionID = sessionID - insertionTask?.cancel() - insertionTask = Task { [weak self] in - guard let self else { return } - do { - try await insertionService.insert(text: text, into: previousApplication) - guard sessionID == insertionSessionID else { - return - } - isInserting = false - insertionTask = nil - draftSourceText = "" - sourceText = "" - resultText = "" - mutatePopoverSession { $0.clearResult() } - handle(actions: stateMachine.send(.insertionFinished)) - } catch { - guard sessionID == insertionSessionID else { - return - } - isInserting = false - insertionTask = nil - stateMachine = PopoverStateMachine(state: fallbackState) - errorMessage = error.userFacingMessage - onFocusPopover?() - } - } - } -} - -private struct LocalAPIKeyProvider: @unchecked Sendable { - let apiKeyStore: LocalAPIKeyStore - let providerID: String - let providerName: String - - func loadAPIKey() throws -> String { - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerName)) - } - return apiKey - } -} - -private extension Error { - var userFacingMessage: String { - if let transformationError = self as? TransformationError { - switch transformationError { - case .emptySource: - return L10n.text("error.emptySource") - case .emptyResponse: - return L10n.text("error.emptyResponse") - case .timeout: - return L10n.text("error.timeout") - case .networkConnectionLost: - return L10n.text("error.networkConnectionLost") - case .provider(let message): - return localizedProviderMessage(message) - } - } - - if let localizedError = self as? LocalizedError, - let description = localizedError.errorDescription { - return description - } - - if let insertionError = self as? InsertionError { - switch insertionError { - case .accessibilityPermissionMissing: - return L10n.text("insertion.error.accessibility") - case .activationFailed: - return L10n.text("insertion.error.activation") - case .cannotCreatePasteEvent: - return L10n.text("insertion.error.pasteEvent") - case .clipboardRestoreFailed: - return L10n.text("insertion.error.clipboardRestore") - } - } - - return String(describing: self) - } - - private func localizedProviderMessage(_ message: String) -> String { - let providerName = LLMProviderPreset.openAI.name - let prefix = "\(providerName) 请求失败:" - guard message.hasPrefix(prefix) else { - return message - } - - let detail = String(message.dropFirst(prefix.count)) - if detail == "URL 无效" { - return L10n.format("error.provider.urlInvalid", providerName) - } - if detail == "HTTP unknown" { - return L10n.format("error.provider.httpUnknown", providerName) - } - return L10n.format("error.provider.prefix", providerName, detail) - } -} - enum InkletTextViewAttachmentEvent { case attach(NSTextView) case detach(NSTextView) @@ -1452,446 +611,3 @@ private struct ResultEditorHeightPreferenceKey: PreferenceKey { value = nextValue() } } - -private final class InkletTextContainerView: NSView { - let scrollView = NSScrollView() - let placeholderLabel = NSTextField(labelWithString: "") - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - scrollView.translatesAutoresizingMaskIntoConstraints = false - placeholderLabel.translatesAutoresizingMaskIntoConstraints = false - placeholderLabel.font = .systemFont(ofSize: 14) - placeholderLabel.textColor = .placeholderTextColor - placeholderLabel.lineBreakMode = .byTruncatingTail - placeholderLabel.maximumNumberOfLines = 1 - placeholderLabel.isEditable = false - placeholderLabel.isSelectable = false - placeholderLabel.backgroundColor = .clear - placeholderLabel.drawsBackground = false - - addSubview(scrollView) - addSubview(placeholderLabel) - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - scrollView.topAnchor.constraint(equalTo: topAnchor), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor), - placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), - placeholderLabel.topAnchor.constraint(equalTo: topAnchor), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @MainActor - func updatePlaceholderVisibility() { - let textView = scrollView.documentView as? NSTextView - placeholderLabel.isHidden = placeholderLabel.stringValue.isEmpty - || textView?.string.isEmpty == false - || textView?.hasMarkedText() == true - } -} - -private final class InkletNativeTextView: NSTextView { - var onInputStateChange: (() -> Void)? - var onEscapeKeyDown: (() -> Void)? - - override func keyDown(with event: NSEvent) { - guard event.keyCode == 53, !hasMarkedText() else { - super.keyDown(with: event) - return - } - - onEscapeKeyDown?() - } - - override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) - onInputStateChange?() - } - - override func unmarkText() { - super.unmarkText() - onInputStateChange?() - } - - override func insertText(_ insertString: Any, replacementRange: NSRange) { - super.insertText(insertString, replacementRange: replacementRange) - onInputStateChange?() - } -} - -private struct InkletTextView: NSViewRepresentable { - @Binding var text: String - var placeholder: String? - var isEditable: Bool - var onSubmit: (() -> Void)? - var onInsertOriginal: (() -> Void)? - var onEscape: (() -> Void)? - var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? - - func makeCoordinator() -> Coordinator { - Coordinator( - text: $text, - onSubmit: onSubmit, - onInsertOriginal: onInsertOriginal, - onEscape: onEscape, - onTextViewAttachment: onTextViewAttachment - ) - } - - func makeNSView(context: Context) -> InkletTextContainerView { - let container = InkletTextContainerView() - let scrollView = container.scrollView - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = false - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.contentInsets = NSEdgeInsetsZero - scrollView.automaticallyAdjustsContentInsets = false - scrollView.horizontalScrollElasticity = .none - - let textView = InkletNativeTextView() - textView.string = text - textView.delegate = context.coordinator - textView.onInputStateChange = { [weak coordinator = context.coordinator, weak textView, weak container] in - guard let textView else { - return - } - coordinator?.syncText(from: textView) - container?.updatePlaceholderVisibility() - } - textView.onEscapeKeyDown = { [weak coordinator = context.coordinator] in - coordinator?.onEscape?() - } - textView.isEditable = isEditable - textView.isSelectable = true - textView.isRichText = false - textView.importsGraphics = false - textView.allowsUndo = true - textView.usesFindBar = false - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.font = .systemFont(ofSize: 14) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - scrollView.documentView = textView - context.coordinator.textView = textView - onTextViewAttachment?(.attach(textView)) - container.placeholderLabel.stringValue = placeholder ?? "" - container.updatePlaceholderVisibility() - return container - } - - func updateNSView(_ container: InkletTextContainerView, context: Context) { - let scrollView = container.scrollView - guard let textView = scrollView.documentView as? NSTextView else { - return - } - - context.coordinator.text = $text - context.coordinator.onSubmit = onSubmit - context.coordinator.onInsertOriginal = onInsertOriginal - context.coordinator.onEscape = onEscape - context.coordinator.onTextViewAttachment = onTextViewAttachment - context.coordinator.textView = textView - onTextViewAttachment?(.attach(textView)) - - textView.isEditable = isEditable - textView.font = .systemFont(ofSize: 14) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - container.placeholderLabel.stringValue = placeholder ?? "" - - if textView.isEditable, !textView.hasMarkedText(), textView.string != text { - textView.string = text - } - container.updatePlaceholderVisibility() - } - - static func dismantleNSView( - _ container: InkletTextContainerView, - coordinator: Coordinator - ) { - if let textView = container.scrollView.documentView as? NSTextView { - coordinator.onTextViewAttachment?(.detach(textView)) - } - coordinator.textView = nil - } - - final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { - var text: Binding - var onSubmit: (() -> Void)? - var onInsertOriginal: (() -> Void)? - var onEscape: (() -> Void)? - var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? - weak var textView: NSTextView? - - init( - text: Binding, - onSubmit: (() -> Void)?, - onInsertOriginal: (() -> Void)?, - onEscape: (() -> Void)?, - onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? - ) { - self.text = text - self.onSubmit = onSubmit - self.onInsertOriginal = onInsertOriginal - self.onEscape = onEscape - self.onTextViewAttachment = onTextViewAttachment - super.init() - } - - @MainActor - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - syncText(from: textView) - textView.enclosingScrollView?.superview - .flatMap { $0 as? InkletTextContainerView }? - .updatePlaceholderVisibility() - } - - @MainActor - func textViewDidChangeSelection(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - textView.enclosingScrollView?.superview - .flatMap { $0 as? InkletTextContainerView }? - .updatePlaceholderVisibility() - } - - @MainActor - func syncText(from textView: NSTextView) { - text.wrappedValue = textView.string - } - - @MainActor - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - guard !textView.hasMarkedText() else { - return false - } - - if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - onEscape?() - return true - } - - guard commandSelector == #selector(NSResponder.insertNewline(_:)) - || commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) - else { - return false - } - - let modifiers = NSApp.currentEvent?.modifierFlags.intersection(.deviceIndependentFlagsMask) ?? [] - if modifiers.contains(.command) { - onInsertOriginal?() - return true - } - - if modifiers.contains(.shift) || modifiers.contains(.option) { - return false - } - - onSubmit?() - return true - } - } -} - -private extension NSView { - var descendantTextViews: [NSTextView] { - var textViews: [NSTextView] = [] - if let textView = self as? NSTextView { - textViews.append(textView) - } - - for subview in subviews { - textViews.append(contentsOf: subview.descendantTextViews) - } - - return textViews - } -} - -private struct PopoverKeyEventHandler: NSViewRepresentable { - let route: WritingPopoverSessionState.Route - let onSubmit: () -> Void - let onInsertOriginal: () -> Void - let onEscape: () -> Void - let onCycleMode: (Int) -> Void - let onMoveModeHighlight: (Int) -> Void - let onCommitMode: () -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - context.coordinator.attach(to: view) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.route = route - context.coordinator.onSubmit = onSubmit - context.coordinator.onInsertOriginal = onInsertOriginal - context.coordinator.onEscape = onEscape - context.coordinator.onCycleMode = onCycleMode - context.coordinator.onMoveModeHighlight = onMoveModeHighlight - context.coordinator.onCommitMode = onCommitMode - context.coordinator.attach(to: nsView) - } - - static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { - coordinator.detach() - } - - func makeCoordinator() -> Coordinator { - Coordinator( - route: route, - onSubmit: onSubmit, - onInsertOriginal: onInsertOriginal, - onEscape: onEscape, - onCycleMode: onCycleMode, - onMoveModeHighlight: onMoveModeHighlight, - onCommitMode: onCommitMode - ) - } - - @MainActor - final class Coordinator { - var route: WritingPopoverSessionState.Route - var onSubmit: () -> Void - var onInsertOriginal: () -> Void - var onEscape: () -> Void - var onCycleMode: (Int) -> Void - var onMoveModeHighlight: (Int) -> Void - var onCommitMode: () -> Void - private weak var view: NSView? - private var monitor: Any? - - init( - route: WritingPopoverSessionState.Route, - onSubmit: @escaping () -> Void, - onInsertOriginal: @escaping () -> Void, - onEscape: @escaping () -> Void, - onCycleMode: @escaping (Int) -> Void, - onMoveModeHighlight: @escaping (Int) -> Void, - onCommitMode: @escaping () -> Void - ) { - self.route = route - self.onSubmit = onSubmit - self.onInsertOriginal = onInsertOriginal - self.onEscape = onEscape - self.onCycleMode = onCycleMode - self.onMoveModeHighlight = onMoveModeHighlight - self.onCommitMode = onCommitMode - } - - func detach() { - if let monitor { - NSEvent.removeMonitor(monitor) - self.monitor = nil - } - } - - func attach(to view: NSView) { - self.view = view - guard monitor == nil else { return } - - monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.handle(event) ?? event - } - } - - private func handle(_ event: NSEvent) -> NSEvent? { - guard let window = view?.window, - event.window === window, - window.isKeyWindow - else { - return event - } - - let action = WritingPopoverKeyboardPolicy.action( - route: route, - keyCode: event.keyCode, - modifiers: keyboardModifiers(from: event.modifierFlags), - isComposingText: isComposingText - ) - - switch action { - case .passThrough: - return event - case .consume: - return nil - case .escape: - onEscape() - return nil - case .moveHighlight(let offset): - onMoveModeHighlight(offset) - return nil - case .commitMode: - onCommitMode() - return nil - case .cycleMode(let direction): - onCycleMode(direction) - return nil - case .submit: - onSubmit() - return nil - case .insertOriginal: - onInsertOriginal() - return nil - } - } - - private var isComposingText: Bool { - guard let responder = view?.window?.firstResponder as? NSTextInputClient else { - return false - } - - return responder.hasMarkedText() - } - - private func keyboardModifiers( - from modifiers: NSEvent.ModifierFlags - ) -> WritingPopoverKeyboardModifiers { - var keyboardModifiers: WritingPopoverKeyboardModifiers = [] - if modifiers.contains(.command) { - keyboardModifiers.insert(.command) - } - if modifiers.contains(.shift) { - keyboardModifiers.insert(.shift) - } - if modifiers.contains(.option) { - keyboardModifiers.insert(.option) - } - if modifiers.contains(.control) { - keyboardModifiers.insert(.control) - } - return keyboardModifiers - } - } -} diff --git a/Sources/InkletApp/InkletPopoverViewModel.swift b/Sources/InkletApp/InkletPopoverViewModel.swift new file mode 100644 index 0000000..2f6e00e --- /dev/null +++ b/Sources/InkletApp/InkletPopoverViewModel.swift @@ -0,0 +1,844 @@ +import Combine +import AppKit +import SwiftUI +import InkletCore + +@MainActor +final class InkletPopoverViewModel: ObservableObject { + @Published var sourceText = "" + @Published var resultText = "" + @Published var errorMessage: String? + @Published var isTransforming = false + @Published var isInserting = false + @Published private(set) var modePickerState: WritingModePickerState + @Published private(set) var popoverSession: WritingPopoverSessionState + @Published private(set) var modeSearchFocusRevision = 0 + @Published private(set) var modes: [PromptMode] + @Published var preferredPopoverHeight: CGFloat = 168 + @Published var appearance: AppAppearance + @Published var voiceShortcutHint: VoiceInputConfig.Shortcut? + @Published private(set) var dictationPhase: WritingDictationCoordinator.Phase = .idle + + var onHidePopover: (() -> Void)? + var onFocusPopover: (() -> Void)? + var onFocusSourceInput: ((FocusRequestGeneration.Request) -> Void)? + var onOpenSettings: (() -> Void)? + var onCancelDictation: (() -> Void)? + + var route: WritingPopoverSessionState.Route { + popoverSession.route + } + + var selectedModeID: String { + popoverSession.selectedModeID + } + + var isResultStale: Bool { + popoverSession.isResultStale && !resultText.isEmpty + } + + var resultModeDisplayName: String? { + guard let resultModeID = popoverSession.resultModeID else { + return nil + } + + return modes.first(where: { $0.id == resultModeID })?.localizedName ?? resultModeID + } + + var currentProviderName: String { + config.resolvedProviderPreset.name + } + + var currentModelName: String { + config.model + } + + var currentVoiceInputConfig: VoiceInputConfig { + config.voiceInput + } + + var shouldShowDictationStatus: Bool { + voiceShortcutHint != nil || dictationPhase.isActive + } + + var dictationStatusText: String { + switch dictationPhase { + case .connecting: + L10n.text("dictation.status.connecting") + case .listening: + L10n.text("dictation.status.listening") + case .recordingForFallback: + L10n.text("dictation.status.recordingFallback") + case .finalizing: + L10n.text("dictation.status.finalizing") + case .recovering: + L10n.text("dictation.status.recovering") + case .idle, .complete, .failed: + L10n.format("dictation.hint.hold", voiceShortcutHint?.localizedName ?? "") + } + } + + var dictationStatusAccessibilityLabel: String { + switch dictationPhase { + case .listening: + L10n.text("dictation.accessibility.listening") + case .recordingForFallback: + L10n.text("dictation.accessibility.recordingFallback") + case .connecting, .finalizing, .recovering: + dictationStatusText + case .idle, .complete, .failed: + L10n.text("dictation.accessibility.ready") + } + } + + private struct SourceDictationPresentationSnapshot { + let sourceText: String + let resultText: String + let errorMessage: String? + let popoverSession: WritingPopoverSessionState + let stateMachineState: PopoverStateMachine.State + let draftSourceText: String + let hasTransformedInSession: Bool + } + + private var stateMachine: PopoverStateMachine + private let configStore: UserDefaultsConfigStore + private let apiKeyStore: LocalAPIKeyStore + private let insertionService: InsertionService + private let transformationServiceFactory: (any LLMProvider) -> TransformationService + private let historyStore: any HistoryStore + private let writingModePreferenceStore: WritingModePreferenceStore + private var config: AppConfig + private var previousApplication: NSRunningApplication? + private var transformationTask: Task? + private var insertionTask: Task? + private var sessionID = 0 + private var draftSourceText = "" + private var hasTransformedInSession = false + private var sourceFocusGeneration = FocusRequestGeneration() + private var sourceDictationPresentationSnapshot: SourceDictationPresentationSnapshot? + private var isRestoringSourceDictationPresentation = false + private var languageCancellable: AnyCancellable? + private var dictationErrorMessage: String? + + init( + stateMachine: PopoverStateMachine = PopoverStateMachine(), + configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), + apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), + transformationServiceFactory: @escaping (any LLMProvider) -> TransformationService = { TransformationService(provider: $0) }, + insertionService: InsertionService = InsertionService(), + historyStore: any HistoryStore = JSONLHistoryStore(), + writingModePreferenceStore: WritingModePreferenceStore = WritingModePreferenceStore() + ) { + self.stateMachine = stateMachine + self.configStore = configStore + self.apiKeyStore = apiKeyStore + self.transformationServiceFactory = transformationServiceFactory + self.insertionService = insertionService + self.historyStore = historyStore + self.writingModePreferenceStore = writingModePreferenceStore + + let loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() + let selectedModeID = Self.resolvedModeID( + preferredModeID: writingModePreferenceStore.loadLastModeID(), + config: loadedConfig + ) + let visibleModes = Self.resolvedVisibleModes(from: loadedConfig) + self.config = loadedConfig + self.modes = visibleModes + self.modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: visibleModes), + preferredModeID: selectedModeID + ) + self.popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) + self.appearance = loadedConfig.appearance + self.voiceShortcutHint = nil + languageCancellable = NotificationCenter.default.publisher(for: .inkletLanguageDidChange) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.refreshLocalizedContent() + } + } + + private func refreshLocalizedContent() { + modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: modes), + preferredModeID: modePickerState.highlightedModeID, + query: modePickerState.query + ) + if case .failed(let errorKey) = dictationPhase, errorMessage == dictationErrorMessage { + errorMessage = L10n.text(errorKey) + dictationErrorMessage = errorMessage + } + } + + func resetForOpen(previousApplication: NSRunningApplication?) { + invalidateSourceInputFocusRequests() + transformationTask?.cancel() + transformationTask = nil + insertionTask?.cancel() + insertionTask = nil + sessionID += 1 + self.previousApplication = previousApplication + stateMachine = PopoverStateMachine() + + config = (try? configStore.load()) ?? AppConfig.defaultConfig() + let selectedModeID = Self.resolvedModeID( + preferredModeID: writingModePreferenceStore.loadLastModeID(), + config: config + ) + modes = Self.resolvedVisibleModes(from: config) + popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) + modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: modes), + preferredModeID: selectedModeID + ) + modeSearchFocusRevision += 1 + appearance = config.appearance + refreshVoiceShortcutHint() + sourceText = draftSourceText + resultText = "" + errorMessage = nil + isTransforming = false + isInserting = false + hasTransformedInSession = false + sourceDictationPresentationSnapshot = nil + dictationPhase = .idle + preferredPopoverHeight = WritingModePickerView.preferredHeight( + resultCount: modePickerState.filteredItems.count + ) + + handle(actions: stateMachine.send(.open)) + if !sourceText.isEmpty { + _ = stateMachine.send(.sourceChanged(sourceText)) + } + } + + var isBusy: Bool { + isTransforming || isInserting || dictationPhase.isActive + } + + func cancelForMigrationMaintenance() { + sessionID += 1 + transformationTask?.cancel() + transformationTask = nil + insertionTask?.cancel() + insertionTask = nil + isTransforming = false + isInserting = false + } + + private func refreshVoiceShortcutHint() { + let shortcut = config.voiceInput.shortcut + voiceShortcutHint = shortcut == .disabled ? nil : shortcut + } + + func beginSourceDictationPresentation() -> Bool { + guard route == .editor, + !isBusy, + !isRestoringSourceDictationPresentation, + sourceDictationPresentationSnapshot == nil + else { + return false + } + + sourceDictationPresentationSnapshot = SourceDictationPresentationSnapshot( + sourceText: sourceText, + resultText: resultText, + errorMessage: errorMessage, + popoverSession: popoverSession, + stateMachineState: stateMachine.state, + draftSourceText: draftSourceText, + hasTransformedInSession: hasTransformedInSession + ) + errorMessage = nil + return true + } + + func synchronizeSourceTextDuringDictation(_ text: String) { + guard sourceDictationPresentationSnapshot != nil else { + return + } + sourceText = text + } + + func commitSourceDictationPresentation() { + guard sourceDictationPresentationSnapshot != nil else { + return + } + sourceDictationPresentationSnapshot = nil + resultText = "" + errorMessage = nil + mutatePopoverSession { $0.clearResult() } + draftSourceText = sourceText + hasTransformedInSession = false + stateMachine = PopoverStateMachine( + state: .editingSource(source: sourceText, errorMessage: nil) + ) + } + + func synchronizeSourceTextAfterDictation(_ text: String) { + sourceText = text + resultText = "" + errorMessage = nil + mutatePopoverSession { $0.clearResult() } + draftSourceText = text + hasTransformedInSession = false + stateMachine = PopoverStateMachine( + state: .editingSource(source: text, errorMessage: nil) + ) + } + + func restoreSourceDictationPresentation() { + guard let snapshot = sourceDictationPresentationSnapshot, + !isRestoringSourceDictationPresentation + else { + return + } + isRestoringSourceDictationPresentation = true + defer { + sourceDictationPresentationSnapshot = nil + isRestoringSourceDictationPresentation = false + } + sourceText = snapshot.sourceText + resultText = snapshot.resultText + errorMessage = snapshot.errorMessage + popoverSession = snapshot.popoverSession + stateMachine = PopoverStateMachine(state: snapshot.stateMachineState) + draftSourceText = snapshot.draftSourceText + hasTransformedInSession = snapshot.hasTransformedInSession + } + + func setDictationPhase(_ phase: WritingDictationCoordinator.Phase) { + dictationPhase = phase + if case .failed(let errorKey) = phase { + errorMessage = L10n.text(errorKey) + dictationErrorMessage = errorMessage + } + } + + func updateSourceText(_ text: String) { + guard !isBusy else { + return + } + + sourceText = text + if !resultText.isEmpty { + resultText = "" + mutatePopoverSession { $0.clearResult() } + } + + _ = stateMachine.send(.sourceChanged(text)) + } + + func updateResultText(_ text: String) { + guard !isBusy else { + return + } + + resultText = text + guard !text.isEmpty else { + mutatePopoverSession { $0.clearResult() } + stateMachine = PopoverStateMachine( + state: .editingSource(source: sourceText, errorMessage: nil) + ) + return + } + + _ = stateMachine.send(.resultChanged(text)) + } + + func updateModeSearchQuery(_ query: String) { + guard !isBusy else { + return + } + mutateModePickerState { $0.setQuery(query) } + } + + func moveModeHighlight(by offset: Int) { + guard !isBusy else { + return + } + mutateModePickerState { $0.moveHighlight(by: offset) } + } + + func highlightMode(modeID: String) { + guard !isBusy else { + return + } + mutateModePickerState { _ = $0.highlight(modeID: modeID) } + } + + func commitHighlightedMode() { + guard let highlightedModeID = modePickerState.highlightedModeID else { + return + } + + commitMode(modeID: highlightedModeID) + } + + func commitMode(modeID: String) { + guard !isBusy, + modes.contains(where: { $0.id == modeID }) + else { + return + } + + mutatePopoverSession { $0.enterEditor(modeID: modeID) } + writingModePreferenceStore.saveLastModeID(modeID) + requestSourceInputFocus() + } + + func returnToModePicker() { + guard !isBusy else { + return + } + + invalidateSourceInputFocusRequests() + mutatePopoverSession { $0.showModePicker() } + modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: modes), + preferredModeID: selectedModeID + ) + modeSearchFocusRevision += 1 + } + + func cyclePromptMode(direction: Int) { + guard !modes.isEmpty else { + return + } + + let currentIndex = modes.firstIndex { $0.id == selectedModeID } ?? 0 + let nextIndex = (currentIndex + direction + modes.count) % modes.count + commitMode(modeID: modes[nextIndex].id) + } + + func submit() { + guard !isBusy else { + return + } + + errorMessage = nil + if !resultText.isEmpty, isResultStale { + removeSingleTrailingNewlineFromSource() + _ = stateMachine.send(.sourceChanged(sourceText)) + handle(actions: stateMachine.send(.submit)) + return + } + + if !resultText.isEmpty { + removeSingleTrailingNewlineFromResult() + let currentResult = resultText + _ = stateMachine.send(.resultChanged(currentResult)) + let actions = stateMachine.send(.submit) + if actions.isEmpty { + insert( + text: currentResult, + fallbackState: .previewingResult(source: sourceText, result: currentResult) + ) + } else { + handle(actions: actions) + } + return + } + + removeSingleTrailingNewlineFromSource() + _ = stateMachine.send(.sourceChanged(sourceText)) + handle(actions: stateMachine.send(.submit)) + } + + private func removeSingleTrailingNewlineFromSource() { + if sourceText.hasSuffix("\r\n") { + sourceText.removeLast(2) + } else if sourceText.hasSuffix("\n") || sourceText.hasSuffix("\r") { + sourceText.removeLast() + } + } + + private func removeSingleTrailingNewlineFromResult() { + if resultText.hasSuffix("\r\n") { + resultText.removeLast(2) + } else if resultText.hasSuffix("\n") || resultText.hasSuffix("\r") { + resultText.removeLast() + } + } + + func insertOriginal() { + guard !isBusy else { + return + } + + let trimmedSource = sourceText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSource.isEmpty else { + errorMessage = L10n.text("popover.error.emptyOriginal") + return + } + + errorMessage = nil + let fallbackState: PopoverStateMachine.State = resultText.isEmpty + ? .editingSource(source: sourceText, errorMessage: nil) + : .previewingResult(source: sourceText, result: resultText) + + if resultText.isEmpty { + _ = stateMachine.send(.sourceChanged(sourceText)) + let actions = stateMachine.send(.insertOriginal) + if !actions.isEmpty { + handle(actions: actions) + return + } + } + + insert(text: sourceText, fallbackState: fallbackState) + } + + func escape() { + if dictationPhase.isActive { + onCancelDictation?() + return + } + + if isTransforming { + cancelTransformationAndStayInEditor() + return + } + + guard !isInserting else { + return + } + + if route == .modePicker { + draftSourceText = hasTransformedInSession ? "" : sourceText + handle(actions: stateMachine.send(.close)) + return + } + + if !resultText.isEmpty { + resultText = "" + errorMessage = nil + mutatePopoverSession { $0.clearResult() } + stateMachine = PopoverStateMachine( + state: .editingSource(source: sourceText, errorMessage: nil) + ) + requestSourceInputFocus() + return + } + + returnToModePicker() + } + + func openSettings() { + guard !isBusy else { + return + } + onHidePopover?() + onOpenSettings?() + } + + func isCurrentSourceFocusRequest(_ request: FocusRequestGeneration.Request) -> Bool { + sourceFocusGeneration.isCurrent(request) + } + + private func requestSourceInputFocus() { + guard route == .editor else { + return + } + + let request = sourceFocusGeneration.issue() + onFocusSourceInput?(request) + } + + private func invalidateSourceInputFocusRequests() { + sourceFocusGeneration.invalidate() + } + + private func handle(actions: [PopoverStateMachine.Action]) { + for action in actions { + switch action { + case .showPopover: + onFocusPopover?() + case .hidePopover: + onHidePopover?() + case .focusSourceInput: + requestSourceInputFocus() + case .startTransformation(let source): + startTransformation(source: source) + case .showResult(let result): + hasTransformedInSession = true + draftSourceText = "" + resultText = result + case .showError(let message): + errorMessage = localizedStateMachineMessage(message) + case .insertText(let text): + let fallbackState: PopoverStateMachine.State + if !resultText.isEmpty { + fallbackState = .previewingResult(source: sourceText, result: resultText) + } else { + fallbackState = .editingSource(source: sourceText, errorMessage: nil) + } + insert(text: text, fallbackState: fallbackState) + } + } + } + + private func startTransformation(source: String) { + transformationTask?.cancel() + let preservesExistingResult = isResultStale + if !preservesExistingResult { + resultText = "" + mutatePopoverSession { $0.clearResult() } + } + errorMessage = nil + isTransforming = true + + let resolvedModeID = Self.resolvedModeID( + preferredModeID: selectedModeID, + visibleModes: modes + ) + let mode = PromptModeStore(modes: modes).resolve( + modeID: resolvedModeID, + sourceText: source + ) + let transformationModeID = mode.id + let model = config.model + let timeoutSeconds = config.timeoutSeconds + let providerPreset = config.resolvedProviderPreset + let providerID = config.providerID + let apiKeyStore = self.apiKeyStore + let provider = OpenAIProvider( + apiKeyProvider: { + try LocalAPIKeyProvider( + apiKeyStore: apiKeyStore, + providerID: providerID, + providerName: providerPreset.name + ).loadAPIKey() + }, + endpoint: providerPreset.endpoint + ) + let transformationService = transformationServiceFactory(provider) + + transformationTask = Task { [weak self] in + guard let self else { return } + do { + let result = try await transformationService.transform( + sourceText: source, + mode: mode, + model: model, + timeoutSeconds: timeoutSeconds + ) + guard !Task.isCancelled else { return } + try? historyStore.append(HistoryItem( + source: .write, + inputText: source, + outputText: result.outputText, + modeName: mode.localizedName, + targetLanguageName: nil, + model: model, + metadata: [ + "modeID": mode.id, + "providerID": providerID + ] + )) + transformationTask = nil + isTransforming = false + mutatePopoverSession { $0.recordResult(modeID: transformationModeID) } + handle(actions: stateMachine.send(.transformationSucceeded(result: result.outputText))) + } catch { + guard !Task.isCancelled else { return } + transformationTask = nil + isTransforming = false + if !preservesExistingResult { + resultText = "" + mutatePopoverSession { $0.clearResult() } + } + handle(actions: stateMachine.send(.transformationFailed(message: error.userFacingMessage))) + if preservesExistingResult { + synchronizeStateMachineWithVisibleContent() + } + } + } + } + + private func cancelTransformationAndStayInEditor() { + transformationTask?.cancel() + transformationTask = nil + isTransforming = false + errorMessage = nil + mutatePopoverSession { $0.enterEditor(modeID: selectedModeID) } + synchronizeStateMachineWithVisibleContent() + } + + private func synchronizeStateMachineWithVisibleContent() { + let visibleState: PopoverStateMachine.State = resultText.isEmpty + ? .editingSource(source: sourceText, errorMessage: nil) + : .previewingResult(source: sourceText, result: resultText) + stateMachine = PopoverStateMachine(state: visibleState) + } + + private func mutateModePickerState(_ mutation: (inout WritingModePickerState) -> Void) { + var updatedState = modePickerState + mutation(&updatedState) + modePickerState = updatedState + } + + private func mutatePopoverSession(_ mutation: (inout WritingPopoverSessionState) -> Void) { + var updatedSession = popoverSession + mutation(&updatedSession) + popoverSession = updatedSession + } + + private static func resolvedModeID( + preferredModeID: String?, + config: AppConfig + ) -> String { + guard let preferredModeID else { + return config.defaultVisibleModeID + } + + return config.visibleModeID(preferredModeID: preferredModeID) + } + + private static func resolvedVisibleModes(from config: AppConfig) -> [PromptMode] { + let visibleModes = config.visiblePromptModes + if !visibleModes.isEmpty { + return visibleModes + } + + return AppConfig.defaultConfig().visiblePromptModes + } + + private static func resolvedModeID( + preferredModeID: String?, + visibleModes: [PromptMode] + ) -> String { + if let preferredModeID, + visibleModes.contains(where: { $0.id == preferredModeID }) { + return preferredModeID + } + + return visibleModes.first?.id ?? PromptMode.translateToEnglishID + } + + private static func modePickerItems(for modes: [PromptMode]) -> [WritingModePickerItem] { + modes.map { WritingModePickerItem(id: $0.id, title: $0.localizedName) } + } + + private func localizedStateMachineMessage(_ message: String) -> String { + switch message { + case "请输入要转换的文本": + L10n.text("error.emptySource") + default: + message + } + } + + private func insert(text: String, fallbackState: PopoverStateMachine.State) { + guard let previousApplication else { + stateMachine = PopoverStateMachine(state: fallbackState) + errorMessage = L10n.text("popover.error.missingTarget") + return + } + + errorMessage = nil + onHidePopover?() + isInserting = true + + let insertionSessionID = sessionID + insertionTask?.cancel() + insertionTask = Task { [weak self] in + guard let self else { return } + do { + try await insertionService.insert(text: text, into: previousApplication) + guard sessionID == insertionSessionID else { + return + } + isInserting = false + insertionTask = nil + draftSourceText = "" + sourceText = "" + resultText = "" + mutatePopoverSession { $0.clearResult() } + handle(actions: stateMachine.send(.insertionFinished)) + } catch { + guard sessionID == insertionSessionID else { + return + } + isInserting = false + insertionTask = nil + stateMachine = PopoverStateMachine(state: fallbackState) + errorMessage = error.userFacingMessage + onFocusPopover?() + } + } + } +} + +private struct LocalAPIKeyProvider: @unchecked Sendable { + let apiKeyStore: LocalAPIKeyStore + let providerID: String + let providerName: String + + func loadAPIKey() throws -> String { + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerName)) + } + return apiKey + } +} + +private extension Error { + var userFacingMessage: String { + if let transformationError = self as? TransformationError { + switch transformationError { + case .emptySource: + return L10n.text("error.emptySource") + case .emptyResponse: + return L10n.text("error.emptyResponse") + case .timeout: + return L10n.text("error.timeout") + case .networkConnectionLost: + return L10n.text("error.networkConnectionLost") + case .provider(let message): + return localizedProviderMessage(message) + } + } + + if let localizedError = self as? LocalizedError, + let description = localizedError.errorDescription { + return description + } + + if let insertionError = self as? InsertionError { + switch insertionError { + case .accessibilityPermissionMissing: + return L10n.text("insertion.error.accessibility") + case .activationFailed: + return L10n.text("insertion.error.activation") + case .cannotCreatePasteEvent: + return L10n.text("insertion.error.pasteEvent") + case .clipboardRestoreFailed: + return L10n.text("insertion.error.clipboardRestore") + } + } + + return String(describing: self) + } + + private func localizedProviderMessage(_ message: String) -> String { + let providerName = LLMProviderPreset.openAI.name + let prefix = "\(providerName) 请求失败:" + guard message.hasPrefix(prefix) else { + return message + } + + let detail = String(message.dropFirst(prefix.count)) + if detail == "URL 无效" { + return L10n.format("error.provider.urlInvalid", providerName) + } + if detail == "HTTP unknown" { + return L10n.format("error.provider.httpUnknown", providerName) + } + return L10n.format("error.provider.prefix", providerName, detail) + } +} diff --git a/Sources/InkletApp/InkletTextView.swift b/Sources/InkletApp/InkletTextView.swift new file mode 100644 index 0000000..d599c05 --- /dev/null +++ b/Sources/InkletApp/InkletTextView.swift @@ -0,0 +1,446 @@ +import AppKit +import SwiftUI +import InkletCore + +final class InkletTextContainerView: NSView { + let scrollView = NSScrollView() + let placeholderLabel = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.font = .systemFont(ofSize: 14) + placeholderLabel.textColor = .placeholderTextColor + placeholderLabel.lineBreakMode = .byTruncatingTail + placeholderLabel.maximumNumberOfLines = 1 + placeholderLabel.isEditable = false + placeholderLabel.isSelectable = false + placeholderLabel.backgroundColor = .clear + placeholderLabel.drawsBackground = false + + addSubview(scrollView) + addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + placeholderLabel.topAnchor.constraint(equalTo: topAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @MainActor + func updatePlaceholderVisibility() { + let textView = scrollView.documentView as? NSTextView + placeholderLabel.isHidden = placeholderLabel.stringValue.isEmpty + || textView?.string.isEmpty == false + || textView?.hasMarkedText() == true + } +} + +private final class InkletNativeTextView: NSTextView { + var onInputStateChange: (() -> Void)? + var onEscapeKeyDown: (() -> Void)? + + override func keyDown(with event: NSEvent) { + guard event.keyCode == 53, !hasMarkedText() else { + super.keyDown(with: event) + return + } + + onEscapeKeyDown?() + } + + override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) + onInputStateChange?() + } + + override func unmarkText() { + super.unmarkText() + onInputStateChange?() + } + + override func insertText(_ insertString: Any, replacementRange: NSRange) { + super.insertText(insertString, replacementRange: replacementRange) + onInputStateChange?() + } +} + +struct InkletTextView: NSViewRepresentable { + @Binding var text: String + var placeholder: String? + var isEditable: Bool + var onSubmit: (() -> Void)? + var onInsertOriginal: (() -> Void)? + var onEscape: (() -> Void)? + var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator( + text: $text, + onSubmit: onSubmit, + onInsertOriginal: onInsertOriginal, + onEscape: onEscape, + onTextViewAttachment: onTextViewAttachment + ) + } + + func makeNSView(context: Context) -> InkletTextContainerView { + let container = InkletTextContainerView() + let scrollView = container.scrollView + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.contentInsets = NSEdgeInsetsZero + scrollView.automaticallyAdjustsContentInsets = false + scrollView.horizontalScrollElasticity = .none + + let textView = InkletNativeTextView() + textView.string = text + textView.delegate = context.coordinator + textView.onInputStateChange = { [weak coordinator = context.coordinator, weak textView, weak container] in + guard let textView else { + return + } + coordinator?.syncText(from: textView) + container?.updatePlaceholderVisibility() + } + textView.onEscapeKeyDown = { [weak coordinator = context.coordinator] in + coordinator?.onEscape?() + } + textView.isEditable = isEditable + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.allowsUndo = true + textView.usesFindBar = false + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.font = .systemFont(ofSize: 14) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + scrollView.documentView = textView + context.coordinator.textView = textView + onTextViewAttachment?(.attach(textView)) + container.placeholderLabel.stringValue = placeholder ?? "" + container.updatePlaceholderVisibility() + return container + } + + func updateNSView(_ container: InkletTextContainerView, context: Context) { + let scrollView = container.scrollView + guard let textView = scrollView.documentView as? NSTextView else { + return + } + + context.coordinator.text = $text + context.coordinator.onSubmit = onSubmit + context.coordinator.onInsertOriginal = onInsertOriginal + context.coordinator.onEscape = onEscape + context.coordinator.onTextViewAttachment = onTextViewAttachment + context.coordinator.textView = textView + onTextViewAttachment?(.attach(textView)) + + textView.isEditable = isEditable + textView.font = .systemFont(ofSize: 14) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + container.placeholderLabel.stringValue = placeholder ?? "" + + if textView.isEditable, !textView.hasMarkedText(), textView.string != text { + textView.string = text + } + container.updatePlaceholderVisibility() + } + + static func dismantleNSView( + _ container: InkletTextContainerView, + coordinator: Coordinator + ) { + if let textView = container.scrollView.documentView as? NSTextView { + coordinator.onTextViewAttachment?(.detach(textView)) + } + coordinator.textView = nil + } + + final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { + var text: Binding + var onSubmit: (() -> Void)? + var onInsertOriginal: (() -> Void)? + var onEscape: (() -> Void)? + var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? + weak var textView: NSTextView? + + init( + text: Binding, + onSubmit: (() -> Void)?, + onInsertOriginal: (() -> Void)?, + onEscape: (() -> Void)?, + onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? + ) { + self.text = text + self.onSubmit = onSubmit + self.onInsertOriginal = onInsertOriginal + self.onEscape = onEscape + self.onTextViewAttachment = onTextViewAttachment + super.init() + } + + @MainActor + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { + return + } + + syncText(from: textView) + textView.enclosingScrollView?.superview + .flatMap { $0 as? InkletTextContainerView }? + .updatePlaceholderVisibility() + } + + @MainActor + func textViewDidChangeSelection(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { + return + } + + textView.enclosingScrollView?.superview + .flatMap { $0 as? InkletTextContainerView }? + .updatePlaceholderVisibility() + } + + @MainActor + func syncText(from textView: NSTextView) { + text.wrappedValue = textView.string + } + + @MainActor + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard !textView.hasMarkedText() else { + return false + } + + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + onEscape?() + return true + } + + guard commandSelector == #selector(NSResponder.insertNewline(_:)) + || commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) + else { + return false + } + + let modifiers = NSApp.currentEvent?.modifierFlags.intersection(.deviceIndependentFlagsMask) ?? [] + if modifiers.contains(.command) { + onInsertOriginal?() + return true + } + + if modifiers.contains(.shift) || modifiers.contains(.option) { + return false + } + + onSubmit?() + return true + } + } +} + +private extension NSView { + var descendantTextViews: [NSTextView] { + var textViews: [NSTextView] = [] + if let textView = self as? NSTextView { + textViews.append(textView) + } + + for subview in subviews { + textViews.append(contentsOf: subview.descendantTextViews) + } + + return textViews + } +} + +struct PopoverKeyEventHandler: NSViewRepresentable { + let route: WritingPopoverSessionState.Route + let onSubmit: () -> Void + let onInsertOriginal: () -> Void + let onEscape: () -> Void + let onCycleMode: (Int) -> Void + let onMoveModeHighlight: (Int) -> Void + let onCommitMode: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + context.coordinator.attach(to: view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.route = route + context.coordinator.onSubmit = onSubmit + context.coordinator.onInsertOriginal = onInsertOriginal + context.coordinator.onEscape = onEscape + context.coordinator.onCycleMode = onCycleMode + context.coordinator.onMoveModeHighlight = onMoveModeHighlight + context.coordinator.onCommitMode = onCommitMode + context.coordinator.attach(to: nsView) + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.detach() + } + + func makeCoordinator() -> Coordinator { + Coordinator( + route: route, + onSubmit: onSubmit, + onInsertOriginal: onInsertOriginal, + onEscape: onEscape, + onCycleMode: onCycleMode, + onMoveModeHighlight: onMoveModeHighlight, + onCommitMode: onCommitMode + ) + } + + @MainActor + final class Coordinator { + var route: WritingPopoverSessionState.Route + var onSubmit: () -> Void + var onInsertOriginal: () -> Void + var onEscape: () -> Void + var onCycleMode: (Int) -> Void + var onMoveModeHighlight: (Int) -> Void + var onCommitMode: () -> Void + private weak var view: NSView? + private var monitor: Any? + + init( + route: WritingPopoverSessionState.Route, + onSubmit: @escaping () -> Void, + onInsertOriginal: @escaping () -> Void, + onEscape: @escaping () -> Void, + onCycleMode: @escaping (Int) -> Void, + onMoveModeHighlight: @escaping (Int) -> Void, + onCommitMode: @escaping () -> Void + ) { + self.route = route + self.onSubmit = onSubmit + self.onInsertOriginal = onInsertOriginal + self.onEscape = onEscape + self.onCycleMode = onCycleMode + self.onMoveModeHighlight = onMoveModeHighlight + self.onCommitMode = onCommitMode + } + + func detach() { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } + + func attach(to view: NSView) { + self.view = view + guard monitor == nil else { return } + + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handle(event) ?? event + } + } + + private func handle(_ event: NSEvent) -> NSEvent? { + guard let window = view?.window, + event.window === window, + window.isKeyWindow + else { + return event + } + + let action = WritingPopoverKeyboardPolicy.action( + route: route, + keyCode: event.keyCode, + modifiers: keyboardModifiers(from: event.modifierFlags), + isComposingText: isComposingText + ) + + switch action { + case .passThrough: + return event + case .consume: + return nil + case .escape: + onEscape() + return nil + case .moveHighlight(let offset): + onMoveModeHighlight(offset) + return nil + case .commitMode: + onCommitMode() + return nil + case .cycleMode(let direction): + onCycleMode(direction) + return nil + case .submit: + onSubmit() + return nil + case .insertOriginal: + onInsertOriginal() + return nil + } + } + + private var isComposingText: Bool { + guard let responder = view?.window?.firstResponder as? NSTextInputClient else { + return false + } + + return responder.hasMarkedText() + } + + private func keyboardModifiers( + from modifiers: NSEvent.ModifierFlags + ) -> WritingPopoverKeyboardModifiers { + var keyboardModifiers: WritingPopoverKeyboardModifiers = [] + if modifiers.contains(.command) { + keyboardModifiers.insert(.command) + } + if modifiers.contains(.shift) { + keyboardModifiers.insert(.shift) + } + if modifiers.contains(.option) { + keyboardModifiers.insert(.option) + } + if modifiers.contains(.control) { + keyboardModifiers.insert(.control) + } + return keyboardModifiers + } + } +} diff --git a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift index 5e375e4..3f835c1 100644 --- a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift +++ b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift @@ -267,7 +267,9 @@ final class LegacyMigrationAppSourceTests: XCTestCase { } func testPopoverExposesBusyAndMaintenanceCancellation() throws { - let view = try appSource(named: "InkletPopoverView.swift") + let view = try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift"] + .map { try appSource(named: $0) } + .joined(separator: "\n") let controller = try appSource(named: "InkletPopoverWindowController.swift") XCTAssertTrue(view.contains("var isBusy: Bool")) diff --git a/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift b/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift index 9b329e1..7f97a18 100644 --- a/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift +++ b/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift @@ -187,7 +187,9 @@ final class WritingDictationLocalizationTests: XCTestCase { } func testDictationAccessibilityUndoAndErrorKeysAreWiredToProductionSurfaces() throws { - let popover = try sourceFile("Sources/InkletApp/InkletPopoverView.swift") + let popover = try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift", "InkletTextView.swift"] + .map { try sourceFile("Sources/InkletApp/\($0)") } + .joined(separator: "\n") let transaction = try sourceFile("Sources/InkletApp/DictationEditorTransaction.swift") let controller = try sourceFile("Sources/InkletApp/InkletPopoverWindowController.swift") let recorder = try sourceFile("Sources/InkletApp/AudioRecorder.swift") diff --git a/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift b/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift index 528763d..2031ce9 100644 --- a/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift +++ b/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift @@ -35,7 +35,7 @@ final class WritingModeLauncherSourceTests: XCTestCase { of: "private final class InkletNativeTextView" )) let representableStart = try XCTUnwrap(source.range( - of: "private struct InkletTextView", + of: "struct InkletTextView", range: nativeTextViewStart.upperBound.. String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/InkletPopoverView.swift") - return try String(contentsOf: sourceURL, encoding: .utf8) + return try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift", "InkletTextView.swift"].map { + try String( + contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent($0), + encoding: .utf8 + ) + }.joined(separator: "\n") } private func pickerSource() throws -> String { diff --git a/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift b/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift index d5eb20b..9a679a0 100644 --- a/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift +++ b/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift @@ -106,7 +106,7 @@ final class WritingPopoverKeyboardPolicyTests: XCTestCase { let source = try popoverSource() let nativeStart = try XCTUnwrap(source.range(of: "private final class InkletNativeTextView")) let representableStart = try XCTUnwrap(source.range( - of: "private struct InkletTextView", + of: "struct InkletTextView", range: nativeStart.upperBound.. String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - return try String( - contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp/InkletPopoverView.swift"), - encoding: .utf8 - ) + return try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift", "InkletTextView.swift"].map { + try String( + contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent($0), + encoding: .utf8 + ) + }.joined(separator: "\n") } private func windowControllerSource() throws -> String { From 93aae7dc3f7c1114b8cdfbacebf5e246e5e76e2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:09:01 +0000 Subject: [PATCH 6/6] Restore the non-OpenAI provider code Reverts "Remove unused non-OpenAI providers". Letting users choose a provider is planned, so the presets, providers, factory and custom endpoint setting stay. Call sites are restored in their new files. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DzisdHuvFVePscX66cn3Lw --- .../InkletApp/InkletPopoverViewModel.swift | 46 +- .../SelectionActionsController.swift | 19 +- Sources/InkletApp/SettingsViewModel.swift | 14 +- Sources/InkletCore/AnthropicProvider.swift | 128 ++++++ .../InkletCore/ChatCompletionProvider.swift | 111 +++++ Sources/InkletCore/ConfigStore.swift | 9 + Sources/InkletCore/GeminiProvider.swift | 130 ++++++ Sources/InkletCore/LLMProviderFactory.swift | 23 + Sources/InkletCore/LLMProviderPreset.swift | 206 +++++++++ Sources/InkletCore/ModelCatalogService.swift | 17 +- .../Resources/model-catalog-snapshot.json | 410 ++++++++++++++++++ Tests/InkletCoreTests/ConfigStoreTests.swift | 12 +- Tests/InkletCoreTests/LLMProviderTests.swift | 123 ++++++ .../ModelCatalogServiceTests.swift | 4 +- 14 files changed, 1212 insertions(+), 40 deletions(-) create mode 100644 Sources/InkletCore/AnthropicProvider.swift create mode 100644 Sources/InkletCore/ChatCompletionProvider.swift create mode 100644 Sources/InkletCore/GeminiProvider.swift create mode 100644 Sources/InkletCore/LLMProviderFactory.swift create mode 100644 Tests/InkletCoreTests/LLMProviderTests.swift diff --git a/Sources/InkletApp/InkletPopoverViewModel.swift b/Sources/InkletApp/InkletPopoverViewModel.swift index 2f6e00e..fc800c5 100644 --- a/Sources/InkletApp/InkletPopoverViewModel.swift +++ b/Sources/InkletApp/InkletPopoverViewModel.swift @@ -46,7 +46,7 @@ final class InkletPopoverViewModel: ObservableObject { } var currentProviderName: String { - config.resolvedProviderPreset.name + LLMProviderPreset.preset(id: config.providerID).name } var currentModelName: String { @@ -604,16 +604,13 @@ final class InkletPopoverViewModel: ObservableObject { let providerPreset = config.resolvedProviderPreset let providerID = config.providerID let apiKeyStore = self.apiKeyStore - let provider = OpenAIProvider( - apiKeyProvider: { - try LocalAPIKeyProvider( - apiKeyStore: apiKeyStore, - providerID: providerID, - providerName: providerPreset.name - ).loadAPIKey() - }, - endpoint: providerPreset.endpoint - ) + let provider = LLMProviderFactory.provider(for: providerPreset) { + try LocalAPIKeyProvider( + apiKeyStore: apiKeyStore, + providerID: providerID, + providerName: providerPreset.name + ).loadAPIKey() + } let transformationService = transformationServiceFactory(provider) transformationTask = Task { [weak self] in @@ -826,19 +823,22 @@ private extension Error { } private func localizedProviderMessage(_ message: String) -> String { - let providerName = LLMProviderPreset.openAI.name - let prefix = "\(providerName) 请求失败:" - guard message.hasPrefix(prefix) else { - return message - } + for providerName in LLMProviderPreset.all.map(\.name) { + let prefix = "\(providerName) 请求失败:" + guard message.hasPrefix(prefix) else { + continue + } - let detail = String(message.dropFirst(prefix.count)) - if detail == "URL 无效" { - return L10n.format("error.provider.urlInvalid", providerName) - } - if detail == "HTTP unknown" { - return L10n.format("error.provider.httpUnknown", providerName) + let detail = String(message.dropFirst(prefix.count)) + if detail == "URL 无效" { + return L10n.format("error.provider.urlInvalid", providerName) + } + if detail == "HTTP unknown" { + return L10n.format("error.provider.httpUnknown", providerName) + } + return L10n.format("error.provider.prefix", providerName, detail) } - return L10n.format("error.provider.prefix", providerName, detail) + + return message } } diff --git a/Sources/InkletApp/SelectionActionsController.swift b/Sources/InkletApp/SelectionActionsController.swift index 6bebf8e..2e80484 100644 --- a/Sources/InkletApp/SelectionActionsController.swift +++ b/Sources/InkletApp/SelectionActionsController.swift @@ -542,17 +542,14 @@ final class SelectionActionsController { let providerPreset = config.resolvedProviderPreset let providerID = config.providerID let apiKeyStore = self.apiKeyStore - let provider = OpenAIProvider( - apiKeyProvider: { - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) - } - return apiKey - }, - endpoint: providerPreset.endpoint - ) + let provider = LLMProviderFactory.provider(for: providerPreset) { + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) + } + return apiKey + } let targetLanguageName = config.selectionActions.translationLanguage.resolvedPromptTargetName( interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier ) diff --git a/Sources/InkletApp/SettingsViewModel.swift b/Sources/InkletApp/SettingsViewModel.swift index 49ae7c5..18f7ef7 100644 --- a/Sources/InkletApp/SettingsViewModel.swift +++ b/Sources/InkletApp/SettingsViewModel.swift @@ -76,7 +76,7 @@ final class SettingsViewModel: ObservableObject { self.selectedPromptModeID = loadedConfig.promptModes.sorted { $0.sortOrder < $1.sortOrder }.first?.id ?? PromptMode.translateToEnglishID self.cachedProviderModels = Dictionary( - uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in + uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { return nil } @@ -109,7 +109,15 @@ final class SettingsViewModel: ObservableObject { return historyItems.filter { $0.source == historyFilter } } + var isCustomOpenAICompatibleProvider: Bool { + false + } + var selectedProviderModelOptions: [String] { + guard !isCustomOpenAICompatibleProvider else { + return [] + } + var seen = Set() var options: [String] = [] @@ -135,7 +143,7 @@ final class SettingsViewModel: ObservableObject { } var shouldShowCustomModelField: Bool { - selectedModelMenuValue == Self.customModelMenuID + isCustomOpenAICompatibleProvider || selectedModelMenuValue == Self.customModelMenuID } var selectedModelIsDefault: Bool { @@ -323,7 +331,7 @@ final class SettingsViewModel: ObservableObject { refreshMigrationWorkflowIdle() guard !isMigrationMaintenanceActive else { return } cachedProviderModels = Dictionary( - uniqueKeysWithValues: [LLMProviderPreset.openAI].compactMap { preset in + uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { return nil } diff --git a/Sources/InkletCore/AnthropicProvider.swift b/Sources/InkletCore/AnthropicProvider.swift new file mode 100644 index 0000000..f613827 --- /dev/null +++ b/Sources/InkletCore/AnthropicProvider.swift @@ -0,0 +1,128 @@ +import Foundation + +public struct AnthropicProvider: LLMProvider { + public struct RequestBody: Codable, Equatable { + public struct Message: Codable, Equatable { + public struct Content: Codable, Equatable { + public var type: String + public var text: String + } + + public var role: String + public var content: [Content] + } + + public var model: String + public var maxTokens: Int + public var system: String + public var messages: [Message] + + enum CodingKeys: String, CodingKey { + case model + case maxTokens = "max_tokens" + case system + case messages + } + } + + private struct ResponseBody: Decodable { + struct Content: Decodable { + var type: String? + var text: String? + } + + var content: [Content] + } + + private struct ErrorResponseBody: Decodable { + struct ErrorBody: Decodable { + var message: String? + } + + var error: ErrorBody + } + + private let apiKeyProvider: @Sendable () throws -> String + private let endpoint: URL + private let session: URLSession + + public init( + apiKeyProvider: @escaping @Sendable () throws -> String, + endpoint: URL = URL(string: "https://api.anthropic.com/v1/messages")!, + session: URLSession = .shared + ) { + self.apiKeyProvider = apiKeyProvider + self.endpoint = endpoint + self.session = session + } + + public func transform(_ request: TransformationRequest) async throws -> TransformationResult { + let apiKey = try apiKeyProvider() + let bodyData = try JSONEncoder().encode(Self.makeRequestBody(for: request)) + + var urlRequest = URLRequest(url: endpoint, timeoutInterval: request.timeoutSeconds) + urlRequest.httpMethod = "POST" + urlRequest.setValue(apiKey, forHTTPHeaderField: "x-api-key") + urlRequest.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + + let started = Date() + let (data, response) = try await session.data(for: urlRequest) + let elapsedMilliseconds = Int(Date().timeIntervalSince(started) * 1_000) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TransformationError.provider("Anthropic 请求失败:HTTP unknown") + } + + guard (200..<300).contains(httpResponse.statusCode) else { + throw TransformationError.provider(Self.providerErrorMessage(from: data, statusCode: httpResponse.statusCode)) + } + + let outputText = try Self.parseOutputText(from: data) + return TransformationResult( + outputText: outputText, + providerMetadata: [ + "provider": "anthropic", + "model": request.model + ], + elapsedMilliseconds: elapsedMilliseconds + ) + } + + public static func makeRequestBody(for request: TransformationRequest) -> RequestBody { + RequestBody( + model: request.model, + maxTokens: 4096, + system: request.systemPrompt, + messages: [ + RequestBody.Message( + role: "user", + content: [.init(type: "text", text: request.sourceText)] + ) + ] + ) + } + + public static func parseOutputText(from data: Data) throws -> String { + let outputText = try JSONDecoder() + .decode(ResponseBody.self, from: data) + .content + .filter { $0.type == "text" } + .compactMap(\.text) + .joined() + guard !outputText.isEmpty else { + throw TransformationError.emptyResponse + } + return outputText + } + + static func providerErrorMessage(from data: Data, statusCode: Int) -> String { + if let errorBody = try? JSONDecoder().decode(ErrorResponseBody.self, from: data), + let message = errorBody.error.message { + return "Anthropic 请求失败:\(message)" + } + + return "Anthropic 请求失败:HTTP \(statusCode)" + } +} diff --git a/Sources/InkletCore/ChatCompletionProvider.swift b/Sources/InkletCore/ChatCompletionProvider.swift new file mode 100644 index 0000000..ac20288 --- /dev/null +++ b/Sources/InkletCore/ChatCompletionProvider.swift @@ -0,0 +1,111 @@ +import Foundation + +public struct ChatCompletionProvider: LLMProvider { + public struct RequestBody: Codable, Equatable { + public struct Message: Codable, Equatable { + public var role: String + public var content: String + } + + public var model: String + public var messages: [Message] + } + + private struct ResponseBody: Decodable { + struct Choice: Decodable { + struct Message: Decodable { + var content: String? + } + + var message: Message? + } + + var choices: [Choice] + } + + private struct ErrorResponseBody: Decodable { + struct ErrorBody: Decodable { + var message: String? + } + + var error: ErrorBody + } + + private let name: String + private let apiKeyProvider: @Sendable () throws -> String + private let endpoint: URL + private let session: URLSession + + public init( + name: String, + apiKeyProvider: @escaping @Sendable () throws -> String, + endpoint: URL, + session: URLSession = .shared + ) { + self.name = name + self.apiKeyProvider = apiKeyProvider + self.endpoint = endpoint + self.session = session + } + + public func transform(_ request: TransformationRequest) async throws -> TransformationResult { + let apiKey = try apiKeyProvider() + let bodyData = try JSONEncoder().encode(Self.makeRequestBody(for: request)) + + var urlRequest = URLRequest(url: endpoint, timeoutInterval: request.timeoutSeconds) + urlRequest.httpMethod = "POST" + urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + + let started = Date() + let (data, response) = try await session.data(for: urlRequest) + let elapsedMilliseconds = Int(Date().timeIntervalSince(started) * 1_000) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TransformationError.provider("\(name) 请求失败:HTTP unknown") + } + + guard (200..<300).contains(httpResponse.statusCode) else { + throw TransformationError.provider(providerErrorMessage(from: data, statusCode: httpResponse.statusCode)) + } + + let outputText = try Self.parseOutputText(from: data) + return TransformationResult( + outputText: outputText, + providerMetadata: [ + "provider": name, + "model": request.model + ], + elapsedMilliseconds: elapsedMilliseconds + ) + } + + public static func makeRequestBody(for request: TransformationRequest) -> RequestBody { + RequestBody( + model: request.model, + messages: [ + RequestBody.Message(role: "system", content: request.systemPrompt), + RequestBody.Message(role: "user", content: request.sourceText) + ] + ) + } + + public static func parseOutputText(from data: Data) throws -> String { + let response = try JSONDecoder().decode(ResponseBody.self, from: data) + let outputText = response.choices.compactMap(\.message?.content).joined() + guard !outputText.isEmpty else { + throw TransformationError.emptyResponse + } + return outputText + } + + private func providerErrorMessage(from data: Data, statusCode: Int) -> String { + if let errorBody = try? JSONDecoder().decode(ErrorResponseBody.self, from: data), + let message = errorBody.error.message { + return "\(name) 请求失败:\(message)" + } + + return "\(name) 请求失败:HTTP \(statusCode)" + } +} diff --git a/Sources/InkletCore/ConfigStore.swift b/Sources/InkletCore/ConfigStore.swift index ecb594c..6a8e213 100644 --- a/Sources/InkletCore/ConfigStore.swift +++ b/Sources/InkletCore/ConfigStore.swift @@ -21,6 +21,7 @@ public struct AppConfig: Codable, Equatable, Sendable { public var hotkey: String public var appearance: AppAppearance public var promptModes: [PromptMode] + public var customOpenAICompatibleEndpoint: String public var voiceInput: VoiceInputConfig public var selectionActions: SelectionActionsConfig @@ -32,6 +33,7 @@ public struct AppConfig: Codable, Equatable, Sendable { hotkey: String, appearance: AppAppearance = .system, promptModes: [PromptMode], + customOpenAICompatibleEndpoint: String = LLMProviderPreset.customOpenAICompatible.endpoint.absoluteString, voiceInput: VoiceInputConfig = VoiceInputConfig.defaultConfig(), selectionActions: SelectionActionsConfig = SelectionActionsConfig.defaultConfig() ) { @@ -42,6 +44,7 @@ public struct AppConfig: Codable, Equatable, Sendable { self.hotkey = hotkey self.appearance = appearance self.promptModes = promptModes + self.customOpenAICompatibleEndpoint = customOpenAICompatibleEndpoint self.voiceInput = voiceInput self.selectionActions = selectionActions } @@ -90,6 +93,7 @@ public struct AppConfig: Codable, Equatable, Sendable { case hotkey case appearance case promptModes + case customOpenAICompatibleEndpoint case voiceInput case selectionActions } @@ -119,6 +123,10 @@ public struct AppConfig: Codable, Equatable, Sendable { promptModes = AppConfig.migratedPromptModes( try container.decodeIfPresent([PromptMode].self, forKey: .promptModes) ?? defaults.promptModes ) + customOpenAICompatibleEndpoint = try container.decodeIfPresent( + String.self, + forKey: .customOpenAICompatibleEndpoint + ) ?? defaults.customOpenAICompatibleEndpoint voiceInput = try container.decodeIfPresent( VoiceInputConfig.self, forKey: .voiceInput @@ -139,6 +147,7 @@ public struct AppConfig: Codable, Equatable, Sendable { try container.encode(hotkey, forKey: .hotkey) try container.encode(appearance, forKey: .appearance) try container.encode(promptModes, forKey: .promptModes) + try container.encode(customOpenAICompatibleEndpoint, forKey: .customOpenAICompatibleEndpoint) try container.encode(voiceInput, forKey: .voiceInput) try container.encode(selectionActions, forKey: .selectionActions) } diff --git a/Sources/InkletCore/GeminiProvider.swift b/Sources/InkletCore/GeminiProvider.swift new file mode 100644 index 0000000..231b337 --- /dev/null +++ b/Sources/InkletCore/GeminiProvider.swift @@ -0,0 +1,130 @@ +import Foundation + +public struct GeminiProvider: LLMProvider { + public struct RequestBody: Codable, Equatable { + public struct Part: Codable, Equatable { + public var text: String + } + + public struct Content: Codable, Equatable { + public var role: String? + public var parts: [Part] + } + + public var systemInstruction: Content + public var contents: [Content] + } + + private struct ResponseBody: Decodable { + struct Candidate: Decodable { + struct Content: Decodable { + struct Part: Decodable { + var text: String? + } + + var parts: [Part]? + } + + var content: Content? + } + + var candidates: [Candidate] + } + + private struct ErrorResponseBody: Decodable { + struct ErrorBody: Decodable { + var message: String? + } + + var error: ErrorBody + } + + private let apiKeyProvider: @Sendable () throws -> String + private let baseURL: URL + private let session: URLSession + + public init( + apiKeyProvider: @escaping @Sendable () throws -> String, + baseURL: URL = URL(string: "https://generativelanguage.googleapis.com/v1beta")!, + session: URLSession = .shared + ) { + self.apiKeyProvider = apiKeyProvider + self.baseURL = baseURL + self.session = session + } + + public func transform(_ request: TransformationRequest) async throws -> TransformationResult { + let apiKey = try apiKeyProvider() + let bodyData = try JSONEncoder().encode(Self.makeRequestBody(for: request)) + let encodedModel = request.model.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? request.model + let endpoint = baseURL + .appendingPathComponent("models") + .appendingPathComponent(encodedModel) + .appendingPathComponent("generateContent") + + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "key", value: apiKey)] + + guard let url = components?.url else { + throw TransformationError.provider("Gemini 请求失败:URL 无效") + } + + var urlRequest = URLRequest(url: url, timeoutInterval: request.timeoutSeconds) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + + let started = Date() + let (data, response) = try await session.data(for: urlRequest) + let elapsedMilliseconds = Int(Date().timeIntervalSince(started) * 1_000) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TransformationError.provider("Gemini 请求失败:HTTP unknown") + } + + guard (200..<300).contains(httpResponse.statusCode) else { + throw TransformationError.provider(Self.providerErrorMessage(from: data, statusCode: httpResponse.statusCode)) + } + + let outputText = try Self.parseOutputText(from: data) + return TransformationResult( + outputText: outputText, + providerMetadata: [ + "provider": "gemini", + "model": request.model + ], + elapsedMilliseconds: elapsedMilliseconds + ) + } + + public static func makeRequestBody(for request: TransformationRequest) -> RequestBody { + RequestBody( + systemInstruction: .init(role: nil, parts: [.init(text: request.systemPrompt)]), + contents: [ + .init(role: "user", parts: [.init(text: request.sourceText)]) + ] + ) + } + + public static func parseOutputText(from data: Data) throws -> String { + let outputText = try JSONDecoder() + .decode(ResponseBody.self, from: data) + .candidates + .flatMap { $0.content?.parts ?? [] } + .compactMap(\.text) + .joined() + guard !outputText.isEmpty else { + throw TransformationError.emptyResponse + } + return outputText + } + + static func providerErrorMessage(from data: Data, statusCode: Int) -> String { + if let errorBody = try? JSONDecoder().decode(ErrorResponseBody.self, from: data), + let message = errorBody.error.message { + return "Gemini 请求失败:\(message)" + } + + return "Gemini 请求失败:HTTP \(statusCode)" + } +} diff --git a/Sources/InkletCore/LLMProviderFactory.swift b/Sources/InkletCore/LLMProviderFactory.swift new file mode 100644 index 0000000..7200d82 --- /dev/null +++ b/Sources/InkletCore/LLMProviderFactory.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum LLMProviderFactory { + public static func provider( + for preset: LLMProviderPreset, + apiKeyProvider: @escaping @Sendable () throws -> String + ) -> any LLMProvider { + switch preset.kind { + case .openAIResponses: + return OpenAIProvider(apiKeyProvider: apiKeyProvider, endpoint: preset.endpoint) + case .openAICompatibleChat: + return ChatCompletionProvider( + name: preset.name, + apiKeyProvider: apiKeyProvider, + endpoint: preset.endpoint + ) + case .anthropicMessages: + return AnthropicProvider(apiKeyProvider: apiKeyProvider, endpoint: preset.endpoint) + case .geminiGenerateContent: + return GeminiProvider(apiKeyProvider: apiKeyProvider, baseURL: preset.endpoint) + } + } +} diff --git a/Sources/InkletCore/LLMProviderPreset.swift b/Sources/InkletCore/LLMProviderPreset.swift index ab3d1f0..ac200bb 100644 --- a/Sources/InkletCore/LLMProviderPreset.swift +++ b/Sources/InkletCore/LLMProviderPreset.swift @@ -1,10 +1,19 @@ import Foundation +public enum LLMProviderKind: String, Codable, Sendable { + case openAIResponses + case openAICompatibleChat + case anthropicMessages + case geminiGenerateContent +} + public struct LLMProviderPreset: Codable, Equatable, Identifiable, Sendable { public var id: String public var name: String public var defaultModel: String public var apiKeyPlaceholder: String + public var keychainService: String + public var kind: LLMProviderKind public var endpoint: URL public init( @@ -12,12 +21,16 @@ public struct LLMProviderPreset: Codable, Equatable, Identifiable, Sendable { name: String, defaultModel: String, apiKeyPlaceholder: String, + keychainService: String, + kind: LLMProviderKind, endpoint: URL ) { self.id = id self.name = name self.defaultModel = defaultModel self.apiKeyPlaceholder = apiKeyPlaceholder + self.keychainService = keychainService + self.kind = kind self.endpoint = endpoint } @@ -26,6 +39,199 @@ public struct LLMProviderPreset: Codable, Equatable, Identifiable, Sendable { name: "OpenAI", defaultModel: "gpt-5.6-luna", apiKeyPlaceholder: "sk-...", + keychainService: KeychainStore.defaultService, + kind: .openAIResponses, endpoint: URL(string: "https://api.openai.com/v1/responses")! ) + + public static let customOpenAICompatible = openAICompatible( + id: "custom-openai-compatible", + name: "Custom OpenAI Compatible", + defaultModel: "gpt-5-mini", + placeholder: "sk-...", + service: "Inklet.CustomOpenAICompatible", + endpoint: "https://api.example.com/v1/chat/completions" + ) + + public static let all: [LLMProviderPreset] = [ + .openAI, + LLMProviderPreset( + id: "anthropic", + name: "Anthropic", + defaultModel: "claude-haiku-4-5", + apiKeyPlaceholder: "sk-ant-...", + keychainService: "Inklet.Anthropic", + kind: .anthropicMessages, + endpoint: URL(string: "https://api.anthropic.com/v1/messages")! + ), + LLMProviderPreset( + id: "gemini", + name: "Google Gemini", + defaultModel: "gemini-flash-latest", + apiKeyPlaceholder: "AIza...", + keychainService: "Inklet.Gemini", + kind: .geminiGenerateContent, + endpoint: URL(string: "https://generativelanguage.googleapis.com/v1beta")! + ), + openAICompatible( + id: "deepseek", + name: "DeepSeek", + defaultModel: "deepseek-v4-flash", + placeholder: "sk-...", + service: "Inklet.DeepSeek", + endpoint: "https://api.deepseek.com/chat/completions" + ), + openAICompatible( + id: "qwen", + name: "Alibaba Qwen", + defaultModel: "qwen3.6-plus", + placeholder: "sk-...", + service: "Inklet.Qwen", + endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + ), + openAICompatible( + id: "moonshot", + name: "Moonshot Kimi", + defaultModel: "kimi-k2.6", + placeholder: "sk-...", + service: "Inklet.Moonshot", + endpoint: "https://api.moonshot.cn/v1/chat/completions" + ), + openAICompatible( + id: "zhipu", + name: "Zhipu GLM", + defaultModel: "glm-4.7-flash", + placeholder: "...", + service: "Inklet.Zhipu", + endpoint: "https://open.bigmodel.cn/api/paas/v4/chat/completions" + ), + openAICompatible( + id: "minimax", + name: "MiniMax", + defaultModel: "MiniMax-M2.7-highspeed", + placeholder: "...", + service: "Inklet.MiniMax", + endpoint: "https://api.minimax.io/v1/chat/completions" + ), + openAICompatible( + id: "siliconflow", + name: "SiliconFlow", + defaultModel: "deepseek-ai/deepseek-v4-flash", + placeholder: "sk-...", + service: "Inklet.SiliconFlow", + endpoint: "https://api.siliconflow.com/v1/chat/completions" + ), + openAICompatible( + id: "volcengine", + name: "Volcengine Ark", + defaultModel: "doubao-seed-1-6-flash", + placeholder: "...", + service: "Inklet.Volcengine", + endpoint: "https://ark.cn-beijing.volces.com/api/v3/chat/completions" + ), + openAICompatible( + id: "tencent-hunyuan", + name: "Tencent Hunyuan", + defaultModel: "hunyuan-turbos-latest", + placeholder: "...", + service: "Inklet.TencentHunyuan", + endpoint: "https://api.hunyuan.cloud.tencent.com/v1/chat/completions" + ), + openAICompatible( + id: "baichuan", + name: "Baichuan", + defaultModel: "Baichuan4-Turbo", + placeholder: "sk-...", + service: "Inklet.Baichuan", + endpoint: "https://api.baichuan-ai.com/v1/chat/completions" + ), + openAICompatible( + id: "lingyiwanwu", + name: "01.AI Yi", + defaultModel: "yi-lightning", + placeholder: "...", + service: "Inklet.Lingyiwanwu", + endpoint: "https://api.lingyiwanwu.com/v1/chat/completions" + ), + openAICompatible( + id: "xai", + name: "xAI", + defaultModel: "grok-4-fast", + placeholder: "xai-...", + service: "Inklet.xAI", + endpoint: "https://api.x.ai/v1/chat/completions" + ), + openAICompatible( + id: "groq", + name: "Groq", + defaultModel: "meta-llama/llama-4-scout-17b-16e-instruct", + placeholder: "gsk_...", + service: "Inklet.Groq", + endpoint: "https://api.groq.com/openai/v1/chat/completions" + ), + openAICompatible( + id: "mistral", + name: "Mistral", + defaultModel: "mistral-small-latest", + placeholder: "...", + service: "Inklet.Mistral", + endpoint: "https://api.mistral.ai/v1/chat/completions" + ), + openAICompatible( + id: "openrouter", + name: "OpenRouter", + defaultModel: "openai/gpt-5.4-mini", + placeholder: "sk-or-...", + service: "Inklet.OpenRouter", + endpoint: "https://openrouter.ai/api/v1/chat/completions" + ), + openAICompatible( + id: "perplexity", + name: "Perplexity", + defaultModel: "sonar", + placeholder: "pplx-...", + service: "Inklet.Perplexity", + endpoint: "https://api.perplexity.ai/chat/completions" + ), + openAICompatible( + id: "together", + name: "Together AI", + defaultModel: "Qwen/Qwen3.6-Plus", + placeholder: "...", + service: "Inklet.Together", + endpoint: "https://api.together.xyz/v1/chat/completions" + ), + openAICompatible( + id: "cerebras", + name: "Cerebras", + defaultModel: "gpt-oss-120b", + placeholder: "csk-...", + service: "Inklet.Cerebras", + endpoint: "https://api.cerebras.ai/v1/chat/completions" + ), + .customOpenAICompatible + ] + + public static func preset(id: String) -> LLMProviderPreset { + all.first { $0.id == id } ?? .openAI + } + + private static func openAICompatible( + id: String, + name: String, + defaultModel: String, + placeholder: String, + service: String, + endpoint: String + ) -> LLMProviderPreset { + LLMProviderPreset( + id: id, + name: name, + defaultModel: defaultModel, + apiKeyPlaceholder: placeholder, + keychainService: service, + kind: .openAICompatibleChat, + endpoint: URL(string: endpoint)! + ) + } } diff --git a/Sources/InkletCore/ModelCatalogService.swift b/Sources/InkletCore/ModelCatalogService.swift index 7c91d33..95bf450 100644 --- a/Sources/InkletCore/ModelCatalogService.swift +++ b/Sources/InkletCore/ModelCatalogService.swift @@ -129,7 +129,22 @@ public final class ModelCatalogService: @unchecked Sendable { } private static let providerMapping: [String: String] = [ - LLMProviderPreset.openAI.id: "openai" + "openai": "openai", + "anthropic": "anthropic", + "gemini": "google", + "deepseek": "deepseek", + "qwen": "alibaba", + "moonshot": "moonshotai", + "xai": "xai", + "groq": "groq", + "mistral": "mistral", + "openrouter": "openrouter", + "perplexity": "perplexity", + "together": "togetherai", + "cerebras": "cerebras", + "zhipu": "zhipuai", + "minimax": "minimax", + "siliconflow": "siliconflow" ] } diff --git a/Sources/InkletCore/Resources/model-catalog-snapshot.json b/Sources/InkletCore/Resources/model-catalog-snapshot.json index e113a82..04f6bde 100644 --- a/Sources/InkletCore/Resources/model-catalog-snapshot.json +++ b/Sources/InkletCore/Resources/model-catalog-snapshot.json @@ -53,6 +53,416 @@ "text-embedding-3-small", "gpt-3.5-turbo", "text-embedding-ada-002" + ], + "anthropic": [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-opus-4-5", + "claude-opus-4-5-20251101", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", + "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929", + "claude-opus-4-1", + "claude-opus-4-1-20250805", + "claude-opus-4-0", + "claude-opus-4-20250514", + "claude-sonnet-4-0", + "claude-sonnet-4-20250514", + "claude-3-7-sonnet-20250219", + "claude-3-5-haiku-20241022", + "claude-3-5-haiku-latest", + "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-20240620", + "claude-3-haiku-20240307", + "claude-3-sonnet-20240229", + "claude-3-opus-20240229" + ], + "gemini": [ + "gemini-3.5-flash", + "gemini-3.1-flash-lite", + "gemma-4-26b-a4b-it", + "gemma-4-31b-it", + "gemini-3.1-flash-lite-preview", + "gemini-3.1-flash-image-preview", + "gemini-3.1-pro-preview", + "gemini-3.1-pro-preview-customtools", + "gemini-3-flash-preview", + "gemini-3-pro-preview", + "gemini-2.5-flash-lite-preview-09-2025", + "gemini-2.5-flash-preview-09-2025", + "gemini-flash-latest", + "gemini-flash-lite-latest", + "gemini-live-2.5-flash-preview-native-audio", + "gemini-live-2.5-flash", + "gemini-2.5-flash-image", + "gemini-2.5-flash-image-preview", + "gemma-3n-e2b-it", + "gemini-2.5-flash-lite", + "gemini-2.5-flash-lite-preview-06-17", + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-2.5-pro-preview-06-05", + "gemini-2.5-flash-preview-05-20", + "gemini-embedding-001", + "gemma-3n-e4b-it", + "gemini-2.5-pro-preview-05-06", + "gemini-2.5-flash-preview-04-17", + "gemma-3-12b-it", + "gemma-3-4b-it", + "gemma-3-27b-it", + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-1.5-flash-8b", + "gemini-1.5-flash", + "gemini-1.5-pro" + ], + "deepseek": [ + "deepseek-v4-flash", + "deepseek-v4-pro", + "deepseek-chat", + "deepseek-reasoner" + ], + "qwen": [ + "qwen3.6-27b", + "qwen3.6-max-preview", + "qwen3.6-35b-a3b", + "qwen3.6-plus", + "qwen3.5-122b-a10b", + "qwen3.5-27b", + "qwen3.5-35b-a3b", + "qwen3.5-plus", + "qwen3.5-397b-a17b", + "qwen3-max", + "qwen3-vl-plus", + "qwen3-livetranslate-flash-realtime", + "qwen3-omni-flash", + "qwen3-omni-flash-realtime", + "qwen-plus", + "qwen3-next-80b-a3b-instruct", + "qwen3-next-80b-a3b-thinking", + "qwen-vl-plus", + "qwen-vl-max", + "qwen-flash", + "qwen3-coder-flash", + "qwen3-coder-plus", + "qwen-omni-turbo-realtime", + "qwen-turbo", + "qwen-vl-ocr", + "qwen3-14b", + "qwen3-235b-a22b", + "qwen3-32b", + "qwen3-8b", + "qwen3-coder-30b-a3b-instruct", + "qwen3-coder-480b-a35b-instruct", + "qwen3-vl-235b-a22b", + "qwen3-vl-30b-a3b", + "qwen-omni-turbo", + "qvq-max", + "qwq-plus", + "qwen-max", + "qwen-mt-plus", + "qwen-mt-turbo", + "qwen2-5-omni-7b", + "qwen2-5-14b-instruct", + "qwen2-5-32b-instruct", + "qwen2-5-72b-instruct", + "qwen2-5-7b-instruct", + "qwen2-5-vl-72b-instruct", + "qwen2-5-vl-7b-instruct", + "qwen-plus-character-ja" + ], + "moonshot": [ + "kimi-k2.6", + "kimi-k2.5", + "kimi-k2-thinking", + "kimi-k2-thinking-turbo", + "kimi-k2-0905-preview", + "kimi-k2-turbo-preview", + "kimi-k2-0711-preview" + ], + "xai": [ + "grok-4.3", + "grok-4.20-0309-non-reasoning", + "grok-4.20-0309-reasoning", + "grok-4.20-multi-agent-0309", + "grok-2-1212", + "grok-2-latest", + "grok-2-vision-1212", + "grok-2-vision-latest", + "grok-beta", + "grok-vision-beta", + "grok-2", + "grok-2-vision" + ], + "groq": [ + "moonshotai/kimi-k2-instruct-0905", + "groq/compound", + "groq/compound-mini", + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + "moonshotai/kimi-k2-instruct", + "meta-llama/llama-4-maverick-17b-128e-instruct", + "meta-llama/llama-4-scout-17b-16e-instruct", + "meta-llama/llama-guard-4-12b", + "openai/gpt-oss-safeguard-20b", + "mistral-saba-24b", + "deepseek-r1-distill-llama-70b", + "qwen/qwen3-32b", + "llama-3.3-70b-versatile", + "qwen-qwq-32b", + "meta-llama/llama-prompt-guard-2-22m", + "meta-llama/llama-prompt-guard-2-86m", + "allam-2-7b", + "llama-3.1-8b-instant", + "llama-guard-3-8b", + "gemma2-9b-it", + "llama3-70b-8192", + "llama3-8b-8192" + ], + "mistral": [ + "mistral-medium-2604", + "mistral-medium-latest", + "mistral-small-2603", + "mistral-small-latest", + "devstral-2512", + "labs-devstral-small-2512", + "devstral-medium-latest", + "mistral-large-2512", + "mistral-large-latest", + "mistral-medium-2508", + "devstral-medium-2507", + "devstral-small-2507", + "mistral-small-2506", + "devstral-small-2505", + "mistral-medium-2505", + "magistral-medium-latest", + "magistral-small", + "codestral-latest", + "mistral-large-2411", + "pixtral-large-latest", + "ministral-3b-latest", + "ministral-8b-latest", + "pixtral-12b", + "mistral-nemo", + "open-mixtral-8x22b", + "mistral-embed", + "open-mixtral-8x7b", + "open-mistral-7b" + ], + "openrouter": [ + "anthropic/claude-opus-4.7-fast", + "perceptron/perceptron-mk1", + "inclusionai/ring-2.6-1t", + "google/gemini-3.1-flash-lite", + "baidu/cobuddy:free", + "openai/gpt-chat-latest", + "ibm-granite/granite-4.1-8b", + "mistralai/mistral-medium-3-5", + "x-ai/grok-4.3", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + "openrouter/owl-alpha", + "poolside/laguna-m.1:free", + "poolside/laguna-xs.2:free", + "~anthropic/claude-haiku-latest", + "~anthropic/claude-sonnet-latest", + "~google/gemini-flash-latest", + "~google/gemini-pro-latest", + "~moonshotai/kimi-latest", + "~openai/gpt-latest", + "~openai/gpt-mini-latest", + "qwen/qwen3.5-plus-20260420", + "qwen/qwen3.6-27b", + "qwen/qwen3.6-35b-a3b", + "qwen/qwen3.6-flash", + "qwen/qwen3.6-max-preview", + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash:free", + "deepseek/deepseek-v4-pro", + "openai/gpt-5.5", + "openai/gpt-5.5-pro", + "inclusionai/ling-2.6-1t", + "tencent/hy3-preview", + "xiaomi/mimo-v2.5", + "xiaomi/mimo-v2.5-pro", + "~anthropic/claude-opus-latest", + "inclusionai/ling-2.6-flash", + "openai/gpt-5.4-image-2", + "openrouter/pareto-code", + "baidu/qianfan-ocr-fast", + "moonshotai/kimi-k2.6", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.6-fast", + "z-ai/glm-5.1", + "google/gemma-4-26b-a4b-it", + "google/gemma-4-26b-a4b-it:free", + "google/gemma-4-31b-it", + "google/gemma-4-31b-it:free", + "qwen/qwen3.6-plus", + "arcee-ai/trinity-large-thinking", + "arcee-ai/trinity-large-thinking:free", + "z-ai/glm-5v-turbo", + "x-ai/grok-4.20", + "x-ai/grok-4.20-multi-agent", + "google/lyria-3-clip-preview", + "google/lyria-3-pro-preview", + "kwaipilot/kat-coder-pro-v2", + "rekaai/reka-edge", + "minimax/minimax-m2.7", + "xiaomi/mimo-v2-omni", + "xiaomi/mimo-v2-pro", + "openai/gpt-5.4-mini", + "openai/gpt-5.4-nano", + "mistralai/mistral-small-2603", + "z-ai/glm-5-turbo", + "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3-super-120b-a12b:free", + "bytedance-seed/seed-2.0-lite", + "qwen/qwen3.5-9b", + "openai/gpt-5.4", + "openai/gpt-5.4-pro", + "inception/mercury-2", + "google/gemini-3.1-flash-lite-preview", + "openai/gpt-5.3-chat", + "bytedance-seed/seed-2.0-mini", + "google/gemini-3.1-flash-image-preview", + "google/gemini-3.1-pro-preview-customtools", + "liquid/lfm-2-24b-a2b", + "qwen/qwen3.5-122b-a10b", + "qwen/qwen3.5-27b", + "qwen/qwen3.5-35b-a3b" + ], + "perplexity": [ + "sonar", + "sonar-deep-research", + "sonar-pro", + "sonar-reasoning-pro" + ], + "together": [ + "Qwen/Qwen3.6-Plus", + "deepseek-ai/DeepSeek-V4-Pro", + "moonshotai/Kimi-K2.6", + "google/gemma-4-31B-it", + "zai-org/GLM-5.1", + "MiniMaxAI/MiniMax-M2.7", + "Qwen/Qwen3.5-397B-A17B", + "MiniMaxAI/MiniMax-M2.5", + "Qwen/Qwen3-Coder-Next-FP8", + "moonshotai/Kimi-K2.5", + "essentialai/Rnj-1-Instruct", + "deepseek-ai/DeepSeek-V3-1", + "openai/gpt-oss-120b", + "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + "deepseek-ai/DeepSeek-V3", + "deepseek-ai/DeepSeek-R1", + "meta-llama/Llama-3.3-70B-Instruct-Turbo" + ], + "cerebras": [ + "zai-glm-4.7", + "gpt-oss-120b", + "qwen-3-235b-a22b-instruct-2507", + "llama3.1-8b" + ], + "zhipu": [ + "glm-5v-turbo", + "glm-5.1", + "glm-5", + "glm-4.7-flash", + "glm-4.7-flashx", + "glm-4.7", + "glm-4.6v", + "glm-4.6", + "glm-4.5v", + "glm-4.5", + "glm-4.5-air", + "glm-4.5-flash" + ], + "minimax": [ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.1", + "MiniMax-M2" + ], + "siliconflow": [ + "deepseek-ai/deepseek-v4-flash", + "deepseek-ai/deepseek-v4-pro", + "moonshotai/Kimi-K2.6", + "zai-org/GLM-5.1", + "zai-org/GLM-5V-Turbo", + "MiniMaxAI/MiniMax-M2.5", + "zai-org/GLM-5", + "stepfun-ai/Step-3.5-Flash", + "moonshotai/Kimi-K2.5", + "MiniMaxAI/MiniMax-M2.1", + "zai-org/GLM-4.7", + "zai-org/GLM-4.6V", + "deepseek-ai/DeepSeek-V3.2", + "baidu/ERNIE-4.5-300B-A47B", + "ByteDance-Seed/Seed-OSS-36B-Instruct", + "deepseek-ai/DeepSeek-R1", + "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", + "deepseek-ai/DeepSeek-V3", + "deepseek-ai/DeepSeek-V3.1", + "deepseek-ai/DeepSeek-V3.1-Terminus", + "deepseek-ai/DeepSeek-V3.2-Exp", + "deepseek-ai/deepseek-vl2", + "inclusionAI/Ling-flash-2.0", + "inclusionAI/Ling-mini-2.0", + "inclusionAI/Ring-flash-2.0", + "meta-llama/Meta-Llama-3.1-8B-Instruct", + "moonshotai/Kimi-K2-Instruct", + "moonshotai/Kimi-K2-Instruct-0905", + "moonshotai/Kimi-K2-Thinking", + "nex-agi/DeepSeek-V3.1-Nex-N1", + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + "Qwen/Qwen2.5-14B-Instruct", + "Qwen/Qwen2.5-32B-Instruct", + "Qwen/Qwen2.5-72B-Instruct", + "Qwen/Qwen2.5-72B-Instruct-128K", + "Qwen/Qwen2.5-7B-Instruct", + "Qwen/Qwen2.5-Coder-32B-Instruct", + "Qwen/Qwen2.5-VL-32B-Instruct", + "Qwen/Qwen2.5-VL-72B-Instruct", + "Qwen/Qwen2.5-VL-7B-Instruct", + "Qwen/Qwen3-14B", + "Qwen/Qwen3-235B-A22B", + "Qwen/Qwen3-235B-A22B-Instruct-2507", + "Qwen/Qwen3-235B-A22B-Thinking-2507", + "Qwen/Qwen3-30B-A3B-Instruct-2507", + "Qwen/Qwen3-30B-A3B-Thinking-2507", + "Qwen/Qwen3-32B", + "Qwen/Qwen3-8B", + "Qwen/Qwen3-Coder-30B-A3B-Instruct", + "Qwen/Qwen3-Coder-480B-A35B-Instruct", + "Qwen/Qwen3-Next-80B-A3B-Instruct", + "Qwen/Qwen3-Next-80B-A3B-Thinking", + "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "Qwen/Qwen3-Omni-30B-A3B-Thinking", + "Qwen/Qwen3-VL-235B-A22B-Instruct", + "Qwen/Qwen3-VL-235B-A22B-Thinking", + "Qwen/Qwen3-VL-30B-A3B-Instruct", + "Qwen/Qwen3-VL-30B-A3B-Thinking", + "Qwen/Qwen3-VL-32B-Instruct", + "Qwen/Qwen3-VL-32B-Thinking", + "Qwen/Qwen3-VL-8B-Instruct", + "Qwen/Qwen3-VL-8B-Thinking", + "Qwen/QwQ-32B", + "tencent/Hunyuan-A13B-Instruct", + "tencent/Hunyuan-MT-7B", + "THUDM/GLM-4-32B-0414", + "THUDM/GLM-4-9B-0414", + "THUDM/GLM-Z1-32B-0414", + "THUDM/GLM-Z1-9B-0414", + "zai-org/GLM-4.5", + "zai-org/GLM-4.5-Air", + "zai-org/GLM-4.5V", + "zai-org/GLM-4.6" ] } } diff --git a/Tests/InkletCoreTests/ConfigStoreTests.swift b/Tests/InkletCoreTests/ConfigStoreTests.swift index 1d94b41..046a12d 100644 --- a/Tests/InkletCoreTests/ConfigStoreTests.swift +++ b/Tests/InkletCoreTests/ConfigStoreTests.swift @@ -141,6 +141,10 @@ final class ConfigStoreTests: XCTestCase { XCTAssertEqual(config.hotkey, "⌥Space") XCTAssertEqual(config.appearance, .system) XCTAssertEqual(config.defaultVisibleModeID, PromptMode.translateToEnglishID) + XCTAssertEqual( + config.customOpenAICompatibleEndpoint, + LLMProviderPreset.customOpenAICompatible.endpoint.absoluteString + ) XCTAssertEqual(config.selectionActions, SelectionActionsConfig.defaultConfig()) } @@ -157,6 +161,7 @@ final class ConfigStoreTests: XCTestCase { config.timeoutSeconds = 9 config.hotkey = "⌘Space" config.appearance = .dark + config.customOpenAICompatibleEndpoint = "http://127.0.0.1:1234/v1/chat/completions" config.selectionActions = SelectionActionsConfig( isEnabled: false, translationLanguage: .japanese, @@ -196,6 +201,10 @@ final class ConfigStoreTests: XCTestCase { XCTAssertEqual(config.appearance, AppConfig.defaultConfig().appearance) XCTAssertEqual(config.defaultVisibleModeID, AppConfig.defaultConfig().defaultVisibleModeID) XCTAssertEqual(config.promptModes, AppConfig.defaultConfig().promptModes) + XCTAssertEqual( + config.customOpenAICompatibleEndpoint, + AppConfig.defaultConfig().customOpenAICompatibleEndpoint + ) XCTAssertEqual(config.selectionActions, AppConfig.defaultConfig().selectionActions) } @@ -500,7 +509,8 @@ final class ConfigStoreTests: XCTestCase { func testResolvedProviderPresetAlwaysUsesOpenAI() { var config = AppConfig.defaultConfig() - config.providerID = "custom-openai-compatible" + config.providerID = LLMProviderPreset.customOpenAICompatible.id + config.customOpenAICompatibleEndpoint = "http://127.0.0.1:1234/v1/chat/completions" XCTAssertEqual( config.resolvedProviderPreset, diff --git a/Tests/InkletCoreTests/LLMProviderTests.swift b/Tests/InkletCoreTests/LLMProviderTests.swift new file mode 100644 index 0000000..7141158 --- /dev/null +++ b/Tests/InkletCoreTests/LLMProviderTests.swift @@ -0,0 +1,123 @@ +import XCTest +@testable import InkletCore + +final class LLMProviderTests: XCTestCase { + func testProviderPresetCoversMainstreamProviders() { + XCTAssertEqual( + LLMProviderPreset.all.map(\.id), + [ + "openai", + "anthropic", + "gemini", + "deepseek", + "qwen", + "moonshot", + "zhipu", + "minimax", + "siliconflow", + "volcengine", + "tencent-hunyuan", + "baichuan", + "lingyiwanwu", + "xai", + "groq", + "mistral", + "openrouter", + "perplexity", + "together", + "cerebras", + "custom-openai-compatible" + ] + ) + } + + func testProviderDefaultsUseCurrentFastModels() { + let defaults = Dictionary(uniqueKeysWithValues: LLMProviderPreset.all.map { ($0.id, $0.defaultModel) }) + + XCTAssertEqual(defaults["openai"], "gpt-5.6-luna") + XCTAssertEqual(defaults["anthropic"], "claude-haiku-4-5") + XCTAssertEqual(defaults["gemini"], "gemini-flash-latest") + XCTAssertEqual(defaults["deepseek"], "deepseek-v4-flash") + XCTAssertEqual(defaults["qwen"], "qwen3.6-plus") + XCTAssertEqual(defaults["minimax"], "MiniMax-M2.7-highspeed") + XCTAssertEqual(defaults["groq"], "meta-llama/llama-4-scout-17b-16e-instruct") + XCTAssertEqual(defaults["openrouter"], "openai/gpt-5.4-mini") + XCTAssertEqual(defaults["custom-openai-compatible"], "gpt-5-mini") + XCTAssertTrue(LLMProviderPreset.all.allSatisfy { !$0.defaultModel.isEmpty }) + } + + func testChatCompletionBuildsOpenAICompatibleRequestWithoutTemperature() throws { + let body = ChatCompletionProvider.makeRequestBody(for: request) + let json = try encodedJSONObject(body) + let messages = try XCTUnwrap(json["messages"] as? [[String: Any]]) + + XCTAssertNil(json["temperature"]) + XCTAssertEqual(json["model"] as? String, "test-model") + XCTAssertEqual(messages.compactMap { $0["role"] as? String }, ["system", "user"]) + XCTAssertEqual(messages.compactMap { $0["content"] as? String }, ["Rewrite clearly.", "Hello"]) + } + + func testChatCompletionParsesResponseText() throws { + let json = #"{"choices":[{"message":{"content":"Hi there."}}]}"# + let data = try XCTUnwrap(json.data(using: .utf8)) + + XCTAssertEqual(try ChatCompletionProvider.parseOutputText(from: data), "Hi there.") + } + + func testAnthropicBuildsMessagesRequestWithoutTemperature() throws { + let body = AnthropicProvider.makeRequestBody(for: request) + let json = try encodedJSONObject(body) + let messages = try XCTUnwrap(json["messages"] as? [[String: Any]]) + let content = try XCTUnwrap(messages.first?["content"] as? [[String: Any]]) + + XCTAssertNil(json["temperature"]) + XCTAssertEqual(json["model"] as? String, "test-model") + XCTAssertEqual(json["system"] as? String, "Rewrite clearly.") + XCTAssertEqual(messages.first?["role"] as? String, "user") + XCTAssertEqual(content.first?["text"] as? String, "Hello") + } + + func testAnthropicParsesResponseText() throws { + let json = #"{"content":[{"type":"text","text":"Hi there."}]}"# + let data = try XCTUnwrap(json.data(using: .utf8)) + + XCTAssertEqual(try AnthropicProvider.parseOutputText(from: data), "Hi there.") + } + + func testGeminiBuildsGenerateContentRequestWithoutGenerationConfig() throws { + let body = GeminiProvider.makeRequestBody(for: request) + let json = try encodedJSONObject(body) + let systemInstruction = try XCTUnwrap(json["systemInstruction"] as? [String: Any]) + let systemParts = try XCTUnwrap(systemInstruction["parts"] as? [[String: Any]]) + let contents = try XCTUnwrap(json["contents"] as? [[String: Any]]) + let userParts = try XCTUnwrap(contents.first?["parts"] as? [[String: Any]]) + + XCTAssertNil(json["generationConfig"]) + XCTAssertEqual(systemParts.first?["text"] as? String, "Rewrite clearly.") + XCTAssertEqual(contents.first?["role"] as? String, "user") + XCTAssertEqual(userParts.first?["text"] as? String, "Hello") + } + + func testGeminiParsesResponseText() throws { + let json = #"{"candidates":[{"content":{"parts":[{"text":"Hi there."}]}}]}"# + let data = try XCTUnwrap(json.data(using: .utf8)) + + XCTAssertEqual(try GeminiProvider.parseOutputText(from: data), "Hi there.") + } + + private func encodedJSONObject(_ value: T) throws -> [String: Any] { + let data = try JSONEncoder().encode(value) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private var request: TransformationRequest { + TransformationRequest( + sourceText: "Hello", + systemPrompt: "Rewrite clearly.", + modeID: "polish", + modeName: "Polish", + model: "test-model", + timeoutSeconds: 10 + ) + } +} diff --git a/Tests/InkletCoreTests/ModelCatalogServiceTests.swift b/Tests/InkletCoreTests/ModelCatalogServiceTests.swift index 2b74fc2..a1f7c43 100644 --- a/Tests/InkletCoreTests/ModelCatalogServiceTests.swift +++ b/Tests/InkletCoreTests/ModelCatalogServiceTests.swift @@ -28,7 +28,7 @@ final class ModelCatalogServiceTests: XCTestCase { try await service.refreshIfNeeded() XCTAssertEqual(service.cachedModelIDs(for: "openai"), ["gpt-5.2", "gpt-4.1-mini"]) - XCTAssertNil(service.cachedModelIDs(for: "gemini")) + XCTAssertEqual(service.cachedModelIDs(for: "gemini"), ["gemini-3.5-flash"]) XCTAssertNil(service.cachedModelIDs(for: "custom-openai-compatible")) } @@ -86,6 +86,8 @@ final class ModelCatalogServiceTests: XCTestCase { let service = ModelCatalogService(userDefaults: userDefaults) XCTAssertFalse(service.cachedModelIDs(for: "openai")?.isEmpty ?? true) + XCTAssertFalse(service.cachedModelIDs(for: "anthropic")?.isEmpty ?? true) + XCTAssertFalse(service.cachedModelIDs(for: "gemini")?.isEmpty ?? true) } func testBundledFallbackURLFindsAppResourcesBundle() throws {