diff --git a/README.md b/README.md index d63a0e0..56952c3 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Use **Check for Updates…** from the app menu to check manually in either the p 1. Open Inklet from your Applications folder. For a source build, run `scripts/run-local-app.sh` from the repository root, then use `/Applications/Inklet Local.app`. 2. Click the Inklet menu bar icon and open Settings. 3. Grant Accessibility permission when macOS asks. Inklet uses this one generic permission to read selections, perform a configured copy fallback, return focus to the previous app, and paste confirmed results. Inklet stays in the background while System Settings is open and returns to General settings when you close it. -4. Enter your OpenAI API key in General. Inklet uses this one key for writing, realtime dictation, selection translation, and pronunciation. +4. Choose a provider in General and enter its API key. With OpenAI, the default, one key covers writing, realtime dictation, selection translation, and pronunciation. With any other provider, writing and selection translation use that provider, while realtime dictation and pronunciation still need an OpenAI API key in the separate optional field. 5. Configure Writing Assistant with the model, writing shortcut, generation settings, prompt modes, Dictation hold shortcut, and microphone you want to use. Advanced Dictation exposes only the recovery model; the recovery endpoint is not editable. The realtime model is fixed by Inklet. 6. Optional: configure Selection Assistant with a translation language, Force Selection mode, AI pronunciation voice, and pronunciation speed, then preview the voice in Settings. 7. Grant Microphone permission on the first valid Dictation hold. Opening Inklet, visiting Settings, or pressing the Dictation shortcut outside the active source editor does not request it. @@ -79,9 +79,9 @@ The Dictation shortcut is source-local and hold-only. You can change its modifie - Keeps simulated `Command+C` off by default. Menu Copy remains the safe Force Selection fallback; you can explicitly enable simulated copy as an advanced fallback for apps without a usable Copy menu, but it may interfere with games, remote desktops, or virtual machines. - Serializes temporary clipboard reads and restores the prior snapshot only while the same read still owns the observed copy result; newer clipboard contents win. The double-copy trigger is passive: it consumes the copy the user already made without issuing another synthetic copy or restoring older clipboard data. Right-click remains native and never starts a selection read. - Does not use browser-specific selection code and does not request browser Automation. Chrome, Safari, Edge, and native apps use the same generic path. -- Lets you edit prompt modes, OpenAI model, timeout, writing shortcut, Dictation hold shortcut, microphone, recovery model, selection translation language, selection Translate prompt, Force Selection mode, simulated-copy permission, AI pronunciation voice, and AI pronunciation speed. +- Lets you edit prompt modes, provider, model, custom OpenAI-compatible endpoint, timeout, writing shortcut, Dictation hold shortcut, microphone, recovery model, selection translation language, selection Translate prompt, Force Selection mode, simulated-copy permission, AI pronunciation voice, and AI pronunciation speed. - Shows local History for successful Write and Selection results, with consecutive duplicate entries collapsed, selectable source/result text, a result copy control, and a clear-all action. Existing legacy Voice entries remain readable. -- Uses one shared OpenAI API key for writing, realtime dictation, selection translation, and pronunciation. +- Writes and translates with OpenAI, Anthropic, Google Gemini, DeepSeek, Alibaba Qwen, Moonshot Kimi, Zhipu GLM, MiniMax, SiliconFlow, Volcengine Ark, Tencent Hunyuan, Baichuan, 01.AI Yi, xAI, Groq, Mistral, OpenRouter, Perplexity, Together AI, Cerebras, or any custom OpenAI-compatible endpoint, such as a local model server. Realtime dictation and pronunciation always use OpenAI. - Provides English, Simplified Chinese, Traditional Chinese, Japanese, Korean, Spanish, French, German, Portuguese, and Italian app UI localization. - Adapts compact selection menus and constrained settings/writing controls to translated labels; language changes refresh open UI while preserving writing content. @@ -102,7 +102,7 @@ Inklet is an early MVP. The repository currently includes: - Full Xcode is recommended for XCTest support. - Accessibility permission for Inklet, required for generic selection reading, configured copy fallback, returning focus to the previous app, and pasting the generated result. - Microphone permission for realtime dictation while the shortcut is held. -- An OpenAI API key. +- An API key for OpenAI or another supported provider. Realtime dictation and pronunciation require an OpenAI API key. ## Build And Run @@ -183,18 +183,18 @@ If macOS blocks automatic access to the matching legacy container, Settings keep ## Privacy -- Inklet uses your configured OpenAI API key to call OpenAI for writing, realtime dictation, selection translation, and pronunciation. +- Inklet sends writing and selection translation requests to the provider you choose in Settings, using that provider's API key. Realtime dictation and pronunciation always use your OpenAI API key and OpenAI. - While you hold the Dictation shortcut, active microphone audio is streamed directly to OpenAI Realtime transcription. Inklet also keeps one temporary local recovery recording. It remains local until the fallback request actually begins, is uploaded at most once to `https://api.openai.com/v1/audio/transcriptions` using the same existing OpenAI API key used by realtime dictation, and is deleted on every terminal session path. -- Your OpenAI API key is stored locally on your Mac. +- Your API keys are stored locally on your Mac, one per provider. - Inklet uses Accessibility permission for generic selection reading, configured copy fallback, returning focus to the previous app, and pasting text. - Inklet uses Microphone permission only during a valid Dictation hold in the active Writing Assistant source editor. Finishing dictation leaves an editable draft and does not insert into another app. - Inklet temporarily uses the clipboard for insertion and configured Force Selection fallback reads. A Force Selection read restores the previous clipboard only if Inklet's temporary copied value is still current, and does not overwrite a later external clipboard change. - Inklet saves successful Write and Selection source/result text locally in History until you clear it in Settings, while skipping consecutive duplicate entries. An unprocessed dictated draft creates no History entry; existing legacy Voice entries remain locally readable. - Selection Actions capture the source app and selection location, validate that source before and during the read, and use Accessibility to read its current selection. If Accessibility does not return selected text, the configured Force Selection mode can briefly invoke menu Copy and read the resulting clipboard text through the protected transaction described above. Simulated `Command+C` is off by default and runs only after an explicit advanced opt-in. You can turn Force Selection off in Settings. This path sends no browser-targeted Apple Events and does not request browser Automation. Pressing `Command+C` twice quickly after selecting text explicitly reads the copy you already made. Inklet does not save merely selected text unless a successful action is recorded in local History. - Selection Assistant caches successful translation results locally for 7 days using hashed cache keys to speed repeated translations. -- Selection Assistant translation sends selected text and your custom Translate instructions to OpenAI when no local cached translation is available; AI pronunciation sends selected text to OpenAI. +- Selection Assistant translation sends selected text and your custom Translate instructions to your selected provider when no local cached translation is available; AI pronunciation sends selected text to OpenAI. - Inklet fetches the public model catalog from `models.dev` at most once per day. This request does not include your text, audio, API keys, or app settings. -- Do not send private text or audio to OpenAI unless you trust OpenAI's data handling policies. +- Do not send private text or audio to OpenAI or your selected provider unless you trust their data handling policies. ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 3960878..e9dcf28 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,7 +35,7 @@ Inklet 仅通过 GitHub Releases 检查更新。正式版大约每 24 小时从 1. 从 Applications 文件夹打开 Inklet。需要运行源码构建时,请在仓库根目录执行 `scripts/run-local-app.sh`,然后使用 `/Applications/Inklet Local.app`。 2. 点击菜单栏里的 Inklet 图标,打开 Settings。 3. 按 macOS 提示授予 Accessibility 权限。Inklet 用这一个通用权限读取选区、执行已配置的复制备用流程、回到上一个应用并粘贴确认后的结果。系统设置打开期间 Inklet 会留在后台;关闭系统设置后 Inklet 会返回 General 设置页。 -4. 在 General 中填写 OpenAI API key。Inklet 会用这一把 key 处理写作、实时听写、选区翻译和发音。 +4. 在 General 中选择服务商并填写它的 API key。默认的 OpenAI 用一把 key 处理写作、实时听写、选区翻译和发音。选择其他服务商时,写作和选区翻译使用该服务商;实时听写和发音仍需要在单独的可选字段中填写 OpenAI API key。 5. 在 Writing Assistant 中配置模型、写作快捷键、生成参数、Prompt 模式、听写长按快捷键和麦克风。高级听写只提供恢复模型,不提供端点设置;恢复端点不可编辑。实时模型由 Inklet 固定。 6. 可选:在 Selection Assistant 中配置翻译语言、强制取词模式、AI 发音声音和发音速度,并在设置中试听该声音。 7. 第一次有效长按听写快捷键时授予 Microphone 权限。只打开 Inklet、查看 Settings,或在原文编辑器之外按快捷键都不会请求权限。 @@ -79,9 +79,9 @@ Inklet 仅通过 GitHub Releases 检查更新。正式版大约每 24 小时从 - 默认关闭模拟 `Command+C`。菜单复制仍是安全的强制取词备用方式;对于没有可用复制菜单的 App,可以显式开启模拟复制这一高级备用选项,但它可能干扰游戏、远程桌面或虚拟机。 - 临时剪贴板读取会串行执行。只有同一次读取仍持有已观察到的复制结果时,Inklet 才恢复之前的快照;较新的剪贴板内容优先。双击复制触发是被动流程:它只读取用户已经完成的复制,不会再发一次合成复制,也不会恢复更旧的剪贴板数据。右键点击保留原生行为,不会开始选区读取。 - 不包含浏览器专用的选区代码,也不会请求浏览器 Automation。Chrome、Safari、Edge 和原生 App 使用同一条通用流程。 -- 可以编辑 Prompt 模式、OpenAI 模型、timeout、写作快捷键、听写长按快捷键、麦克风、恢复模型、选区翻译语言、选区 Translate prompt、强制取词模式、模拟复制权限、AI 发音声音和 AI 发音速度。 +- 可以编辑 Prompt 模式、服务商、模型、自定义 OpenAI 兼容接口地址、timeout、写作快捷键、听写长按快捷键、麦克风、恢复模型、选区翻译语言、选区 Translate prompt、强制取词模式、模拟复制权限、AI 发音声音和 AI 发音速度。 - 在本地 History 中查看成功的写作和选区结果,连续重复项会自动合并,原文/结果文本可选择,可一键复制结果或清空全部历史;旧版 Voice History 仍可读取。 -- 使用一把共享的 OpenAI API key 处理写作、实时听写、选区翻译和发音。 +- 写作和翻译可使用 OpenAI、Anthropic、Google Gemini、DeepSeek、阿里通义千问(Alibaba Qwen)、月之暗面 Kimi、智谱 GLM、MiniMax、硅基流动(SiliconFlow)、火山方舟(Volcengine Ark)、腾讯混元、百川、零一万物 Yi、xAI、Groq、Mistral、OpenRouter、Perplexity、Together AI、Cerebras,或任意自定义 OpenAI 兼容接口(例如本地模型服务)。实时听写和发音始终使用 OpenAI。 - 提供英文、简体中文、繁体中文、日文、韩文、西班牙文、法文、德文、葡萄牙文和意大利文应用界面。 - 紧凑选区菜单以及设置、写作界面中的受限控件会根据译文调整布局;切换语言时刷新已打开的界面并保留写作内容。 @@ -102,7 +102,7 @@ Inklet 是早期 MVP。当前仓库包含: - 推荐安装完整 Xcode,以获得 XCTest 支持。 - Accessibility 权限,用于通用选区读取、按设置启用的复制备用流程、回到上一个应用并粘贴生成结果。 - Microphone 权限,仅在有效长按听写快捷键时用于实时听写。 -- 一个 OpenAI API key。 +- OpenAI 或其他受支持服务商的 API key。实时听写和发音需要 OpenAI API key。 ## 从源码构建和运行 @@ -183,18 +183,18 @@ docs/ 手动测试说明和隐私政策 ## 隐私 -- Inklet 使用你配置的 OpenAI API key 调用 OpenAI,处理写作、实时听写、选区翻译和发音。 +- Inklet 会使用你在设置中选择的服务商及其 API key 处理写作和选区翻译。实时听写和发音始终使用你的 OpenAI API key 调用 OpenAI。 - 长按听写快捷键期间,活动麦克风音频会直接流式发送到 OpenAI Realtime 转写服务。Inklet 还会保存一份临时本地恢复录音;它在备用请求真正开始前始终只保存在本机,最多上传一次到 `https://api.openai.com/v1/audio/transcriptions`,使用实时听写所用的同一把现有 OpenAI API key,并在每个会话终止路径中删除。 -- OpenAI API key 存储在你的 Mac 本地。 +- API key 按服务商分别存储在你的 Mac 本地。 - Inklet 使用 Accessibility 权限完成通用选区读取、按设置启用的复制备用流程、回到上一个应用并粘贴文本。 - Inklet 只在写作助手原文编辑器内有效长按听写快捷键时使用 Microphone 权限。听写完成后仍停留在可编辑草稿,不会插入其他 App。 - Inklet 会临时使用剪贴板完成插入和已配置的强制取词备用读取。强制取词仅在 Inklet 的临时复制内容仍是当前内容时恢复原剪贴板,不会覆盖之后发生的外部剪贴板变化。 - Inklet 会把成功的写作和选区原文/结果作为本地 History 保存,直到你在 Settings 中清空;连续重复项会自动跳过。未经处理的听写草稿不会创建 History;已有的旧版 Voice 条目仍可在本机读取。 - 选区动作会捕获来源 App 和选区位置,在读取前和读取期间验证来源,然后通过 Accessibility 读取该 App 的当前选区。如果 Accessibility 没有返回选中文本,设置中的强制取词模式可以短暂调用菜单复制,并通过上面所述的受保护剪贴板事务读取复制出的文本。模拟 `Command+C` 默认关闭,只有显式开启高级选项后才会运行。你可以在设置中关闭强制取词。此流程不会发送针对浏览器的 Apple Events,也不会请求浏览器 Automation。选中文本后快速按两次 `Command+C`,会显式读取你已经完成的复制。Inklet 不会保存仅被选中的文本;只有成功完成的动作会进入本地 History。 - Selection Assistant 会把成功的翻译结果用哈希缓存键在本地缓存 7 天,以加速重复翻译。 -- 当本地没有可用缓存时,Selection Assistant 翻译会把选中文本和自定义 Translate 指令发送到 OpenAI;AI 发音会把选中文本发送到 OpenAI。 +- 当本地没有可用缓存时,Selection Assistant 翻译会把选中文本和自定义 Translate 指令发送到你选择的服务商;AI 发音会把选中文本发送到 OpenAI。 - Inklet 最多每天从 `models.dev` 获取一次公开模型目录。该请求不包含你的文本、音频、API keys 或应用设置。 -- 除非你信任 OpenAI 的数据处理政策,否则不要发送私密文本或音频。 +- 除非你信任 OpenAI 或所选服务商的数据处理政策,否则不要发送私密文本或音频。 ## 贡献 diff --git a/Sources/InkletApp/InkletLocalization.swift b/Sources/InkletApp/InkletLocalization.swift index 7c44617..73e580c 100644 --- a/Sources/InkletApp/InkletLocalization.swift +++ b/Sources/InkletApp/InkletLocalization.swift @@ -266,11 +266,13 @@ enum L10n { "settings.help.timeout": "Maximum request duration.", "settings.seconds": "%d sec", "settings.row.provider": "Provider", - "settings.help.provider": "Current provider.", + "settings.help.provider": "Used for writing and selection translation. Realtime dictation and pronunciation always use OpenAI.", "settings.row.openAIAPIKey": "OpenAI API Key", "settings.help.openAIAPIKey": "Stored locally on this Mac and shared by writing, realtime dictation, selection translation, and pronunciation. Clear the field to remove it.", + "settings.help.openAIAPIKey.voiceOnly": "Optional. Stored locally on this Mac and used only for realtime dictation and pronunciation. Clear the field to remove it.", + "settings.provider.customOpenAICompatible": "Custom (OpenAI Compatible)", "settings.row.apiKey": "API Key", - "settings.help.apiKey": "Stored locally on this Mac. Clear the field to remove it.", + "settings.help.apiKey": "Stored locally on this Mac and used for writing and selection translation. Clear the field to remove it.", "settings.row.endpoint": "Endpoint", "settings.help.endpoint": "Full OpenAI-compatible /chat/completions URL.", "settings.row.model": "Model", @@ -581,11 +583,13 @@ enum L10n { "settings.help.timeout": "请求最长等待时间。", "settings.seconds": "%d 秒", "settings.row.provider": "服务商", - "settings.help.provider": "当前服务商。", + "settings.help.provider": "用于写作和选区翻译。实时听写和发音始终使用 OpenAI。", "settings.row.openAIAPIKey": "OpenAI API Key", "settings.help.openAIAPIKey": "保存在本机,写作、实时听写、选区翻译和发音共用这一把 Key;清空输入框即可删除。", + "settings.help.openAIAPIKey.voiceOnly": "可选。保存在本机,仅用于实时听写和发音;清空输入框即可删除。", + "settings.provider.customOpenAICompatible": "自定义(OpenAI 兼容)", "settings.row.apiKey": "API Key", - "settings.help.apiKey": "API Key 会保存在本机;清空输入框即可删除。", + "settings.help.apiKey": "API Key 会保存在本机,用于写作和选区翻译;清空输入框即可删除。", "settings.row.endpoint": "接口地址", "settings.help.endpoint": "完整的 OpenAI-compatible /chat/completions URL。", "settings.row.model": "模型", @@ -934,8 +938,8 @@ enum L10n { "settings.row.hotkey": "快捷鍵", "settings.help.timeout": "請求最長等待時間。", "settings.seconds": "%d 秒", - "settings.help.provider": "目前服務商。", - "settings.help.apiKey": "API Key 會儲存在本機。", + "settings.help.provider": "用於寫作與選區翻譯。即時聽寫與發音一律使用 OpenAI。", + "settings.help.apiKey": "API 金鑰會儲存在本機,用於寫作與選區翻譯。清空欄位即可移除。", "settings.help.model.default": "預設:%@", "settings.mode.untitled": "未命名模式", "settings.mode.pick": "選擇一個 Prompt Mode", @@ -1015,6 +1019,8 @@ enum L10n { "settings.help.endpoint": "完整的 OpenAI 相容 /chat/completions 網址。", "settings.help.hotkey": "例如:⌥Space、Option+Space、Cmd+Space。", "settings.help.openAIAPIKey": "儲存在這台 Mac 上,供寫作、即時聽寫、選區翻譯與發音共用。清空欄位即可移除。", + "settings.help.openAIAPIKey.voiceOnly": "選用。儲存在這台 Mac 上,僅用於即時聽寫與發音。清空欄位即可移除。", + "settings.provider.customOpenAICompatible": "自訂(OpenAI 相容)", "settings.hotkey.pressKey": " 按鍵", "settings.hotkey.record": "錄製快捷鍵", "settings.hotkey.recording": "按下快捷鍵", @@ -1249,8 +1255,8 @@ enum L10n { "settings.row.hotkey": "ホットキー", "settings.help.timeout": "リクエストの最大待ち時間。", "settings.seconds": "%d 秒", - "settings.help.provider": "現在のプロバイダー。", - "settings.help.apiKey": "この Mac にローカル保存されます。", + "settings.help.provider": "文章作成と選択テキストの翻訳に使用します。リアルタイム音声入力と読み上げには常に OpenAI を使用します。", + "settings.help.apiKey": "この Mac に保存され、文章作成と選択テキストの翻訳に使用されます。削除するには欄を空にしてください。", "settings.help.model.default": "既定: %@", "settings.mode.untitled": "無題のモード", "settings.mode.pick": "Prompt Mode を選択", @@ -1330,6 +1336,8 @@ enum L10n { "settings.help.endpoint": "OpenAI 互換の /chat/completions の完全な URL。", "settings.help.hotkey": "例:⌥Space、Option+Space、Cmd+Space。", "settings.help.openAIAPIKey": "この Mac に保存され、文章作成、リアルタイム音声入力、選択テキストの翻訳・読み上げに使用されます。削除するには欄を空にしてください。", + "settings.help.openAIAPIKey.voiceOnly": "任意。この Mac に保存され、リアルタイム音声入力と読み上げにのみ使用されます。削除するには欄を空にしてください。", + "settings.provider.customOpenAICompatible": "カスタム(OpenAI 互換)", "settings.hotkey.pressKey": " キー", "settings.hotkey.record": "ショートカットを記録", "settings.hotkey.recording": "ショートカットを入力", @@ -1564,8 +1572,8 @@ enum L10n { "settings.row.hotkey": "단축키", "settings.help.timeout": "요청 최대 대기 시간.", "settings.seconds": "%d초", - "settings.help.provider": "현재 provider.", - "settings.help.apiKey": "이 Mac에 로컬로 저장됩니다.", + "settings.help.provider": "글쓰기와 선택 영역 번역에 사용됩니다. 실시간 받아쓰기와 발음에는 항상 OpenAI가 사용됩니다.", + "settings.help.apiKey": "이 Mac에 저장되며 글쓰기와 선택 영역 번역에 사용됩니다. 필드를 비우면 삭제됩니다.", "settings.help.model.default": "기본값: %@", "settings.mode.untitled": "이름 없는 모드", "settings.mode.pick": "Prompt Mode 선택", @@ -1645,6 +1653,8 @@ enum L10n { "settings.help.endpoint": "OpenAI 호환 /chat/completions 전체 URL입니다.", "settings.help.hotkey": "예: ⌥Space, Option+Space, Cmd+Space.", "settings.help.openAIAPIKey": "이 Mac에 저장되며 글쓰기, 실시간 받아쓰기, 선택 영역 번역 및 발음에 함께 사용됩니다. 필드를 비우면 삭제됩니다.", + "settings.help.openAIAPIKey.voiceOnly": "선택 사항입니다. 이 Mac에 저장되며 실시간 받아쓰기와 발음에만 사용됩니다. 필드를 비우면 삭제됩니다.", + "settings.provider.customOpenAICompatible": "사용자 지정(OpenAI 호환)", "settings.hotkey.pressKey": " 키", "settings.hotkey.record": "단축키 기록", "settings.hotkey.recording": "단축키 누르기", @@ -1879,8 +1889,8 @@ enum L10n { "settings.row.hotkey": "Atajo", "settings.help.timeout": "Duración máxima de la solicitud.", "settings.seconds": "%d s", - "settings.help.provider": "Proveedor actual.", - "settings.help.apiKey": "Se guarda localmente en este Mac.", + "settings.help.provider": "Se usa para la escritura y la traducción de selecciones. El dictado en tiempo real y la pronunciación siempre usan OpenAI.", + "settings.help.apiKey": "Se guarda en este Mac y se usa para la escritura y la traducción. Vacía el campo para eliminarla.", "settings.help.model.default": "Predeterminado: %@", "settings.mode.untitled": "Modo sin título", "settings.mode.pick": "Selecciona un modo de prompt", @@ -1960,6 +1970,8 @@ enum L10n { "settings.help.endpoint": "URL completa de /chat/completions compatible con OpenAI.", "settings.help.hotkey": "Por ejemplo: ⌥Space, Option+Space, Cmd+Space.", "settings.help.openAIAPIKey": "Se guarda en este Mac y se comparte entre escritura, dictado en tiempo real, traducción y pronunciación. Vacía el campo para eliminarla.", + "settings.help.openAIAPIKey.voiceOnly": "Opcional. Se guarda en este Mac y solo se usa para el dictado en tiempo real y la pronunciación. Vacía el campo para eliminarla.", + "settings.provider.customOpenAICompatible": "Personalizado (compatible con OpenAI)", "settings.hotkey.pressKey": " tecla", "settings.hotkey.record": "Grabar atajo", "settings.hotkey.recording": "Pulsa el atajo", @@ -2194,8 +2206,8 @@ enum L10n { "settings.row.hotkey": "Raccourci", "settings.help.timeout": "Durée maximale de la requête.", "settings.seconds": "%d s", - "settings.help.provider": "Fournisseur actuel.", - "settings.help.apiKey": "Enregistrée localement sur ce Mac.", + "settings.help.provider": "Utilisé pour la rédaction et la traduction de sélection. La dictée en temps réel et la prononciation utilisent toujours OpenAI.", + "settings.help.apiKey": "Stockée sur ce Mac et utilisée pour la rédaction et la traduction. Videz le champ pour la supprimer.", "settings.help.model.default": "Par défaut : %@", "settings.mode.untitled": "Mode sans titre", "settings.mode.pick": "Sélectionnez un mode de prompt", @@ -2275,6 +2287,8 @@ enum L10n { "settings.help.endpoint": "URL complète de /chat/completions compatible avec OpenAI.", "settings.help.hotkey": "Par exemple : ⌥Space, Option+Space, Cmd+Space.", "settings.help.openAIAPIKey": "Stockée sur ce Mac et partagée par la rédaction, la dictée en temps réel, la traduction et la prononciation. Videz le champ pour la supprimer.", + "settings.help.openAIAPIKey.voiceOnly": "Facultative. Stockée sur ce Mac et utilisée uniquement pour la dictée en temps réel et la prononciation. Videz le champ pour la supprimer.", + "settings.provider.customOpenAICompatible": "Personnalisé (compatible OpenAI)", "settings.hotkey.pressKey": " touche", "settings.hotkey.record": "Enregistrer un raccourci", "settings.hotkey.recording": "Appuyez sur le raccourci", @@ -2509,8 +2523,8 @@ enum L10n { "settings.row.hotkey": "Tastenkürzel", "settings.help.timeout": "Maximale Dauer der Anfrage.", "settings.seconds": "%d s", - "settings.help.provider": "Aktueller Anbieter.", - "settings.help.apiKey": "Wird lokal auf diesem Mac gespeichert.", + "settings.help.provider": "Wird für Schreiben und Übersetzung verwendet. Echtzeitdiktat und Aussprache nutzen immer OpenAI.", + "settings.help.apiKey": "Wird auf diesem Mac gespeichert und für Schreiben und Übersetzung verwendet. Zum Entfernen das Feld leeren.", "settings.help.model.default": "Standard: %@", "settings.mode.untitled": "Unbenannter Modus", "settings.mode.pick": "Prompt-Modus auswählen", @@ -2590,6 +2604,8 @@ enum L10n { "settings.help.endpoint": "Vollständige OpenAI-kompatible /chat/completions-URL.", "settings.help.hotkey": "Zum Beispiel: ⌥Space, Option+Space, Cmd+Space.", "settings.help.openAIAPIKey": "Wird auf diesem Mac gespeichert und für Schreiben, Echtzeitdiktat, Übersetzung und Aussprache verwendet. Zum Entfernen das Feld leeren.", + "settings.help.openAIAPIKey.voiceOnly": "Optional. Wird auf diesem Mac gespeichert und nur für Echtzeitdiktat und Aussprache verwendet. Zum Entfernen das Feld leeren.", + "settings.provider.customOpenAICompatible": "Benutzerdefiniert (OpenAI-kompatibel)", "settings.hotkey.pressKey": " Taste", "settings.hotkey.record": "Kurzbefehl aufnehmen", "settings.hotkey.recording": "Kurzbefehl drücken", @@ -2824,8 +2840,8 @@ enum L10n { "settings.row.hotkey": "Atalho", "settings.help.timeout": "Duração máxima da solicitação.", "settings.seconds": "%d s", - "settings.help.provider": "Provedor atual.", - "settings.help.apiKey": "Salva localmente neste Mac.", + "settings.help.provider": "Usado na escrita e na tradução de seleções. O ditado em tempo real e a pronúncia sempre usam a OpenAI.", + "settings.help.apiKey": "Armazenada neste Mac e usada na escrita e na tradução. Esvazie o campo para removê-la.", "settings.help.model.default": "Padrão: %@", "settings.mode.untitled": "Modo sem título", "settings.mode.pick": "Selecione um modo de prompt", @@ -2905,6 +2921,8 @@ enum L10n { "settings.help.endpoint": "URL completo de /chat/completions compatível com OpenAI.", "settings.help.hotkey": "Por exemplo: ⌥Space, Option+Space, Cmd+Space.", "settings.help.openAIAPIKey": "Armazenada neste Mac e compartilhada entre escrita, ditado em tempo real, tradução e pronúncia. Esvazie o campo para removê-la.", + "settings.help.openAIAPIKey.voiceOnly": "Opcional. Armazenada neste Mac e usada apenas no ditado em tempo real e na pronúncia. Esvazie o campo para removê-la.", + "settings.provider.customOpenAICompatible": "Personalizado (compatível com OpenAI)", "settings.hotkey.pressKey": " tecla", "settings.hotkey.record": "Gravar atalho", "settings.hotkey.recording": "Pressione o atalho", @@ -3139,8 +3157,8 @@ enum L10n { "settings.row.hotkey": "Scorciatoia", "settings.help.timeout": "Durata massima della richiesta.", "settings.seconds": "%d s", - "settings.help.provider": "Provider attuale.", - "settings.help.apiKey": "Salvata localmente su questo Mac.", + "settings.help.provider": "Usato per la scrittura e la traduzione della selezione. La dettatura in tempo reale e la pronuncia usano sempre OpenAI.", + "settings.help.apiKey": "Memorizzata su questo Mac e usata per la scrittura e la traduzione. Svuota il campo per rimuoverla.", "settings.help.model.default": "Predefinito: %@", "settings.mode.untitled": "Modalità senza titolo", "settings.mode.pick": "Seleziona una modalità prompt", @@ -3220,6 +3238,8 @@ enum L10n { "settings.help.endpoint": "URL completo di /chat/completions compatibile con OpenAI.", "settings.help.hotkey": "Ad esempio: ⌥Space, Option+Space, Cmd+Space.", "settings.help.openAIAPIKey": "Memorizzata su questo Mac e condivisa tra scrittura, dettatura in tempo reale, traduzione e pronuncia. Svuota il campo per rimuoverla.", + "settings.help.openAIAPIKey.voiceOnly": "Facoltativa. Memorizzata su questo Mac e usata solo per la dettatura in tempo reale e la pronuncia. Svuota il campo per rimuoverla.", + "settings.provider.customOpenAICompatible": "Personalizzato (compatibile con OpenAI)", "settings.hotkey.pressKey": " tasto", "settings.hotkey.record": "Registra scorciatoia", "settings.hotkey.recording": "Premi la scorciatoia", diff --git a/Sources/InkletApp/SettingsView.swift b/Sources/InkletApp/SettingsView.swift index 12652dc..8c1116b 100644 --- a/Sources/InkletApp/SettingsView.swift +++ b/Sources/InkletApp/SettingsView.swift @@ -332,10 +332,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 22) { settingsPanel { - settingsRow(L10n.text("settings.row.openAIAPIKey"), help: L10n.text("settings.help.openAIAPIKey")) { - SecureField(LLMProviderPreset.openAI.apiKeyPlaceholder, text: $model.providerAPIKey) - .textFieldStyle(.roundedBorder) - } + providerRows settingsRow(L10n.text("settings.row.language"), help: L10n.text("settings.help.language")) { Picker("", selection: $model.interfaceLanguage) { @@ -364,6 +361,51 @@ struct SettingsView: View { } } + @ViewBuilder + private var providerRows: some View { + let selectedProviderBinding = Binding( + get: { model.config.providerID }, + set: { model.selectProvider($0) } + ) + + settingsRow(L10n.text("settings.row.provider"), help: L10n.text("settings.help.provider")) { + Picker("", selection: selectedProviderBinding) { + ForEach(LLMProviderPreset.all) { preset in + Text(model.providerDisplayName(preset)).tag(preset.id) + } + } + .labelsHidden() + .frame(maxWidth: 320, alignment: .leading) + } + + if model.usesOpenAIForWriting { + settingsRow(L10n.text("settings.row.openAIAPIKey"), help: L10n.text("settings.help.openAIAPIKey")) { + SecureField(LLMProviderPreset.openAI.apiKeyPlaceholder, text: $model.providerAPIKey) + .textFieldStyle(.roundedBorder) + } + } else { + settingsRow(L10n.text("settings.row.apiKey"), help: L10n.text("settings.help.apiKey")) { + SecureField(model.selectedProvider.apiKeyPlaceholder, text: $model.providerAPIKey) + .textFieldStyle(.roundedBorder) + } + + if model.isCustomOpenAICompatibleProvider { + settingsRow(L10n.text("settings.row.endpoint"), help: L10n.text("settings.help.endpoint")) { + TextField( + LLMProviderPreset.customOpenAICompatible.endpoint.absoluteString, + text: $model.config.customOpenAICompatibleEndpoint + ) + .textFieldStyle(.roundedBorder) + } + } + + settingsRow(L10n.text("settings.row.openAIAPIKey"), help: L10n.text("settings.help.openAIAPIKey.voiceOnly")) { + SecureField(LLMProviderPreset.openAI.apiKeyPlaceholder, text: $model.openAIAPIKey) + .textFieldStyle(.roundedBorder) + } + } + } + private var migrationNoticeCard: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 10) { diff --git a/Sources/InkletApp/SettingsViewModel.swift b/Sources/InkletApp/SettingsViewModel.swift index 18f7ef7..874bc00 100644 --- a/Sources/InkletApp/SettingsViewModel.swift +++ b/Sources/InkletApp/SettingsViewModel.swift @@ -20,6 +20,7 @@ final class SettingsViewModel: ObservableObject { @Published var config: AppConfig @Published var message: String @Published var providerAPIKey: String + @Published var openAIAPIKey: String @Published var selectedPromptModeID: String @Published var interfaceLanguage: InterfaceLanguage @Published var cachedProviderModels: [String: [String]] @@ -46,6 +47,7 @@ final class SettingsViewModel: ObservableObject { private var modelCatalogRefreshTaskID: UUID? private var savedConfig: AppConfig private var savedProviderAPIKey: String + private var savedOpenAIAPIKey: String private var savedInterfaceLanguage: InterfaceLanguage static let customModelMenuID = "__custom_model__" @@ -57,22 +59,24 @@ final class SettingsViewModel: ObservableObject { historyStore: any HistoryStore = JSONLHistoryStore(), microphoneDeviceCatalog: MicrophoneDeviceCatalog = MicrophoneDeviceCatalog() ) { - var loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() + let 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) ?? "" + let loadedProviderAPIKey = apiKeyStore.loadAPIKey(forProviderID: loadedConfig.providerID) ?? "" self.providerAPIKey = loadedProviderAPIKey self.savedProviderAPIKey = loadedProviderAPIKey + let loadedOpenAIAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) ?? "" + self.openAIAPIKey = loadedOpenAIAPIKey + self.savedOpenAIAPIKey = loadedOpenAIAPIKey self.selectedPromptModeID = loadedConfig.promptModes.sorted { $0.sortOrder < $1.sortOrder }.first?.id ?? PromptMode.translateToEnglishID self.cachedProviderModels = Dictionary( @@ -99,7 +103,35 @@ final class SettingsViewModel: ObservableObject { } var selectedProvider: LLMProviderPreset { - LLMProviderPreset.openAI + LLMProviderPreset.preset(id: config.providerID) + } + + var usesOpenAIForWriting: Bool { + selectedProvider.id == LLMProviderPreset.openAI.id + } + + func providerDisplayName(_ preset: LLMProviderPreset) -> String { + preset.id == LLMProviderPreset.customOpenAICompatible.id + ? L10n.text("settings.provider.customOpenAICompatible") + : preset.name + } + + func selectProvider(_ providerID: String) { + guard !isMigrationMaintenanceActive, providerID != config.providerID else { return } + // Persist a key typed for the previous provider before its field is replaced. + _ = flushPendingEdits() + + let preset = LLMProviderPreset.preset(id: providerID) + let loadedProviderAPIKey = apiKeyStore.loadAPIKey(forProviderID: preset.id) ?? "" + let loadedOpenAIAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) ?? "" + providerAPIKey = loadedProviderAPIKey + savedProviderAPIKey = loadedProviderAPIKey + openAIAPIKey = loadedOpenAIAPIKey + savedOpenAIAPIKey = loadedOpenAIAPIKey + isEditingCustomModel = false + config.providerID = preset.id + config.model = preset.defaultModel + save() } var filteredHistoryItems: [HistoryItem] { @@ -110,7 +142,7 @@ final class SettingsViewModel: ObservableObject { } var isCustomOpenAICompatibleProvider: Bool { - false + selectedProvider.id == LLMProviderPreset.customOpenAICompatible.id } var selectedProviderModelOptions: [String] { @@ -121,7 +153,7 @@ final class SettingsViewModel: ObservableObject { var seen = Set() var options: [String] = [] - for modelID in cachedProviderModels[LLMProviderPreset.openAI.id] ?? [] { + for modelID in cachedProviderModels[selectedProvider.id] ?? [] { if seen.insert(modelID).inserted { options.append(modelID) } @@ -463,10 +495,12 @@ final class SettingsViewModel: ObservableObject { func save() -> Bool { guard !isMigrationMaintenanceActive else { return false } let trimmedKey = providerAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedOpenAIKey = openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) let shouldSaveConfig = config != savedConfig let shouldSaveProviderAPIKey = providerAPIKey != savedProviderAPIKey + let shouldSaveOpenAIAPIKey = hasPendingOpenAIAPIKeyEdit let shouldSaveInterfaceLanguage = interfaceLanguage != savedInterfaceLanguage - guard shouldSaveConfig || shouldSaveProviderAPIKey || shouldSaveInterfaceLanguage else { + guard shouldSaveConfig || shouldSaveProviderAPIKey || shouldSaveOpenAIAPIKey || shouldSaveInterfaceLanguage else { return true } @@ -480,8 +514,6 @@ final class SettingsViewModel: ObservableObject { message = L10n.text("settings.error.modelRequired") return false } - config.providerID = LLMProviderPreset.openAI.id - _ = try Hotkey.parse(config.hotkey) try configStore.save(config) savedConfig = config @@ -493,13 +525,19 @@ final class SettingsViewModel: ObservableObject { } if shouldSaveProviderAPIKey { - if trimmedKey.isEmpty { - try apiKeyStore.deleteAPIKey(forProviderID: LLMProviderPreset.openAI.id) - } else { - try apiKeyStore.saveAPIKey(trimmedKey, forProviderID: LLMProviderPreset.openAI.id) - } + try storeAPIKey(trimmedKey, forProviderID: config.providerID) providerAPIKey = trimmedKey savedProviderAPIKey = trimmedKey + if usesOpenAIForWriting { + openAIAPIKey = trimmedKey + savedOpenAIAPIKey = trimmedKey + } + } + + if shouldSaveOpenAIAPIKey { + try storeAPIKey(trimmedOpenAIKey, forProviderID: LLMProviderPreset.openAI.id) + openAIAPIKey = trimmedOpenAIKey + savedOpenAIAPIKey = trimmedOpenAIKey } message = L10n.text("settings.saved") NotificationCenter.default.post(name: .appConfigDidSave, object: nil) @@ -513,14 +551,22 @@ final class SettingsViewModel: ObservableObject { } } + private func storeAPIKey(_ apiKey: String, forProviderID providerID: String) throws { + if apiKey.isEmpty { + try apiKeyStore.deleteAPIKey(forProviderID: providerID) + } else { + try apiKeyStore.saveAPIKey(apiKey, forProviderID: providerID) + } + } + private func installAutoSave() { guard !isMigrationMaintenanceActive, autoSaveCancellable == nil else { return } - autoSaveCancellable = Publishers.CombineLatest3($config, $providerAPIKey, $interfaceLanguage) + autoSaveCancellable = Publishers.CombineLatest4($config, $providerAPIKey, $openAIAPIKey, $interfaceLanguage) .dropFirst() .debounce(for: .milliseconds(450), scheduler: RunLoop.main) - .sink { [weak self] _, _, _ in + .sink { [weak self] _, _, _, _ in guard let self else { return } self.save() } @@ -537,9 +583,15 @@ final class SettingsViewModel: ObservableObject { private var hasPendingEdits: Bool { config != savedConfig || providerAPIKey != savedProviderAPIKey + || hasPendingOpenAIAPIKeyEdit || interfaceLanguage != savedInterfaceLanguage } + // The separate OpenAI key field only exists while another provider handles writing. + private var hasPendingOpenAIAPIKeyEdit: Bool { + !usesOpenAIForWriting && openAIAPIKey != savedOpenAIAPIKey + } + func setMigrationMaintenanceActive(_ isActive: Bool) { guard isMigrationMaintenanceActive != isActive else { return } isMigrationMaintenanceActive = isActive diff --git a/Sources/InkletApp/SettingsWindowController.swift b/Sources/InkletApp/SettingsWindowController.swift index ec068ab..44df947 100644 --- a/Sources/InkletApp/SettingsWindowController.swift +++ b/Sources/InkletApp/SettingsWindowController.swift @@ -278,7 +278,7 @@ final class SettingsWindowController: NSWindowController { return } - let providerAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) + let providerAPIKey = apiKeyStore.loadAPIKey(forProviderID: configuredProviderID) guard !OnboardingPolicy.shouldShowProviderSetupAfterReturningFromPermissionSettings( providerAPIKey: providerAPIKey ) else { @@ -290,6 +290,10 @@ final class SettingsWindowController: NSWindowController { window?.makeKeyAndOrderFront(nil) } + private var configuredProviderID: String { + ((try? configStore.load()) ?? AppConfig.defaultConfig()).providerID + } + private func schedulePermissionMonitor(for permission: PermissionSettingsDestination) { permissionMonitorTask?.cancel() permissionMonitorTask = Task { @MainActor in @@ -318,7 +322,7 @@ final class SettingsWindowController: NSWindowController { } private func openPopoverAfterCompletingOnboardingIfNeeded() { - let providerAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) + let providerAPIKey = apiKeyStore.loadAPIKey(forProviderID: configuredProviderID) guard OnboardingPolicy.shouldOpenPopoverAfterClosingSettings( didOpenAccessibilitySettings: didOpenAccessibilitySettings, isAccessibilityTrusted: AccessibilityPermissionService().isTrusted, diff --git a/Sources/InkletCore/ConfigStore.swift b/Sources/InkletCore/ConfigStore.swift index 6a8e213..386cfef 100644 --- a/Sources/InkletCore/ConfigStore.swift +++ b/Sources/InkletCore/ConfigStore.swift @@ -9,9 +9,10 @@ public enum AppAppearance: String, Codable, Equatable, Sendable, CaseIterable, I } public struct AppConfig: Codable, Equatable, Sendable { - public static let currentVersion = 4 + public static let currentVersion = 5 private static let lunaDefaultMigrationVersion = 3 + private static let providerSelectionVersion = 5 private static let formerOpenAIDefaultModel = "gpt-5.4-mini" public var version: Int @@ -61,7 +62,15 @@ public struct AppConfig: Codable, Equatable, Sendable { } public var resolvedProviderPreset: LLMProviderPreset { - LLMProviderPreset.openAI + var preset = LLMProviderPreset.preset(id: providerID) + if preset.id == LLMProviderPreset.customOpenAICompatible.id, + let endpoint = URL(string: customOpenAICompatibleEndpoint.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = endpoint.scheme?.lowercased(), + scheme == "https" || scheme == "http", + endpoint.host != nil { + preset.endpoint = endpoint + } + return preset } public var promptModeStore: PromptModeStore { @@ -105,10 +114,17 @@ public struct AppConfig: Codable, Equatable, Sendable { let decodedVersion = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 version = max(decodedVersion, AppConfig.currentVersion) let decodedProviderID = try container.decodeIfPresent(String.self, forKey: .providerID) ?? defaults.providerID - providerID = LLMProviderPreset.openAI.id - if decodedProviderID == LLMProviderPreset.openAI.id { - let decodedModel = try container.decodeIfPresent(String.self, forKey: .model) ?? defaults.model - if decodedVersion < AppConfig.lunaDefaultMigrationVersion, + // Configurations saved before provider selection always wrote with OpenAI. + let resolvedProviderID = decodedVersion >= AppConfig.providerSelectionVersion + && LLMProviderPreset.all.contains(where: { $0.id == decodedProviderID }) + ? decodedProviderID + : LLMProviderPreset.openAI.id + providerID = resolvedProviderID + if resolvedProviderID == decodedProviderID { + let decodedModel = try container.decodeIfPresent(String.self, forKey: .model) + ?? LLMProviderPreset.preset(id: resolvedProviderID).defaultModel + if resolvedProviderID == LLMProviderPreset.openAI.id, + decodedVersion < AppConfig.lunaDefaultMigrationVersion, decodedModel == AppConfig.formerOpenAIDefaultModel { model = defaults.model } else { diff --git a/Tests/InkletCoreTests/ConfigStoreTests.swift b/Tests/InkletCoreTests/ConfigStoreTests.swift index 046a12d..d5d0d23 100644 --- a/Tests/InkletCoreTests/ConfigStoreTests.swift +++ b/Tests/InkletCoreTests/ConfigStoreTests.swift @@ -223,7 +223,7 @@ final class ConfigStoreTests: XCTestCase { let config = try JSONDecoder().decode(AppConfig.self, from: data) - XCTAssertEqual(AppConfig.currentVersion, 4) + XCTAssertEqual(AppConfig.currentVersion, 5) XCTAssertEqual(config.version, AppConfig.currentVersion) XCTAssertEqual(config.model, "gpt-5.6-luna") } @@ -294,7 +294,7 @@ final class ConfigStoreTests: XCTestCase { XCTAssertNil(encodedJSON["temperature"]) } - func testAppConfigMigratesVersionsOneThroughThreeToVersionFour() throws { + func testAppConfigMigratesVersionsOneThroughThreeToCurrentVersion() throws { for savedVersion in 1...3 { let data = try JSONSerialization.data(withJSONObject: [ "version": savedVersion, @@ -309,8 +309,8 @@ final class ConfigStoreTests: XCTestCase { let config = try JSONDecoder().decode(AppConfig.self, from: data) - XCTAssertEqual(AppConfig.currentVersion, 4) - XCTAssertEqual(config.version, 4) + XCTAssertEqual(AppConfig.currentVersion, 5) + XCTAssertEqual(config.version, AppConfig.currentVersion) XCTAssertEqual(config.voiceInput.shortcut, .rightCommand) XCTAssertEqual( config.voiceInput.speechEndpoint, @@ -337,6 +337,76 @@ final class ConfigStoreTests: XCTestCase { XCTAssertEqual(config.resolvedProviderPreset.id, LLMProviderPreset.openAI.id) } + func testVersionFourConfigKeepsWritingOnOpenAI() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "version": 4, + "providerID": "deepseek", + "model": "deepseek-chat" + ]) + + let config = try JSONDecoder().decode(AppConfig.self, from: data) + + XCTAssertEqual(config.providerID, LLMProviderPreset.openAI.id) + XCTAssertEqual(config.model, LLMProviderPreset.openAI.defaultModel) + } + + func testCurrentConfigKeepsSelectedProviderAndModel() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "version": 5, + "providerID": "deepseek", + "model": "deepseek-chat" + ]) + + let config = try JSONDecoder().decode(AppConfig.self, from: data) + + XCTAssertEqual(config.providerID, "deepseek") + XCTAssertEqual(config.model, "deepseek-chat") + XCTAssertEqual(config.resolvedProviderPreset.id, "deepseek") + } + + func testCurrentConfigUsesProviderDefaultModelWhenModelIsMissing() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "version": 5, + "providerID": "anthropic" + ]) + + let config = try JSONDecoder().decode(AppConfig.self, from: data) + + XCTAssertEqual(config.providerID, "anthropic") + XCTAssertEqual(config.model, LLMProviderPreset.preset(id: "anthropic").defaultModel) + } + + func testCurrentConfigFallsBackToOpenAIForUnknownProvider() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "version": 5, + "providerID": "retired-provider", + "model": "retired-model" + ]) + + let config = try JSONDecoder().decode(AppConfig.self, from: data) + + XCTAssertEqual(config.providerID, LLMProviderPreset.openAI.id) + XCTAssertEqual(config.model, LLMProviderPreset.openAI.defaultModel) + } + + func testSelectedProviderRoundTripsThroughUserDefaults() throws { + let suiteName = "ConfigStoreTests.\(UUID().uuidString)" + let userDefaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { + userDefaults.removePersistentDomain(forName: suiteName) + } + let store = UserDefaultsConfigStore(userDefaults: userDefaults) + var config = AppConfig.defaultConfig() + config.providerID = "gemini" + config.model = "gemini-flash-latest" + + try store.save(config) + let loadedConfig = try store.load() + + XCTAssertEqual(loadedConfig, config) + XCTAssertEqual(loadedConfig.resolvedProviderPreset.id, "gemini") + } + func testConfigDecodeMigratesLegacyModesToFocusedDefaults() throws { let data = """ { @@ -507,17 +577,40 @@ final class ConfigStoreTests: XCTestCase { XCTAssertEqual(mode.systemPrompt, "My custom cleanup prompt.") } - func testResolvedProviderPresetAlwaysUsesOpenAI() { + func testResolvedProviderPresetFollowsSelectedProvider() { + var config = AppConfig.defaultConfig() + XCTAssertEqual(config.resolvedProviderPreset, LLMProviderPreset.openAI) + + config.providerID = "anthropic" + XCTAssertEqual(config.resolvedProviderPreset, LLMProviderPreset.preset(id: "anthropic")) + } + + func testResolvedProviderPresetUsesCustomEndpoint() { var config = AppConfig.defaultConfig() config.providerID = LLMProviderPreset.customOpenAICompatible.id - config.customOpenAICompatibleEndpoint = "http://127.0.0.1:1234/v1/chat/completions" + config.customOpenAICompatibleEndpoint = " http://127.0.0.1:1234/v1/chat/completions " + XCTAssertEqual(config.resolvedProviderPreset.id, LLMProviderPreset.customOpenAICompatible.id) XCTAssertEqual( - config.resolvedProviderPreset, - LLMProviderPreset.openAI + config.resolvedProviderPreset.endpoint.absoluteString, + "http://127.0.0.1:1234/v1/chat/completions" ) } + func testResolvedProviderPresetIgnoresInvalidCustomEndpoint() { + var config = AppConfig.defaultConfig() + config.providerID = LLMProviderPreset.customOpenAICompatible.id + + for endpoint in ["", "not a url", "ftp://example.com/v1/chat/completions"] { + config.customOpenAICompatibleEndpoint = endpoint + XCTAssertEqual( + config.resolvedProviderPreset.endpoint, + LLMProviderPreset.customOpenAICompatible.endpoint, + endpoint + ) + } + } + func testVisibleModeIDKeepsVisiblePreferredMode() { var config = AppConfig.defaultConfig() config.promptModes = [ diff --git a/Tests/InkletCoreTests/SettingsProviderSelectionTests.swift b/Tests/InkletCoreTests/SettingsProviderSelectionTests.swift new file mode 100644 index 0000000..0a0e30b --- /dev/null +++ b/Tests/InkletCoreTests/SettingsProviderSelectionTests.swift @@ -0,0 +1,141 @@ +import XCTest +import Security +@testable import Inklet +@testable import InkletCore + +final class SettingsProviderSelectionTests: XCTestCase { + @MainActor + func testSwitchingProviderLoadsItsKeyAndDefaultModel() throws { + let keychain = ProviderSelectionKeychainClient(values: [ + "openai": "sk-openai", + "deepseek": "sk-deepseek" + ]) + let (model, configStore) = try makeModel(keychain: keychain) + + XCTAssertTrue(model.usesOpenAIForWriting) + XCTAssertEqual(model.providerAPIKey, "sk-openai") + + model.selectProvider("deepseek") + + XCTAssertFalse(model.usesOpenAIForWriting) + XCTAssertEqual(model.config.providerID, "deepseek") + XCTAssertEqual(model.config.model, LLMProviderPreset.preset(id: "deepseek").defaultModel) + XCTAssertEqual(model.providerAPIKey, "sk-deepseek") + XCTAssertEqual(model.openAIAPIKey, "sk-openai") + XCTAssertEqual(try configStore.load().resolvedProviderPreset.id, "deepseek") + } + + @MainActor + func testWritingAndVoiceKeysSaveToTheirOwnProviders() throws { + let keychain = ProviderSelectionKeychainClient() + let (model, _) = try makeModel(keychain: keychain) + + model.selectProvider("anthropic") + model.providerAPIKey = " sk-ant " + model.openAIAPIKey = " sk-openai " + + XCTAssertTrue(model.flushPendingEdits()) + XCTAssertEqual(keychain.value(forAccount: "anthropic"), "sk-ant") + XCTAssertEqual(keychain.value(forAccount: "openai"), "sk-openai") + } + + @MainActor + func testSwitchingProviderKeepsKeyTypedForPreviousProvider() throws { + let keychain = ProviderSelectionKeychainClient() + let (model, _) = try makeModel(keychain: keychain) + + model.providerAPIKey = "sk-openai" + model.selectProvider("gemini") + + XCTAssertEqual(keychain.value(forAccount: "openai"), "sk-openai") + XCTAssertEqual(model.openAIAPIKey, "sk-openai") + XCTAssertEqual(model.providerAPIKey, "") + + model.selectProvider(LLMProviderPreset.openAI.id) + + XCTAssertTrue(model.usesOpenAIForWriting) + XCTAssertEqual(model.providerAPIKey, "sk-openai") + XCTAssertEqual(model.config.model, LLMProviderPreset.openAI.defaultModel) + } + + @MainActor + private func makeModel( + keychain: ProviderSelectionKeychainClient + ) throws -> (SettingsViewModel, UserDefaultsConfigStore) { + let suiteName = "SettingsProviderSelectionTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + let historyURL = FileManager.default.temporaryDirectory + .appendingPathComponent("SettingsProviderSelection-\(UUID().uuidString).jsonl") + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: historyURL) + } + + let configStore = UserDefaultsConfigStore(userDefaults: defaults) + let model = SettingsViewModel( + configStore: configStore, + apiKeyStore: LocalAPIKeyStore { providerID in + KeychainStore(service: "provider-selection-test", account: providerID, client: keychain) + }, + modelCatalogService: ModelCatalogService( + userDefaults: defaults, + bundledFallbackData: { nil } + ), + historyStore: JSONLHistoryStore(fileURL: historyURL) + ) + return (model, configStore) + } +} + +private final class ProviderSelectionKeychainClient: KeychainClient, @unchecked Sendable { + private var values: [String: String] + + init(values: [String: String] = [:]) { + self.values = values + } + + func value(forAccount account: String) -> String? { + values[account] + } + + func copyMatching(_ query: [String: Any], result: UnsafeMutablePointer?) -> OSStatus { + guard let account = query[kSecAttrAccount as String] as? String, + let value = values[account] + else { + return errSecItemNotFound + } + result?.pointee = Data(value.utf8) as CFData + return errSecSuccess + } + + func update(_ query: [String: Any], attributes: [String: Any]) -> OSStatus { + guard let account = query[kSecAttrAccount as String] as? String, + values[account] != nil, + let data = attributes[kSecValueData as String] as? Data + else { + return errSecItemNotFound + } + values[account] = String(decoding: data, as: UTF8.self) + return errSecSuccess + } + + func add(_ query: [String: Any], result: UnsafeMutablePointer?) -> OSStatus { + guard let account = query[kSecAttrAccount as String] as? String, + let data = query[kSecValueData as String] as? Data + else { + return errSecParam + } + guard values[account] == nil else { return errSecDuplicateItem } + values[account] = String(decoding: data, as: UTF8.self) + return errSecSuccess + } + + func delete(_ query: [String: Any]) -> OSStatus { + guard let account = query[kSecAttrAccount as String] as? String, + values.removeValue(forKey: account) != nil + else { + return errSecItemNotFound + } + return errSecSuccess + } +} diff --git a/docs/manual-test-checklist.md b/docs/manual-test-checklist.md index 75796a3..e01461f 100644 --- a/docs/manual-test-checklist.md +++ b/docs/manual-test-checklist.md @@ -134,7 +134,8 @@ Run every item below in Chrome, Safari, Edge, and a native AppKit text view. Rep - `Command+,`: open Settings while Inklet is active. - General: configure the OpenAI API key, then change language and appearance. -- Write Assistant: configure the OpenAI model, writing shortcut, and timeout. +- Provider: switch to another provider (for example DeepSeek), enter its key, and confirm the OpenAI API Key field becomes the optional voice-only field. Transform text in the popover and translate a selection with that provider; confirm dictation and pronunciation still use the OpenAI key. Choose Custom (OpenAI Compatible), set a local endpoint, and transform text. Switch back to OpenAI and confirm the shared key and default model return. Check the General panel in English and Chinese for fit. +- Write Assistant: configure the model for the selected provider, writing shortcut, and timeout. - Writing Assistant: configure the Dictation hold shortcut and microphone. In Advanced Dictation, confirm only the recovery model is available and there is no endpoint field. - Selection Assistant: configure translation language, Translate prompt, Force Selection mode, pronunciation voice, and speed; preview the voice. - Prompt Modes: add, edit, hide, delete with confirmation, and reorder modes.