diff --git a/01_PRD_v0.1.md b/01_PRD_v0.1.md index 9d702a3..14f9246 100644 --- a/01_PRD_v0.1.md +++ b/01_PRD_v0.1.md @@ -75,9 +75,11 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 3. 模拟 `Command + C`。 4. 在限定时间内读取新文本。 5. 恢复原剪贴板。 -6. 仍失败时显示轻提示:“未检测到选中文字”,并提供“开始 OCR”按钮。 +6. 仍失败时分两种情况: + - 剪贴板已有文本:把该文本填入原文框,提示区注明内容来自剪贴板,并提供“翻译剪贴板内容”和“开始 OCR”按钮。默认不自动翻译;通用设置“无选中文字时自动翻译剪贴板”开启后跳过确认直接翻译。 + - 剪贴板没有文本:显示轻提示“未检测到选中文字”,并提供“开始 OCR”按钮。 -禁止在读取失败后自动触发 OCR,避免用户误框选。 +禁止在读取失败后自动触发 OCR,避免用户误框选。剪贴板兜底只读取当前剪贴板内容,不保存剪贴板历史。 #### D. OCR 框选 @@ -115,8 +117,15 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 浮窗目标语言控制: - 一级:中文、English -- 更多:日本語、한국어、繁體中文 -- 点击当前目标语言会使用已修改的原文重新翻译。 +- 更多:日本語、한국어、繁體中文;选中“更多”里的语言后,菜单标题显示该语言名并呈现选中态。 +- 用户在浮窗内手动切换过目标语言后,浮窗保持可见期间的后续取词与 OCR 不再被自动检测覆盖;浮窗关闭后恢复自动检测。 + +重新翻译的入口: + +- 在原文框内按回车。正在翻译时按回车会取消当前请求并用当前原文重新翻译;`Shift + 回车`、`Option + 回车` 插入换行;输入法组字期间回车交给输入法处理。 +- 点击原文区的“翻译”按钮,等价于回车。 +- 点击当前目标语言。 +- 翻译失败时点击错误提示行的“重试”。 翻译应流式显示。若第三方接口不支持流式,应兼容一次性 JSON 返回。 @@ -134,7 +143,7 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 优先保持含义、语气和阅读体验,避免机械逐字对应;只输出译文。 -三个预设均只在设置页选择和编辑,并支持恢复默认。“翻译预设”页为当前默认翻译配置选择默认预设;浮窗不提供预设切换。 +三个预设均只在设置页编辑,并支持恢复默认。“翻译预设”页为当前默认翻译配置选择默认预设。浮窗提供会话级预设切换:切换后立即用当前原文重新翻译,只影响当前浮窗会话,不修改设置中的默认预设,浮窗关闭后回到默认预设。 #### H. 翻译模型配置 @@ -175,37 +184,47 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 建议结构: ```text -┌──────────────────────────────────────┐ -│ 原文 中文 English 更多 │ -│ 可编辑原文区域 │ -│ [复制] [朗读] │ -├──────────────────────────────────────┤ -│ 译文 [翻译中/取消] │ -│ 流式译文区域 │ -│ [复制] [朗读] │ -│ OCR 小图预览(可折叠) [固定] │ -└──────────────────────────────────────┘ +┌────────────────────────────────────────────────┐ +│ 原文 预设 中文 English 更多 [固定] │ +│ 可编辑原文区域 │ +│ [翻译] [复制] [朗读] (OCR 来源追加 [重新框选]) │ +├────────────────────────────────────────────────┤ +│ 译文 [翻译中/取消] │ +│ 流式译文区域 │ +│ 错误行:重试 / 打开设置 / 开始 OCR │ +│ 提示行:翻译剪贴板内容 / 开始 OCR │ +│ [复制] [朗读] │ +│ OCR 小图预览(可折叠) │ +└────────────────────────────────────────────────┘ ``` 行为: - 出现在鼠标或 OCR 区域附近。 - 自动避让菜单栏、Dock 和屏幕边缘。 +- 已固定且已显示的浮窗不再被重新定位,只刷新内容并保持前置。 - 点击外部自动关闭。 - 固定后点击外部不关闭。 - `Esc` 关闭,若正在翻译则先取消请求。 +- `⌘⇧C` 复制译文;原文框支持 `⌘A`、`⌘C`、`⌘V`、`⌘X`。 +- 取词中与本地识别中原文框不可编辑,结束后恢复。 +- 流式输出时译文区自动滚动到底部;用户手动向上滚动后,本次翻译不再自动跟随。 - 新快捷键任务会取消当前未完成请求并替换单一面板内容。 - 复制操作显示短暂成功状态。 -- 默认不自动复制,不自动朗读。 +- 自动复制和自动朗读全部由通用设置控制,自动执行时同样给出复制成功状态。除“OCR 完成后自动复制识别文字”默认开启外,其余自动项默认关闭;默认不自动朗读。 #### J. 语音朗读 原文和译文分别提供朗读按钮。 -- 中文和英文分别保存一个默认 Voice。 +- 中文和英文分别保存 Voice、Instructions 和朗读速度。 - 使用独立的 TTS 配置,不与翻译模型绑定。 -- 首选 OpenAI-compatible `/v1/audio/speech`。 +- 支持 OpenAI-compatible `/v1/audio/speech`。 +- 支持阿里云百炼 Qwen3-TTS 原生 HTTP 协议,包括 + `qwen3-tts-flash` 与 `qwen3-tts-instruct-flash`。 - 支持自定义 URL、API Key、模型、Voice、请求头、输出格式。 +- OpenAI-compatible Speech 支持 `0.25×–4.00×` 朗读速度;默认 `1.00×`。 +- 百炼 Qwen3-TTS 的生成响应与临时音频下载分两步完成;下载不转发鉴权信息。 - 支持生成、播放、暂停、继续、停止、重新播放。 - 点击另一个朗读按钮时停止当前音频并播放新任务。 - API 不可用时可选择 macOS 本地系统语音兜底。 @@ -230,7 +249,11 @@ v0.1 仅服务单一用户,运行在一台 M2 MacBook 上,不需要账号、 - 点击外部关闭浮窗 - 默认固定状态,默认关闭 - OCR 完成后自动翻译,默认开启 +- OCR 完成后自动复制识别文字,默认开启 - OCR 截图预览,默认开启 +- 无选中文字时自动翻译剪贴板,默认关闭 +- 翻译完成后自动复制译文,默认关闭 +- 翻译完成后自动朗读译文,默认关闭 - 本地 TTS 兜底,默认开启 #### L. 权限引导 diff --git a/02_TECHNICAL_DESIGN.md b/02_TECHNICAL_DESIGN.md index 28516ca..f711e4e 100644 --- a/02_TECHNICAL_DESIGN.md +++ b/02_TECHNICAL_DESIGN.md @@ -415,7 +415,8 @@ protocol SpeechProviding: Sendable { "model": "gpt-4o-mini-tts", "input": "需要朗读的文本", "voice": "coral", - "response_format": "mp3" + "response_format": "mp3", + "speed": 0.75 } ``` @@ -425,15 +426,57 @@ protocol SpeechProviding: Sendable { - API Key - Bearer 或自定义 Header - 模型 -- 中文 Voice -- 英文 Voice +- 中文 Voice、Instructions、speed +- 英文 Voice、Instructions、speed - 输出格式 -- 可选 instructions - 超时 +Provider 根据 `SpeechRequest.language` 选择对应语言的 Voice、Instructions 与 speed。 +设置页将两种语言并列放在“声音与朗读方式”中;输出格式和超时仍属于“请求行为”。 +旧 Profile 的单一 `instructions` 与 `speed` 在解码时分别复制到中文和英文配置; +新数据只写入独立字段。 + +为兼容未完整实现 OpenAI Speech schema 的旧第三方服务,`speed == 1.0` 时不发送 +该可选字段;用户选择其他速度时才发送。Profile 解码旧数据时补默认值 `1.0`, +并将异常持久化值限制到官方范围。速度按界面精度归一到两位小数,请求使用 +强类型 `Encodable` 与 `JSONEncoder`,禁止把 `0.8` 序列化为带二进制长尾的数字。 +HTTP 5xx 归类为服务暂不可用,不显示为请求格式不兼容。百炼 Qwen Provider +不发送此字段。 + 第三方服务如果遵循 OpenAI Speech schema 可直接使用。若协议不同,新增独立 Provider,不在 v0.1 里加入任意 JSON 模板编辑器。 -### 9.3 文本长度 +### 9.3 阿里云百炼 Qwen3-TTS HTTP + +使用独立 `DashScopeQwenSpeechProvider`,不把百炼协议伪装成 +OpenAI `/v1/audio/speech`。 + +生成 Endpoint: + +`/api/v1/services/aigc/multimodal-generation/generation` + +请求概念: + +```json +{ + "model": "qwen3-tts-instruct-flash", + "input": { + "text": "需要朗读的文本", + "voice": "Cherry", + "language_type": "Chinese", + "instructions": "缓慢、清晰地朗读" + } +} +``` + +- `qwen3-tts-flash` 不发送 `instructions` +- `qwen3-tts-instruct-flash` 按文本语言发送中文或英文 `instructions` +- 非流式响应返回临时 OSS 音频 URL,Provider 随即下载到内存 +- 只接受阿里云 OSS `aliyuncs.com` 域名,HTTP URL 升级为 HTTPS +- 音频下载请求不得携带 API Key 或生成请求的自定义 Header +- JSON 元数据与音频分别限制响应大小 +- 生成和下载均支持任务取消 + +### 9.4 文本长度 实现 `TextChunker`: @@ -443,7 +486,7 @@ protocol SpeechProviding: Sendable { - 所有块生成完成后依次播放 - 任一块失败时停止并显示具体块序号 -### 9.4 播放 +### 9.5 播放 `AudioPlaybackController` 使用 AVFoundation: @@ -453,7 +496,7 @@ protocol SpeechProviding: Sendable { - 任务取消时释放 Data - 同一时刻只允许一个播放会话 -### 9.5 本地兜底 +### 9.6 本地兜底 `SystemSpeechProvider` 使用 macOS 系统语音: diff --git a/03_ACCEPTANCE_TESTS.md b/03_ACCEPTANCE_TESTS.md index 9eee582..a757f3a 100644 --- a/03_ACCEPTANCE_TESTS.md +++ b/03_ACCEPTANCE_TESTS.md @@ -141,7 +141,31 @@ 步骤:配置 OpenAI-compatible 第三方 Speech endpoint。 -期望:自定义 URL、模型、Voice、Key、Header 生效,音频成功播放。 +期望: + +- 自定义 URL、模型、Voice、Key、Header 生效,音频成功播放 +- 中文和英文分别使用各自的 Voice、Instructions 与朗读速度 +- 旧版单一 Instructions/Speed 配置升级后同时迁移到中文和英文,不丢失设置 +- 调整朗读速度后,请求发送对应 `speed` +- `0.8×`、`0.85×` 等速度以简洁十进制数字发送,不包含二进制浮点长尾 +- 默认 `1.0×` 不发送可选 `speed` 字段,保持旧第三方服务兼容 +- 修改速度后缓存键变化,不复用其他速度生成的音频 +- HTTP 5xx 显示服务暂不可用,不误报为请求格式不兼容 + +### P0-14A 百炼 Qwen3-TTS HTTP + +步骤:选择“阿里云百炼 Qwen HTTP”,配置北京区工作空间域名、API Key、 +`qwen3-tts-flash` 或 `qwen3-tts-instruct-flash`,分别朗读中英文。 + +期望: + +- 使用百炼原生 HTTP 请求结构,而不是 `/v1/audio/speech` +- 中文和英文分别发送正确 Voice 与 `language_type` +- 仅 Instruct 模型发送 Instructions +- 临时 OSS 音频地址升级为 HTTPS 后下载并播放 +- 下载请求不携带 API Key 或自定义鉴权 Header +- 非阿里云 OSS 音频地址被拒绝 +- 相同文本、语言和配置再次朗读时复用内存缓存,不重复计费请求 ### P0-15 TTS 本地兜底 diff --git a/README.md b/README.md index 451524e..f9a11a5 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Both paths open one floating panel where the source stays editable and the sourc - **Two shortcuts, one flow.** No main window to find and no context switching. - **Local OCR.** Screenshots stay in memory and text recognition runs through Apple Vision. - **Bring your own model.** Configure custom URLs, models, headers, timeouts, streaming, OpenAI Responses, or Chat Completions. -- **Flexible speech.** Use an OpenAI-compatible speech endpoint or explicitly fall back to macOS system voices. +- **Flexible speech.** Use an OpenAI-compatible speech endpoint, Alibaba Cloud Model Studio Qwen3-TTS over native HTTP, or explicitly fall back to macOS system voices. - **Privacy by design.** Keys live in Keychain; logs are redacted; there is no history, account, telemetry, or cloud sync. - **Native and lean.** Swift 6, SwiftUI + AppKit, Apple frameworks, and zero third-party runtime dependencies. - **Cancellation-safe.** A new capture replaces the previous session and cancels in-flight OCR, network, and audio work. @@ -117,7 +117,7 @@ Translation profiles support: - Automatic, enabled, or disabled streaming - Streaming SSE and non-streaming JSON responses -Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, voice, headers, and output format. “OpenAI-compatible” implementations vary; please open a compatibility report when a provider needs a dedicated adapter. +Speech profiles support OpenAI-compatible `/v1/audio/speech` endpoints with custom URL, model, headers, output format, and independent Chinese/English voice, instructions, and `0.25×–4.00×` speed control. A dedicated Alibaba Cloud Model Studio HTTP adapter supports `qwen3-tts-flash` and `qwen3-tts-instruct-flash`; it downloads the short-lived audio result over HTTPS without forwarding API credentials. Other “OpenAI-compatible” implementations still vary, so please open a compatibility report when a provider needs a dedicated adapter. Never place a real API key in source files, Markdown, test fixtures, `.env` files committed to Git, or issue screenshots. diff --git a/README.zh-CN.md b/README.zh-CN.md index 956a488..75a18b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ - **两个快捷键,一条工作流。** 不需要先找到主窗口,也不打断当前工作。 - **OCR 完全本地。** 截图只驻留内存,识别使用 Apple Vision。 - **自带模型。** 支持自定义 URL、模型、Header、超时、流式模式、Responses 和 Chat Completions。 -- **灵活朗读。** 可使用 OpenAI-compatible Speech 接口,也可由用户明确切换到 macOS 系统语音。 +- **灵活朗读。** 可使用 OpenAI-compatible Speech 接口、阿里云百炼 Qwen3-TTS 原生 HTTP,也可由用户明确切换到 macOS 系统语音。 - **隐私优先。** 密钥进入 Keychain;日志脱敏;不保存历史;没有账号、遥测或云同步。 - **原生且克制。** Swift 6、SwiftUI + AppKit、Apple 原生框架、零第三方运行时依赖。 - **完整取消链路。** 新任务会替换旧会话并取消进行中的 OCR、网络和音频任务。 @@ -117,7 +117,7 @@ brew install xcodegen - 自动、开启或关闭流式模式 - SSE 流式响应和非流式 JSON 响应 -语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Voice、Header 和输出格式。不同厂商对“OpenAI 兼容”的实现并不完全一致;如需专门适配器,请提交兼容性报告。 +语音配置支持 OpenAI-compatible `/v1/audio/speech`,可自定义 URL、模型、Header 和输出格式,并可独立配置中文/英文 Voice、Instructions 及 `0.25×–4.00×` 朗读速度。专用的阿里云百炼 HTTP 适配器支持 `qwen3-tts-flash` 和 `qwen3-tts-instruct-flash`,会通过 HTTPS 下载短期音频结果且不转发 API 凭据。其他厂商对“OpenAI 兼容”的实现仍可能不同;如需专门适配器,请提交兼容性报告。 不要把真实 API Key 放入源码、Markdown、测试 fixture、Git 中的 `.env` 文件或 Issue 截图。 diff --git a/TextFlow.xcodeproj/project.pbxproj b/TextFlow.xcodeproj/project.pbxproj index 1488a69..6ff7805 100644 --- a/TextFlow.xcodeproj/project.pbxproj +++ b/TextFlow.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 3DBF8B3AFA47CF2A2C62A167 /* LimitedURLSessionDataLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED1D4D6A9B3DB138738A08A /* LimitedURLSessionDataLoaderTests.swift */; }; 3F75409BB2EDAD0337C67A5D /* PanelPositioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B72CEE4A9478AF1C512827 /* PanelPositioner.swift */; }; 3FAE646B0A5880E41DC2060B /* PromptBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9B8F0C3CD29812B3C90E2B /* PromptBuilderTests.swift */; }; + 418447EF716BE81B74A85222 /* DashScopeQwenSpeechProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72CCFADB4255CA05FF0B02D /* DashScopeQwenSpeechProvider.swift */; }; 4210B3285656CBEF2EC8A2DE /* TranslationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1638DF26C44F95AB66C8D4 /* TranslationSession.swift */; }; 4A062368AFA486E634B54C3F /* TranslationProviderIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116EB6653009DB064972AC48 /* TranslationProviderIntegrationTests.swift */; }; 4F5DDF73FAF7DA51C2708F05 /* PermissionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1518E896853A11585CD9004 /* PermissionService.swift */; }; @@ -42,12 +43,14 @@ 5311C7D45A47BB8186AF6EC5 /* TextChunker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D05E8C6A2FF90DE7D0F7 /* TextChunker.swift */; }; 5389A9DBECDDFF0577E184A2 /* AccessibilityTextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCA8BE829BDF9E9C8C207AF /* AccessibilityTextReader.swift */; }; 54FC381DA6A5F48A79177027 /* TranslationProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97395BA291782A91C0631C3A /* TranslationProviders.swift */; }; + 558DDF06DA8C17DDD139EFB5 /* OriginalTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C9ACA0B84DCDD429C45459 /* OriginalTextEditor.swift */; }; 5788177EC3E2C63E9FEC984E /* HotKeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F23FD27D74B89C872BFDB080 /* HotKeyShortcutTests.swift */; }; 57BA31F0B76DEE3956E14C45 /* OpenAICompatibleSpeechProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */; }; 5AD68F336A79F0E83CB25133 /* TranslationPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1722C289DE6D3554A9EE02D /* TranslationPanelController.swift */; }; 5DD2E08F7526B16D71E926F6 /* TranslationProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8B836D1954A3F53C1353CB /* TranslationProfilesView.swift */; }; 62564587D08DA372F1D40E6A /* ScreenCaptureKitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2978AB10D41EA87019E0AC7E /* ScreenCaptureKitService.swift */; }; 67D9AB61B6D1F9069546A6A5 /* SpeechProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44AB94AA6BE692A34699339F /* SpeechProfilesView.swift */; }; + 6A91BB8A908A5D6426382F94 /* DashScopeQwenSpeechProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F40339B96CE0A5309178A4 /* DashScopeQwenSpeechProviderTests.swift */; }; 704909DAE43ABF48CF5766E1 /* SelectionOverlayWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71213522042416AADC094862 /* SelectionOverlayWindowTests.swift */; }; 71ACB87E5408AD35D9831B04 /* OCRProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31D4C89797DB7966F40EED7 /* OCRProviding.swift */; }; 7B52C5847ACADB4124618143 /* CoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB359641EA2C692F1B181B96 /* CoordinateConverter.swift */; }; @@ -73,6 +76,7 @@ CCF2A7202026701F9C1154ED /* SettingsEditorComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A002F42B4FA84C8549C2219 /* SettingsEditorComponents.swift */; }; CD08B7F43DA5D31DCB3A698F /* TextFlowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E62521645442FE9B549428 /* TextFlowApp.swift */; }; CD22916D7BAD43BB172DB6E8 /* ClipboardSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE252A2394B5C38F0D609D06 /* ClipboardSnapshotTests.swift */; }; + D6225DCEAC2079FBA2781B49 /* OriginalTextSubmitPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E406937970C0AB2D5F9BA08 /* OriginalTextSubmitPolicyTests.swift */; }; DA294284495299E78702C6A3 /* AudioPlaybackController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */; }; DAC1AF3BDDD8B8555B40558D /* TextChunkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F66C02904E41992055A2325A /* TextChunkerTests.swift */; }; DBC6A7ECC15029C9AF3EFC1A /* ClipboardSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0079B3E892B4A8197352059A /* ClipboardSnapshot.swift */; }; @@ -120,10 +124,12 @@ 2B6B8BB11F60DD022BD26820 /* OCRReadingOrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRReadingOrderTests.swift; sourceTree = ""; }; 2D7FEFD85A4501C85C88B649 /* SettingsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindow.swift; sourceTree = ""; }; 2E9B8F0C3CD29812B3C90E2B /* PromptBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilderTests.swift; sourceTree = ""; }; + 32C9ACA0B84DCDD429C45459 /* OriginalTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OriginalTextEditor.swift; sourceTree = ""; }; 33CE7E21188DC5CE073B39DD /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = ""; }; 38F1BC6EC60600F0FE795CD4 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageDetectionTests.swift; sourceTree = ""; }; 3D9DCD870DF9FEDFDE3F9814 /* TranslationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationRequest.swift; sourceTree = ""; }; + 3E406937970C0AB2D5F9BA08 /* OriginalTextSubmitPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OriginalTextSubmitPolicyTests.swift; sourceTree = ""; }; 4328784D054E275DC43C1FE9 /* CoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateConverterTests.swift; sourceTree = ""; }; 445F8C7502CD861217E285DC /* PanelPositionerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelPositionerTests.swift; sourceTree = ""; }; 44AB94AA6BE692A34699339F /* SpeechProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechProfilesView.swift; sourceTree = ""; }; @@ -135,6 +141,7 @@ 4CE842B9AF66AD6513D3360A /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = ""; }; 4D46EFC4C3D58904430879AA /* KeychainStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStoreTests.swift; sourceTree = ""; }; 4DDB218915206C7B8E62FDCA /* ProviderDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderDecoderTests.swift; sourceTree = ""; }; + 53F40339B96CE0A5309178A4 /* DashScopeQwenSpeechProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashScopeQwenSpeechProviderTests.swift; sourceTree = ""; }; 56292085A3560797C57EDCD7 /* SpeechAudioCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechAudioCache.swift; sourceTree = ""; }; 5A09FD015BC2128A8F6898D6 /* TextFlow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TextFlow.entitlements; sourceTree = ""; }; 5C5D2984789817E64FCE6762 /* SpeechByteBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechByteBudgetTests.swift; sourceTree = ""; }; @@ -177,6 +184,7 @@ CC830888EB9D0437618BD64D /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; D024FC355DE6A4C7224F13B9 /* SSEParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEParserTests.swift; sourceTree = ""; }; D1722C289DE6D3554A9EE02D /* TranslationPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPanelController.swift; sourceTree = ""; }; + D72CCFADB4255CA05FF0B02D /* DashScopeQwenSpeechProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashScopeQwenSpeechProvider.swift; sourceTree = ""; }; DA523A622ABA147EDEF4A7D0 /* ProfileSecretSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSecretSessionState.swift; sourceTree = ""; }; DFEDEA3F89FF215DFC8332DA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E8325B357D5CC714C05EDDB1 /* ProviderResponseDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderResponseDecoder.swift; sourceTree = ""; }; @@ -202,6 +210,7 @@ isa = PBXGroup; children = ( 98ABD06BDE8DA356EBFF196E /* AudioPlaybackController.swift */, + D72CCFADB4255CA05FF0B02D /* DashScopeQwenSpeechProvider.swift */, 6298768B299979CF0F646982 /* LimitedURLSessionDataLoader.swift */, 92D0E094A543862D2C1D7174 /* OpenAICompatibleSpeechProvider.swift */, 56292085A3560797C57EDCD7 /* SpeechAudioCache.swift */, @@ -334,6 +343,7 @@ 9AB9465DF92D3AF03807D287 /* Panel */ = { isa = PBXGroup; children = ( + 32C9ACA0B84DCDD429C45459 /* OriginalTextEditor.swift */, 8068A26A57898F6F6A6A97B9 /* TranslationPanel.swift */, D1722C289DE6D3554A9EE02D /* TranslationPanelController.swift */, 795750DFD27EF41C442BCF6F /* TranslationPanelView.swift */, @@ -349,11 +359,13 @@ B9E5F309D497A68743CB5B92 /* ClipboardFallbackReaderTests.swift */, FE252A2394B5C38F0D609D06 /* ClipboardSnapshotTests.swift */, 4328784D054E275DC43C1FE9 /* CoordinateConverterTests.swift */, + 53F40339B96CE0A5309178A4 /* DashScopeQwenSpeechProviderTests.swift */, F23FD27D74B89C872BFDB080 /* HotKeyShortcutTests.swift */, 4D46EFC4C3D58904430879AA /* KeychainStoreTests.swift */, 3B5CE66FDC6829555EA09394 /* LanguageDetectionTests.swift */, 5ED1D4D6A9B3DB138738A08A /* LimitedURLSessionDataLoaderTests.swift */, 2B6B8BB11F60DD022BD26820 /* OCRReadingOrderTests.swift */, + 3E406937970C0AB2D5F9BA08 /* OriginalTextSubmitPolicyTests.swift */, 445F8C7502CD861217E285DC /* PanelPositionerTests.swift */, 475D51C2A9EC2A9FA4EC591F /* PermissionSeparationTests.swift */, 01EF3A899ABC719619822EBA /* ProfileSecretSessionStateTests.swift */, @@ -521,11 +533,13 @@ 50E1618B2D24260DD5C67924 /* ClipboardFallbackReaderTests.swift in Sources */, CD22916D7BAD43BB172DB6E8 /* ClipboardSnapshotTests.swift in Sources */, B730F886C2A2B573AC5A5E61 /* CoordinateConverterTests.swift in Sources */, + 6A91BB8A908A5D6426382F94 /* DashScopeQwenSpeechProviderTests.swift in Sources */, 5788177EC3E2C63E9FEC984E /* HotKeyShortcutTests.swift in Sources */, 1A1BD618C6DB603628353251 /* KeychainStoreTests.swift in Sources */, 2FA954D135D9DA76FC19AB95 /* LanguageDetectionTests.swift in Sources */, 3DBF8B3AFA47CF2A2C62A167 /* LimitedURLSessionDataLoaderTests.swift in Sources */, 4FCA25903060F20C7970C9DB /* OCRReadingOrderTests.swift in Sources */, + D6225DCEAC2079FBA2781B49 /* OriginalTextSubmitPolicyTests.swift in Sources */, AEFDE86A81708DC6D5E678AB /* PanelPositionerTests.swift in Sources */, 127AA10273B69DE928546392 /* PermissionSeparationTests.swift in Sources */, BE53C6EFC10DAABED65B9494 /* ProfileSecretSessionStateTests.swift in Sources */, @@ -562,6 +576,7 @@ AFF59F4FAC5E041CB9ED907A /* ClipboardFallbackReader.swift in Sources */, DBC6A7ECC15029C9AF3EFC1A /* ClipboardSnapshot.swift in Sources */, 7B52C5847ACADB4124618143 /* CoordinateConverter.swift in Sources */, + 418447EF716BE81B74A85222 /* DashScopeQwenSpeechProvider.swift in Sources */, 827A7E6E5FC3A5213740D40A /* GlobalHotKeyService.swift in Sources */, FFD38112182E46989E6EABF1 /* KeychainStore.swift in Sources */, 1F388C3950C30831E5E56F33 /* LanguageDetectionService.swift in Sources */, @@ -569,6 +584,7 @@ E5B551B878A066C3FBA728F2 /* LimitedURLSessionDataLoader.swift in Sources */, 71ACB87E5408AD35D9831B04 /* OCRProviding.swift in Sources */, 57BA31F0B76DEE3956E14C45 /* OpenAICompatibleSpeechProvider.swift in Sources */, + 558DDF06DA8C17DDD139EFB5 /* OriginalTextEditor.swift in Sources */, 3F75409BB2EDAD0337C67A5D /* PanelPositioner.swift in Sources */, 4F5DDF73FAF7DA51C2708F05 /* PermissionService.swift in Sources */, 82D1F16FD42978739A5AB70A /* PermissionsView.swift in Sources */, diff --git a/TextFlow/App/AppCoordinator.swift b/TextFlow/App/AppCoordinator.swift index 27f7eb1..1271368 100644 --- a/TextFlow/App/AppCoordinator.swift +++ b/TextFlow/App/AppCoordinator.swift @@ -30,6 +30,15 @@ enum SelectedTextAcquisitionPolicy { guard let appError = error as? AppError else { return true } return appError != .permissionDenied(.accessibility) } + + /// 取词全部失败后可以提供给用户的剪贴板文本;空白内容视为没有可用文本。 + static func offerableClipboardText(_ value: String?) -> String? { + guard let value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return value + } } @MainActor @@ -56,6 +65,15 @@ final class AppCoordinator { self?.settingsStore.defaultTranslationPreset ?? TranslationPreset.defaults[1] } + viewModel.availablePresetsProvider = { [weak self] in + self?.settingsStore.translationPresets ?? TranslationPreset.defaults + } + viewModel.shouldAutoCopyTranslation = { [weak self] in + self?.settingsStore.autoCopyTranslation == true + } + viewModel.shouldAutoSpeakTranslation = { [weak self] in + self?.settingsStore.autoSpeakTranslation == true + } viewModel.translationRunner = { [weak self] request in guard let self else { return AsyncThrowingStream { continuation in @@ -76,6 +94,7 @@ final class AppCoordinator { viewModel.onReleaseSpeech = { [weak self] in self?.stopSpeech(release: true) } viewModel.onReplaySpeech = { [weak self] in self?.replaySpeech() } viewModel.onUseLocalSpeech = { [weak self] in self?.useLocalSpeech() } + viewModel.onOpenSettings = { [weak self] in self?.showSettings() } viewModel.onCancelExternalOperations = { [weak self] in self?.cancelActiveAcquisition() } @@ -269,12 +288,35 @@ final class AppCoordinator { } catch is CancellationError { return } catch { + guard acquisitionGeneration.contains(acquisitionID) else { return } + guard let clipboardText = SelectedTextAcquisitionPolicy + .offerableClipboardText( + NSPasteboard.general.string(forType: .string) + ) else { + guard finishAcquisition(acquisitionID) else { return } + panelViewModel.showNoSelectedText() + panelController.activate() + diagnostics.record( + .userAction, + message: "No selected text found; clipboard restored and empty" + ) + return + } + let autoTranslate = settingsStore.autoTranslateClipboardFallback + let targetLanguage = await languageDetection.targetLanguage( + for: clipboardText + ) + guard !Task.isCancelled else { return } guard finishAcquisition(acquisitionID) else { return } - panelViewModel.showNoSelectedText() + panelViewModel.showClipboardFallbackContent( + text: clipboardText, + targetLanguage: targetLanguage, + autoTranslate: autoTranslate + ) panelController.activate() diagnostics.record( .userAction, - message: "No selected text found; clipboard restored" + message: "No selected text found; offered clipboard text length=\(clipboardText.count) auto=\(autoTranslate)" ) } } @@ -314,7 +356,8 @@ final class AppCoordinator { source: .ocr, targetLanguage: targetLanguage, screenshot: settingsStore.showOCRPreview ? image : nil, - autoTranslate: settingsStore.autoTranslateOCR + autoTranslate: settingsStore.autoTranslateOCR, + autoCopyOriginal: settingsStore.autoCopyOCRText ) panelController.show(anchor: selection.globalRect.center) diagnostics.record( diff --git a/TextFlow/Core/Models/AppError.swift b/TextFlow/Core/Models/AppError.swift index 54c27e0..7233267 100644 --- a/TextFlow/Core/Models/AppError.swift +++ b/TextFlow/Core/Models/AppError.swift @@ -18,6 +18,7 @@ enum AppError: LocalizedError, Sendable, Equatable { case modelNotFound case incompatibleRequest case invalidResponse + case untrustedSpeechAudioURL case responseTooLarge case timedOut case cancelled @@ -60,9 +61,11 @@ enum AppError: LocalizedError, Sendable, Equatable { case .modelNotFound: return "服务找不到所配置的模型。请检查模型名称。" case .incompatibleRequest: - return "服务不兼容当前请求格式。请检查协议和流式设置。" + return "服务不兼容当前请求格式。请检查协议、模型、请求参数或流式设置。" case .invalidResponse: return "服务返回了无法解析的数据。请检查协议兼容性。" + case .untrustedSpeechAudioURL: + return "语音服务返回了不受信任的音频地址,已阻止下载。" case .responseTooLarge: return "服务返回内容超过安全上限。请缩短文本后重试。" case .timedOut: diff --git a/TextFlow/Core/Models/SpeechProfile.swift b/TextFlow/Core/Models/SpeechProfile.swift index 3fe896b..f665415 100644 --- a/TextFlow/Core/Models/SpeechProfile.swift +++ b/TextFlow/Core/Models/SpeechProfile.swift @@ -1,5 +1,27 @@ import Foundation +enum SpeechProtocolKind: String, Codable, CaseIterable, Sendable, Identifiable { + case openAISpeech + case dashScopeQwenHTTP + + var id: Self { self } + + var displayName: String { + switch self { + case .openAISpeech: "OpenAI-compatible Speech" + case .dashScopeQwenHTTP: "阿里云百炼 Qwen HTTP" + } + } + + var defaultPath: String { + switch self { + case .openAISpeech: "/v1/audio/speech" + case .dashScopeQwenHTTP: + "/api/v1/services/aigc/multimodal-generation/generation" + } + } +} + enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { case mp3 case wav @@ -12,8 +34,13 @@ enum SpeechOutputFormat: String, Codable, CaseIterable, Sendable, Identifiable { } struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { + static let minimumSpeed = 0.25 + static let maximumSpeed = 4.0 + static let defaultSpeed = 1.0 + let id: UUID var name: String + var protocolKind: SpeechProtocolKind var endpointMode: EndpointMode var baseURL: String var path: String @@ -22,15 +49,67 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { var chineseVoice: String var englishVoice: String var outputFormat: SpeechOutputFormat - var instructions: String + var chineseInstructions: String + var englishInstructions: String + var chineseSpeed: Double { + didSet { + chineseSpeed = Self.normalizedSpeed(chineseSpeed) + } + } + var englishSpeed: Double { + didSet { + englishSpeed = Self.normalizedSpeed(englishSpeed) + } + } var authMode: AuthMode var customHeaders: [HeaderReference] var timeoutSeconds: Double + init( + id: UUID, + name: String, + protocolKind: SpeechProtocolKind, + endpointMode: EndpointMode, + baseURL: String, + path: String, + fullEndpoint: String, + model: String, + chineseVoice: String, + englishVoice: String, + outputFormat: SpeechOutputFormat, + chineseInstructions: String, + englishInstructions: String, + chineseSpeed: Double, + englishSpeed: Double, + authMode: AuthMode, + customHeaders: [HeaderReference], + timeoutSeconds: Double + ) { + self.id = id + self.name = name + self.protocolKind = protocolKind + self.endpointMode = endpointMode + self.baseURL = baseURL + self.path = path + self.fullEndpoint = fullEndpoint + self.model = model + self.chineseVoice = chineseVoice + self.englishVoice = englishVoice + self.outputFormat = outputFormat + self.chineseInstructions = chineseInstructions + self.englishInstructions = englishInstructions + self.chineseSpeed = Self.normalizedSpeed(chineseSpeed) + self.englishSpeed = Self.normalizedSpeed(englishSpeed) + self.authMode = authMode + self.customHeaders = customHeaders + self.timeoutSeconds = timeoutSeconds + } + static func empty() -> SpeechProfile { SpeechProfile( id: UUID(), name: "新语音配置", + protocolKind: .openAISpeech, endpointMode: .baseURLAndPath, baseURL: "https://api.openai.com", path: "/v1/audio/speech", @@ -39,13 +118,118 @@ struct SpeechProfile: Codable, Identifiable, Hashable, Sendable { chineseVoice: "coral", englishVoice: "coral", outputFormat: .mp3, - instructions: "", + chineseInstructions: "", + englishInstructions: "", + chineseSpeed: defaultSpeed, + englishSpeed: defaultSpeed, authMode: .bearer, customHeaders: [], timeoutSeconds: 60 ) } + private enum CodingKeys: String, CodingKey { + case id + case name + case protocolKind + case endpointMode + case baseURL + case path + case fullEndpoint + case model + case chineseVoice + case englishVoice + case outputFormat + case chineseInstructions + case englishInstructions + case chineseSpeed + case englishSpeed + case instructions + case speed + case authMode + case customHeaders + case timeoutSeconds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + protocolKind = try container.decodeIfPresent( + SpeechProtocolKind.self, + forKey: .protocolKind + ) ?? .openAISpeech + endpointMode = try container.decode(EndpointMode.self, forKey: .endpointMode) + baseURL = try container.decode(String.self, forKey: .baseURL) + path = try container.decode(String.self, forKey: .path) + fullEndpoint = try container.decode(String.self, forKey: .fullEndpoint) + model = try container.decode(String.self, forKey: .model) + chineseVoice = try container.decode(String.self, forKey: .chineseVoice) + englishVoice = try container.decode(String.self, forKey: .englishVoice) + outputFormat = try container.decode( + SpeechOutputFormat.self, + forKey: .outputFormat + ) + let legacyInstructions = try container.decodeIfPresent( + String.self, + forKey: .instructions + ) ?? "" + chineseInstructions = try container.decodeIfPresent( + String.self, + forKey: .chineseInstructions + ) ?? legacyInstructions + englishInstructions = try container.decodeIfPresent( + String.self, + forKey: .englishInstructions + ) ?? legacyInstructions + let legacySpeed = Self.normalizedSpeed( + try container.decodeIfPresent(Double.self, forKey: .speed) + ?? Self.defaultSpeed + ) + chineseSpeed = Self.normalizedSpeed( + try container.decodeIfPresent(Double.self, forKey: .chineseSpeed) + ?? legacySpeed + ) + englishSpeed = Self.normalizedSpeed( + try container.decodeIfPresent(Double.self, forKey: .englishSpeed) + ?? legacySpeed + ) + authMode = try container.decode(AuthMode.self, forKey: .authMode) + customHeaders = try container.decode( + [HeaderReference].self, + forKey: .customHeaders + ) + timeoutSeconds = try container.decode(Double.self, forKey: .timeoutSeconds) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(protocolKind, forKey: .protocolKind) + try container.encode(endpointMode, forKey: .endpointMode) + try container.encode(baseURL, forKey: .baseURL) + try container.encode(path, forKey: .path) + try container.encode(fullEndpoint, forKey: .fullEndpoint) + try container.encode(model, forKey: .model) + try container.encode(chineseVoice, forKey: .chineseVoice) + try container.encode(englishVoice, forKey: .englishVoice) + try container.encode(outputFormat, forKey: .outputFormat) + try container.encode(chineseInstructions, forKey: .chineseInstructions) + try container.encode(englishInstructions, forKey: .englishInstructions) + try container.encode(chineseSpeed, forKey: .chineseSpeed) + try container.encode(englishSpeed, forKey: .englishSpeed) + try container.encode(authMode, forKey: .authMode) + try container.encode(customHeaders, forKey: .customHeaders) + try container.encode(timeoutSeconds, forKey: .timeoutSeconds) + } + + static func normalizedSpeed(_ value: Double) -> Double { + guard value.isFinite else { return defaultSpeed } + let clamped = min(maximumSpeed, max(minimumSpeed, value)) + return (clamped * 100).rounded() / 100 + } + var apiKeyAccount: String { "tts.\(id.uuidString).apiKey" } @@ -72,6 +256,10 @@ struct SpeechAudio: Sendable { protocol SpeechProviding: Sendable { func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio + func synthesize( + _ request: SpeechRequest, + maximumBytes: Int + ) async throws -> SpeechAudio func testConnection() async throws -> ProviderTestResult } diff --git a/TextFlow/Core/Models/TranslationSession.swift b/TextFlow/Core/Models/TranslationSession.swift index 47813dd..295248d 100644 --- a/TextFlow/Core/Models/TranslationSession.swift +++ b/TextFlow/Core/Models/TranslationSession.swift @@ -49,6 +49,26 @@ enum TargetLanguage: String, Codable, CaseIterable, Sendable, Identifiable { } } +/// 浮窗目标语言控件的分组:两个常驻按钮 + 「更多」菜单。 +enum TargetLanguageMenu { + static let primary: [TargetLanguage] = [.simplifiedChinese, .english] + static let secondary: [TargetLanguage] = [ + .japanese, + .korean, + .traditionalChinese + ] + static let fallbackTitle = "更多" + + static func isSecondary(_ language: TargetLanguage) -> Bool { + secondary.contains(language) + } + + /// 选中「更多」里的语言后,菜单标题直接显示当前语言名。 + static func title(for language: TargetLanguage) -> String { + isSecondary(language) ? language.displayName : fallbackTitle + } +} + struct TranslationSession: Identifiable { let id: UUID let source: TextSource diff --git a/TextFlow/Features/OCR/SelectionOverlayController.swift b/TextFlow/Features/OCR/SelectionOverlayController.swift index fe3a7e0..98fb240 100644 --- a/TextFlow/Features/OCR/SelectionOverlayController.swift +++ b/TextFlow/Features/OCR/SelectionOverlayController.swift @@ -162,6 +162,11 @@ final class SelectionOverlayView: NSView { var onComplete: ((CGRect) -> Void)? var onCancel: (() -> Void)? + static let hintText = "拖拽框选文字 · Esc 取消" + + /// 未开始拖拽时显示操作提示,开始拖拽后隐藏。 + private(set) var showsHint = true + private var dragStart: CGPoint? private var selectionRect: CGRect = .zero @@ -171,6 +176,9 @@ final class SelectionOverlayView: NSView { override func draw(_ dirtyRect: NSRect) { NSColor.black.withAlphaComponent(0.42).setFill() bounds.fill() + if showsHint { + drawHint() + } guard !selectionRect.isEmpty else { return } NSColor.white.withAlphaComponent(0.16).setFill() selectionRect.fill() @@ -184,6 +192,7 @@ final class SelectionOverlayView: NSView { override func mouseDown(with event: NSEvent) { dragStart = constrained(convert(event.locationInWindow, from: nil)) selectionRect = .zero + showsHint = false needsDisplay = true } @@ -234,6 +243,29 @@ final class SelectionOverlayView: NSView { ) } + private func drawHint() { + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.white.withAlphaComponent(0.75), + .font: NSFont.systemFont(ofSize: 15, weight: .medium) + ] + let text = Self.hintText as NSString + let size = text.size(withAttributes: attributes) + let origin = CGPoint( + x: bounds.midX - size.width / 2, + y: bounds.midY - size.height / 2 + ) + let padding = CGSize(width: 14, height: 8) + let background = NSRect( + x: origin.x - padding.width, + y: origin.y - padding.height, + width: size.width + padding.width * 2, + height: size.height + padding.height * 2 + ) + NSColor.black.withAlphaComponent(0.35).setFill() + NSBezierPath(roundedRect: background, xRadius: 8, yRadius: 8).fill() + text.draw(at: origin, withAttributes: attributes) + } + private func drawSize() { let text = "\(Int(selectionRect.width)) × \(Int(selectionRect.height))" let attributes: [NSAttributedString.Key: Any] = [ diff --git a/TextFlow/Features/Panel/OriginalTextEditor.swift b/TextFlow/Features/Panel/OriginalTextEditor.swift new file mode 100644 index 0000000..596b153 --- /dev/null +++ b/TextFlow/Features/Panel/OriginalTextEditor.swift @@ -0,0 +1,121 @@ +import AppKit +import SwiftUI + +enum OriginalTextSubmitPolicy { + static let returnKeyCode: UInt16 = 36 + static let keypadEnterKeyCode: UInt16 = 76 + + /// 回车即翻译,但输入法组字期间(marked text)必须让输入法优先处理, + /// 否则中文候选词无法上屏。Shift/Option + 回车保留换行。 + static func shouldSubmit( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + hasMarkedText: Bool + ) -> Bool { + guard !hasMarkedText else { return false } + guard keyCode == returnKeyCode || keyCode == keypadEnterKeyCode else { + return false + } + let flags = modifiers.intersection(.deviceIndependentFlagsMask) + return flags.intersection([.shift, .option, .command, .control]).isEmpty + } +} + +@MainActor +final class SubmittableTextView: NSTextView { + var onSubmit: (() -> Void)? + + override func keyDown(with event: NSEvent) { + guard OriginalTextSubmitPolicy.shouldSubmit( + keyCode: event.keyCode, + modifiers: event.modifierFlags, + hasMarkedText: hasMarkedText() + ) else { + super.keyDown(with: event) + return + } + onSubmit?() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window, isEditable else { return } + window.initialFirstResponder = self + } +} + +struct OriginalTextEditor: NSViewRepresentable { + @Binding var text: String + var isEditable: Bool + var onSubmit: () -> Void + + func makeNSView(context: Context) -> NSScrollView { + let textView = SubmittableTextView() + textView.delegate = context.coordinator + textView.isRichText = false + textView.allowsUndo = true + textView.font = .preferredFont(forTextStyle: .body) + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 2, height: 6) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.string = text + textView.isEditable = isEditable + textView.onSubmit = onSubmit + + let scrollView = NSScrollView() + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.documentView = textView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? SubmittableTextView else { + return + } + textView.onSubmit = onSubmit + if textView.string != text { + textView.string = text + } + if textView.isEditable != isEditable { + textView.isEditable = isEditable + if isEditable, textView.window?.initialFirstResponder == nil { + textView.window?.initialFirstResponder = textView + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + @MainActor + final class Coordinator: NSObject, NSTextViewDelegate { + private let text: Binding + + init(text: Binding) { + self.text = text + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + let value = textView.string + guard text.wrappedValue != value else { return } + text.wrappedValue = value + } + } +} diff --git a/TextFlow/Features/Panel/TranslationPanel.swift b/TextFlow/Features/Panel/TranslationPanel.swift index 0c50861..3ab3bf4 100644 --- a/TextFlow/Features/Panel/TranslationPanel.swift +++ b/TextFlow/Features/Panel/TranslationPanel.swift @@ -1,8 +1,57 @@ import AppKit +/// accessory 应用没有主菜单,编辑类快捷键不会自动送达 borderless 面板, +/// 因此浮窗自己把它们分发给当前响应者。 +enum PanelKeyCommand: Equatable { + case selectAll + case copy + case paste + case cut + case undo + case redo + case copyTranslation + + static func command( + charactersIgnoringModifiers characters: String?, + modifiers: NSEvent.ModifierFlags + ) -> PanelKeyCommand? { + let flags = modifiers.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command), + !flags.contains(.control), + !flags.contains(.option), + let key = characters?.lowercased(), + key.count == 1 else { + return nil + } + switch (key, flags.contains(.shift)) { + case ("c", true): return .copyTranslation + case ("c", false): return .copy + case ("a", false): return .selectAll + case ("v", false): return .paste + case ("x", false): return .cut + case ("z", false): return .undo + case ("z", true): return .redo + default: return nil + } + } + + var responderSelector: Selector? { + switch self { + case .selectAll: #selector(NSText.selectAll(_:)) + case .copy: #selector(NSText.copy(_:)) + case .paste: #selector(NSText.paste(_:)) + case .cut: #selector(NSText.cut(_:)) + case .undo: Selector(("undo:")) + case .redo: Selector(("redo:")) + case .copyTranslation: nil + } + } +} + @MainActor final class TranslationPanel: NSPanel { var onEscape: (() -> Void)? + var onCopyTranslation: (() -> Void)? override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } @@ -10,5 +59,28 @@ final class TranslationPanel: NSPanel { override func cancelOperation(_ sender: Any?) { onEscape?() } -} + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard let command = PanelKeyCommand.command( + charactersIgnoringModifiers: event.charactersIgnoringModifiers, + modifiers: event.modifierFlags + ) else { + return super.performKeyEquivalent(with: event) + } + if command == .copyTranslation { + onCopyTranslation?() + return true + } + if let selector = command.responderSelector { + if let responder = firstResponder, + responder !== self, + responder.tryToPerform(selector, with: self) { + return true + } + if NSApp.sendAction(selector, to: nil, from: self) { + return true + } + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/TextFlow/Features/Panel/TranslationPanelController.swift b/TextFlow/Features/Panel/TranslationPanelController.swift index 53525c5..d37899d 100644 --- a/TextFlow/Features/Panel/TranslationPanelController.swift +++ b/TextFlow/Features/Panel/TranslationPanelController.swift @@ -2,6 +2,13 @@ import AppKit import Combine import SwiftUI +enum PanelPositionPolicy { + /// 固定中的浮窗如果已经显示,就留在用户放置的位置,不再跟随鼠标。 + static func shouldReposition(isPinned: Bool, windowIsVisible: Bool) -> Bool { + !(isPinned && windowIsVisible) + } +} + enum OutsideMonitorPolicy { static func shouldInstall( windowIsVisible: Bool, @@ -75,6 +82,9 @@ final class TranslationPanelController: NSWindowController, NSWindowDelegate { panel.onEscape = { [weak self] in self?.closePanel() } + panel.onCopyTranslation = { [weak self] in + self?.viewModel.copyTranslation() + } viewModel.isPinned = settings.defaultPinned viewModel.onRequestClose = { [weak self] in self?.closeWindowOnly() @@ -99,30 +109,51 @@ final class TranslationPanelController: NSWindowController, NSWindowDelegate { func show(anchor: CGPoint = NSEvent.mouseLocation) { guard let panel = window else { return } - position(panel, around: anchor) + repositionIfNeeded(panel, around: anchor) NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() + focusOriginalTextIfNeeded(panel) recordPresentation(of: panel, activated: true) syncOutsideMonitor() } func showWithoutActivation(anchor: CGPoint = NSEvent.mouseLocation) { guard let panel = window else { return } - position(panel, around: anchor) + repositionIfNeeded(panel, around: anchor) panel.orderFrontRegardless() recordPresentation(of: panel, activated: false) syncOutsideMonitor() } + private func repositionIfNeeded(_ panel: NSWindow, around anchor: CGPoint) { + guard PanelPositionPolicy.shouldReposition( + isPinned: viewModel.isPinned, + windowIsVisible: panel.isVisible + ) else { + return + } + position(panel, around: anchor) + } + func activate() { guard let panel = window else { return } NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() + focusOriginalTextIfNeeded(panel) recordPresentation(of: panel, activated: true) } + /// 让原文框在浮窗获得焦点后即可直接输入与回车翻译; + /// 若用户已经把焦点放在其他控件上则不抢焦点。 + private func focusOriginalTextIfNeeded(_ panel: NSWindow) { + guard let initialResponder = panel.initialFirstResponder else { return } + let current = panel.firstResponder + guard current == nil || current === panel else { return } + panel.makeFirstResponder(initialResponder) + } + private func position(_ panel: NSWindow, around anchor: CGPoint) { let screen = NSScreen.screens.first(where: { $0.frame.contains(anchor) }) ?? NSScreen.main if let visibleFrame = screen?.visibleFrame { diff --git a/TextFlow/Features/Panel/TranslationPanelView.swift b/TextFlow/Features/Panel/TranslationPanelView.swift index 6e16b56..a0efe79 100644 --- a/TextFlow/Features/Panel/TranslationPanelView.swift +++ b/TextFlow/Features/Panel/TranslationPanelView.swift @@ -1,7 +1,45 @@ import SwiftUI +/// 流式译文的滚动跟随状态:默认跟到底部,用户手动往上滚动后本次翻译不再跟随。 +struct TranslationStreamFollowState { + private(set) var isFollowing = true + private(set) var isNearBottom = true + + static let nearBottomThreshold: CGFloat = 12 + + static func isNearBottom( + contentOffset: CGFloat, + containerHeight: CGFloat, + contentHeight: CGFloat + ) -> Bool { + contentOffset + containerHeight >= contentHeight - nearBottomThreshold + } + + mutating func reset() { + isFollowing = true + isNearBottom = true + } + + mutating func updateNearBottom(_ nearBottom: Bool) { + isNearBottom = nearBottom + } + + mutating func handleScrollPhase(_ phase: ScrollPhase) { + // 只有用户自己操作滚动条时才考虑退出跟随,程序滚动(.animating)不算。 + guard phase == .tracking || phase == .interacting || phase == .decelerating else { + return + } + if !isNearBottom { + isFollowing = false + } + } +} + struct TranslationPanelView: View { @ObservedObject var viewModel: TranslationPanelViewModel + @State private var followState = TranslationStreamFollowState() + + fileprivate static let translationBottomAnchor = "translation-bottom" var body: some View { VStack(spacing: 0) { @@ -42,26 +80,34 @@ struct TranslationPanelView: View { private var sourceSection: some View { VStack(alignment: .leading, spacing: 8) { - HStack { + HStack(spacing: 8) { Text("原文") .font(.headline) - Spacer() + presetControl + Spacer(minLength: 8) targetLanguageControls pinControl } - TextEditor(text: $viewModel.originalText) - .font(.body) - .scrollContentBackground(.hidden) - .frame(minHeight: 72) - .overlay { - if viewModel.phase == .acquiringSelectedText { - ProgressView("正在读取选中文字…") - } else if viewModel.phase == .recognizingText { - ProgressView("正在本地识别文字…") - } + OriginalTextEditor( + text: $viewModel.originalText, + isEditable: viewModel.isOriginalEditable, + onSubmit: { viewModel.translate() } + ) + .frame(minHeight: 72) + .overlay { + if viewModel.phase == .acquiringSelectedText { + ProgressView("正在读取选中文字…") + } else if viewModel.phase == .recognizingText { + ProgressView("正在本地识别文字…") } + } speechError(for: .original) HStack { + Button("翻译") { + viewModel.translate() + } + .disabled(!viewModel.canTranslate) + .help("使用当前原文翻译(回车)") Button(copyTitle(for: .original)) { viewModel.copyOriginal() } @@ -70,6 +116,12 @@ struct TranslationPanelView: View { viewModel.speakOriginal() } .disabled(viewModel.originalText.isEmpty) + if viewModel.canRecaptureOCRRegion { + Button("重新框选") { + viewModel.requestOCR() + } + .help("重新进入框选模式并识别新的区域") + } Spacer() speechControls(for: .original) } @@ -92,36 +144,16 @@ struct TranslationPanelView: View { .controlSize(.small) } } - ScrollView { - Text(viewModel.translatedText.isEmpty ? translationPlaceholder : viewModel.translatedText) - .foregroundStyle(viewModel.translatedText.isEmpty ? .secondary : .primary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(minHeight: 68) - - if let errorMessage = viewModel.errorMessage { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) - Text(errorMessage) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - if viewModel.canOfferOCR { - Button("开始 OCR") { - viewModel.requestOCR() - } - .fixedSize() - } - } - } + translationScrollView + + messageSection speechError(for: .translation) HStack { Button(copyTitle(for: .translation)) { viewModel.copyTranslation() } .disabled(viewModel.translatedText.isEmpty) + .help("复制译文(⌘⇧C)") Button("朗读") { viewModel.speakTranslation() } @@ -133,18 +165,152 @@ struct TranslationPanelView: View { .padding(14) } + private var translationScrollView: some View { + ScrollViewReader { proxy in + ScrollView { + Text(viewModel.translatedText.isEmpty ? translationPlaceholder : viewModel.translatedText) + .foregroundStyle(viewModel.translatedText.isEmpty ? .secondary : .primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + Color.clear + .frame(height: 1) + .id(Self.translationBottomAnchor) + } + .onScrollGeometryChange(for: Bool.self) { geometry in + TranslationStreamFollowState.isNearBottom( + contentOffset: geometry.contentOffset.y, + containerHeight: geometry.containerSize.height, + contentHeight: geometry.contentSize.height + ) + } action: { _, nearBottom in + followState.updateNearBottom(nearBottom) + } + .onScrollPhaseChange { _, phase in + followState.handleScrollPhase(phase) + } + .onChange(of: viewModel.translatedText) { _, _ in + guard followState.isFollowing else { return } + proxy.scrollTo(Self.translationBottomAnchor, anchor: .bottom) + } + .onChange(of: viewModel.translationRunID) { _, _ in + followState.reset() + proxy.scrollTo(Self.translationBottomAnchor, anchor: .bottom) + } + } + .frame(minHeight: 68) + } + + @ViewBuilder + private var messageSection: some View { + if let errorMessage = viewModel.errorMessage { + messageRow( + icon: "exclamationmark.triangle", + tint: .orange, + text: errorMessage + ) { + if viewModel.canRetryTranslation { + Button("重试") { + viewModel.translate() + } + .fixedSize() + } + if viewModel.canOpenSettings { + Button("打开设置") { + viewModel.requestOpenSettings() + } + .fixedSize() + } + if viewModel.canOfferOCR { + Button("开始 OCR") { + viewModel.requestOCR() + } + .fixedSize() + } + } + } + if let noticeMessage = viewModel.noticeMessage { + messageRow( + icon: "info.circle", + tint: .secondary, + text: noticeMessage + ) { + if viewModel.canTranslateClipboardText { + Button("翻译剪贴板内容") { + viewModel.translate() + } + .fixedSize() + } + if viewModel.canOfferOCR { + Button("开始 OCR") { + viewModel.requestOCR() + } + .fixedSize() + } + } + } + } + + private func messageRow( + icon: String, + tint: Color, + text: String, + @ViewBuilder actions: () -> Actions + ) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon) + .foregroundStyle(tint) + Text(text) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + actions() + } + } + private var targetLanguageControls: some View { HStack(spacing: 6) { - targetButton(.simplifiedChinese) - targetButton(.english) - Menu("更多") { - targetMenuButton(.japanese) - targetMenuButton(.korean) - targetMenuButton(.traditionalChinese) + ForEach(TargetLanguageMenu.primary) { language in + targetButton(language) + } + Menu { + ForEach(TargetLanguageMenu.secondary) { language in + targetMenuButton(language) + } + } label: { + Text(TargetLanguageMenu.title(for: viewModel.targetLanguage)) + .foregroundStyle( + TargetLanguageMenu.isSecondary(viewModel.targetLanguage) + ? AnyShapeStyle(Color.accentColor) + : AnyShapeStyle(.primary) + ) } .menuStyle(.borderlessButton) .fixedSize() + .help("更多目标语言") + } + } + + private var presetControl: some View { + Menu { + ForEach(viewModel.availablePresets) { preset in + Button { + viewModel.selectPreset(preset) + } label: { + if preset.id == viewModel.activePreset.id { + Label(preset.name, systemImage: "checkmark") + } else { + Text(preset.name) + } + } + } + } label: { + Text(viewModel.activePreset.name) + .font(.callout) + .foregroundStyle(.secondary) } + .menuStyle(.borderlessButton) + .fixedSize() + .help("切换本次翻译使用的预设(不修改设置中的默认预设)") } private func targetButton(_ language: TargetLanguage) -> some View { diff --git a/TextFlow/Features/Panel/TranslationPanelViewModel.swift b/TextFlow/Features/Panel/TranslationPanelViewModel.swift index 12ea8ca..a826cc0 100644 --- a/TextFlow/Features/Panel/TranslationPanelViewModel.swift +++ b/TextFlow/Features/Panel/TranslationPanelViewModel.swift @@ -18,6 +18,15 @@ final class TranslationPanelViewModel: ObservableObject { @Published var isPinned = false @Published private(set) var copiedField: CopiedField? @Published private(set) var canOfferOCR = false + @Published private(set) var canRetryTranslation = false + @Published private(set) var canOpenSettings = false + @Published private(set) var noticeMessage: String? + @Published private(set) var canTranslateClipboardText = false + /// 每次开始翻译都会变化,供译文区重置流式跟随状态。 + @Published private(set) var translationRunID = UUID() + /// 浮窗内临时选择的翻译预设,只在浮窗保持可见期间生效。 + @Published private(set) var sessionPresetID: UUID? + @Published private(set) var textSource: TextSource? @Published private(set) var speechState: SpeechPlaybackState = .idle @Published private(set) var activeSpeechTarget: SpeechTarget? @Published private(set) var speechErrorMessage: String? @@ -32,11 +41,18 @@ final class TranslationPanelViewModel: ObservableObject { var onResumeSpeech: (() -> Void)? var onReplaySpeech: (() -> Void)? var onUseLocalSpeech: (() -> Void)? + var onOpenSettings: (() -> Void)? var translationRunner: TranslationRunner? var translationPresetProvider: (() -> TranslationPreset)? + var availablePresetsProvider: (() -> [TranslationPreset])? + var shouldAutoCopyTranslation: (() -> Bool)? + var shouldAutoSpeakTranslation: (() -> Bool)? var onRequestClose: (() -> Void)? + private let pasteboard: NSPasteboard private var sessionID = UUID() + /// 浮窗内手动选择的目标语言,只在浮窗保持可见期间生效。 + private var manualTargetLanguage: TargetLanguage? private var translationTask: Task? private var translationFlushTask: Task? private var pendingTranslationDelta = "" @@ -48,6 +64,10 @@ final class TranslationPanelViewModel: ObservableObject { private let translationFlushThreshold = 128 private let translationFlushDelay = Duration.milliseconds(40) + init(pasteboard: NSPasteboard = .general) { + self.pasteboard = pasteboard + } + enum CopiedField { case original case translation @@ -76,29 +96,72 @@ final class TranslationPanelViewModel: ObservableObject { source: TextSource, targetLanguage: TargetLanguage, screenshot: CGImage? = nil, - autoTranslate: Bool + autoTranslate: Bool, + autoCopyOriginal: Bool = false ) { replaceSession(phase: .ready) self.originalText = originalText - self.targetLanguage = targetLanguage + self.targetLanguage = manualTargetLanguage ?? targetLanguage self.screenshot = screenshot + textSource = source isPreviewExpanded = false + if autoCopyOriginal { + copy(originalText, field: .original) + } if autoTranslate { translate() } } + /// 取词与 ⌘C 兜底都失败、但剪贴板里有文本时的降级入口。 + /// 默认只把内容填进原文框并说明来源,由用户决定是否翻译。 + func showClipboardFallbackContent( + text: String, + targetLanguage: TargetLanguage, + autoTranslate: Bool + ) { + showContent( + originalText: text, + source: .selectedText, + targetLanguage: targetLanguage, + autoTranslate: autoTranslate + ) + if autoTranslate { + noticeMessage = "未检测到选中文字,已改用剪贴板中的文本翻译。" + } else { + noticeMessage = "未检测到选中文字。以下内容来自剪贴板,尚未翻译。" + canTranslateClipboardText = true + canOfferOCR = true + } + } + func showNoSelectedText() { replaceSession(phase: .error) errorMessage = AppError.noSelectedText.errorDescription canOfferOCR = true } - func show(error: Error, offerOCR: Bool = false) { + func show(error: Error, offerOCR: Bool = false, allowRetry: Bool = false) { cancelTranslation() phase = .error errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription canOfferOCR = offerOCR + canRetryTranslation = allowRetry && canTranslate + canOpenSettings = (error as? AppError) == .configurationMissing + } + + /// 原文非空时才可以翻译;用于「翻译」按钮与回车的可用性判断。 + var canTranslate: Bool { + !originalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// 取词中与本地识别中不允许编辑原文,避免用户的输入被结果覆盖。 + var isOriginalEditable: Bool { + phase != .acquiringSelectedText && phase != .recognizingText + } + + var canRecaptureOCRRegion: Bool { + textSource == .ocr } func translate() { @@ -109,8 +172,7 @@ final class TranslationPanelViewModel: ObservableObject { return } guard let translationRunner else { - phase = .error - errorMessage = "尚未配置翻译模型。请在设置中添加并测试一个翻译配置。" + show(error: AppError.configurationMissing) return } @@ -119,14 +181,18 @@ final class TranslationPanelViewModel: ObservableObject { sessionID: activeSessionID, text: text, targetLanguage: targetLanguage, - preset: translationPresetProvider?() - ?? TranslationPreset.defaults[1] + preset: activePreset ) + translationRunID = UUID() translatedText = "" pendingTranslationDelta = "" translationUIFlushCount = 0 errorMessage = nil + noticeMessage = nil + canTranslateClipboardText = false canOfferOCR = false + canRetryTranslation = false + canOpenSettings = false phase = .translating translationTask = Task { [weak self] in @@ -145,21 +211,23 @@ final class TranslationPanelViewModel: ObservableObject { case .completed: self.flushTranslationDelta(sessionID: activeSessionID) self.phase = .ready + self.runTranslationCompletionActions() case .warning(let message): self.errorMessage = message } } - if self?.sessionID == activeSessionID, - self?.phase == .translating { - self?.flushTranslationDelta(sessionID: activeSessionID) - self?.phase = .ready + if let self, self.sessionID == activeSessionID, + self.phase == .translating { + self.flushTranslationDelta(sessionID: activeSessionID) + self.phase = .ready + self.runTranslationCompletionActions() } } catch is CancellationError { return } catch { guard let self, self.sessionID == activeSessionID else { return } self.flushTranslationDelta(sessionID: activeSessionID) - self.show(error: error) + self.show(error: error, allowRetry: true) } } } @@ -189,13 +257,41 @@ final class TranslationPanelViewModel: ObservableObject { onStartOCR?() } + func requestOpenSettings() { + onOpenSettings?() + } + func selectTargetLanguage(_ language: TargetLanguage) { targetLanguage = language + manualTargetLanguage = language if !originalText.isEmpty { translate() } } + /// 浮窗内可用的翻译预设,以及当前会话正在使用的那个。 + var availablePresets: [TranslationPreset] { + let presets = availablePresetsProvider?() ?? [] + return presets.isEmpty ? TranslationPreset.defaults : presets + } + + var activePreset: TranslationPreset { + if let sessionPresetID, + let preset = availablePresets.first(where: { $0.id == sessionPresetID }) { + return preset + } + return translationPresetProvider?() ?? TranslationPreset.defaults[1] + } + + /// 只切换当前浮窗会话使用的预设,不改设置里的默认预设。 + func selectPreset(_ preset: TranslationPreset) { + guard sessionPresetID != preset.id else { return } + sessionPresetID = preset.id + if canTranslate { + translate() + } + } + func speakOriginal() { speak(originalText, target: .original) } @@ -248,6 +344,9 @@ final class TranslationPanelViewModel: ObservableObject { screenshot = nil speechErrorMessage = nil canOfferLocalSpeech = false + // 浮窗关闭即结束会话:目标语言恢复自动检测,预设回到设置里的默认值。 + manualTargetLanguage = nil + sessionPresetID = nil onRequestClose?() } @@ -269,14 +368,18 @@ final class TranslationPanelViewModel: ObservableObject { translatedText = "" screenshot = nil errorMessage = nil + noticeMessage = nil + canTranslateClipboardText = false canOfferOCR = false + canRetryTranslation = false + canOpenSettings = false isPreviewExpanded = false + textSource = nil self.phase = phase } private func copy(_ value: String, field: CopiedField) { guard !value.isEmpty else { return } - let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(value, forType: .string) copiedField = field @@ -288,6 +391,17 @@ final class TranslationPanelViewModel: ObservableObject { } } + /// 翻译正常结束后的可选动作,全部由通用设置控制,默认不做任何事。 + private func runTranslationCompletionActions() { + guard !translatedText.isEmpty else { return } + if shouldAutoCopyTranslation?() == true { + copy(translatedText, field: .translation) + } + if shouldAutoSpeakTranslation?() == true { + speak(translatedText, target: .translation) + } + } + private func speak(_ text: String, target: SpeechTarget) { guard !text.isEmpty else { return } activeSpeechTarget = target diff --git a/TextFlow/Features/Settings/SettingsStore.swift b/TextFlow/Features/Settings/SettingsStore.swift index 9a779a1..7df5b2d 100644 --- a/TextFlow/Features/Settings/SettingsStore.swift +++ b/TextFlow/Features/Settings/SettingsStore.swift @@ -13,6 +13,10 @@ final class SettingsStore: ObservableObject { static let defaultPinned = "general.defaultPinned" static let autoTranslateOCR = "general.autoTranslateOCR" static let showOCRPreview = "general.showOCRPreview" + static let autoTranslateClipboardFallback = "general.autoTranslateClipboardFallback" + static let autoCopyOCRText = "general.autoCopyOCRText" + static let autoCopyTranslation = "general.autoCopyTranslation" + static let autoSpeakTranslation = "general.autoSpeakTranslation" static let localSpeechFallback = "general.localSpeechFallback" static let panelContentWidth = "panel.contentWidth" static let panelContentHeight = "panel.contentHeight" @@ -49,6 +53,23 @@ final class SettingsStore: ObservableObject { @Published var showOCRPreview: Bool { didSet { defaults.set(showOCRPreview, forKey: Key.showOCRPreview) } } + @Published var autoTranslateClipboardFallback: Bool { + didSet { + defaults.set( + autoTranslateClipboardFallback, + forKey: Key.autoTranslateClipboardFallback + ) + } + } + @Published var autoCopyOCRText: Bool { + didSet { defaults.set(autoCopyOCRText, forKey: Key.autoCopyOCRText) } + } + @Published var autoCopyTranslation: Bool { + didSet { defaults.set(autoCopyTranslation, forKey: Key.autoCopyTranslation) } + } + @Published var autoSpeakTranslation: Bool { + didSet { defaults.set(autoSpeakTranslation, forKey: Key.autoSpeakTranslation) } + } @Published var localSpeechFallback: Bool { didSet { defaults.set(localSpeechFallback, forKey: Key.localSpeechFallback) } } @@ -109,6 +130,10 @@ final class SettingsStore: ObservableObject { Key.defaultPinned: false, Key.autoTranslateOCR: true, Key.showOCRPreview: true, + Key.autoTranslateClipboardFallback: false, + Key.autoCopyOCRText: true, + Key.autoCopyTranslation: false, + Key.autoSpeakTranslation: false, Key.localSpeechFallback: true, Key.launchAtLogin: false, Key.panelContentWidth: Self.defaultPanelContentSize.width, @@ -125,6 +150,12 @@ final class SettingsStore: ObservableObject { defaultPinned = defaults.bool(forKey: Key.defaultPinned) autoTranslateOCR = defaults.bool(forKey: Key.autoTranslateOCR) showOCRPreview = defaults.bool(forKey: Key.showOCRPreview) + autoTranslateClipboardFallback = defaults.bool( + forKey: Key.autoTranslateClipboardFallback + ) + autoCopyOCRText = defaults.bool(forKey: Key.autoCopyOCRText) + autoCopyTranslation = defaults.bool(forKey: Key.autoCopyTranslation) + autoSpeakTranslation = defaults.bool(forKey: Key.autoSpeakTranslation) localSpeechFallback = defaults.bool(forKey: Key.localSpeechFallback) selectedTextShortcut = Self.load( HotKeyShortcut.self, diff --git a/TextFlow/Features/Settings/SettingsWindow.swift b/TextFlow/Features/Settings/SettingsWindow.swift index 2a383be..dcf73de 100644 --- a/TextFlow/Features/Settings/SettingsWindow.swift +++ b/TextFlow/Features/Settings/SettingsWindow.swift @@ -136,7 +136,14 @@ private struct GeneralSettingsView: View { Toggle("点击外部关闭浮窗", isOn: $settings.closeOnOutsideClick) Toggle("默认固定浮窗", isOn: $settings.defaultPinned) Toggle("OCR 完成后自动翻译", isOn: $settings.autoTranslateOCR) + Toggle("OCR 完成后自动复制识别文字", isOn: $settings.autoCopyOCRText) Toggle("显示 OCR 截图预览", isOn: $settings.showOCRPreview) + Toggle( + "无选中文字时自动翻译剪贴板", + isOn: $settings.autoTranslateClipboardFallback + ) + Toggle("翻译完成后自动复制译文", isOn: $settings.autoCopyTranslation) + Toggle("翻译完成后自动朗读译文", isOn: $settings.autoSpeakTranslation) Toggle("允许本地语音兜底", isOn: $settings.localSpeechFallback) if let error = launchAtLogin.errorMessage { Text(error) diff --git a/TextFlow/Features/Settings/SpeechProfilesView.swift b/TextFlow/Features/Settings/SpeechProfilesView.swift index 11e4d9a..0d8ccbb 100644 --- a/TextFlow/Features/Settings/SpeechProfilesView.swift +++ b/TextFlow/Features/Settings/SpeechProfilesView.swift @@ -25,7 +25,7 @@ struct SpeechProfilesView: View { ContentUnavailableView( "尚无语音配置", systemImage: "speaker.wave.2", - description: Text("添加 OpenAI-compatible Speech 配置;秘密只存钥匙串。") + description: Text("添加 OpenAI-compatible 或阿里云百炼 Qwen 配置;秘密只存钥匙串。") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -158,17 +158,41 @@ private struct SpeechProfileEditor: View { private var basicInformationCard: some View { SettingsCard( "基本信息", - subtitle: "模型名称会原样发送给 OpenAI-compatible Speech 服务。" + subtitle: basicInformationSubtitle ) { + HStack(alignment: .bottom, spacing: 14) { + SettingsField("请求协议") { + Picker("请求协议", selection: $profile.protocolKind) { + ForEach(SpeechProtocolKind.allCases) { + Text($0.displayName).tag($0) + } + } + .labelsHidden() + .pickerStyle(.menu) + .onChange(of: profile.protocolKind) { _, protocolKind in + if protocolKind == .dashScopeQwenHTTP { + profile.outputFormat = .wav + } + } + } + + if isDashScope { + Button("应用百炼推荐配置") { + applyDashScopeRecommendedConfiguration() + } + .help("保留当前百炼工作空间域名,并填写原生 HTTP 路径、模型、Voice 与慢速朗读指令。") + } + } + HStack(alignment: .top, spacing: 14) { SettingsField("配置名称") { - TextField("例如:DMX", text: $profile.name) + TextField("例如:百炼语音", text: $profile.name) .textFieldStyle(.roundedBorder) .controlSize(.large) } SettingsField("模型") { - TextField("例如:gpt-4o-mini-tts", text: $profile.model) + TextField(modelPlaceholder, text: $profile.model) .textFieldStyle(.roundedBorder) .controlSize(.large) } @@ -179,7 +203,7 @@ private struct SpeechProfileEditor: View { private var endpointCard: some View { SettingsCard( "接口地址", - subtitle: "可以分别填写 Base URL 与 Path,也可以直接使用完整 Endpoint。" + subtitle: endpointSubtitle ) { SettingsField("URL 模式") { Picker("URL 模式", selection: $profile.endpointMode) { @@ -193,20 +217,20 @@ private struct SpeechProfileEditor: View { if profile.endpointMode == .baseURLAndPath { SettingsField("Base URL") { - TextField("https://api.openai.com", text: $profile.baseURL) + TextField(baseURLPlaceholder, text: $profile.baseURL) .textFieldStyle(.roundedBorder) .controlSize(.large) } SettingsField("Path") { - TextField("/v1/audio/speech", text: $profile.path) + TextField(profile.protocolKind.defaultPath, text: $profile.path) .textFieldStyle(.roundedBorder) .controlSize(.large) } } else { SettingsField("完整 Endpoint") { TextField( - "https://api.openai.com/v1/audio/speech", + fullEndpointPlaceholder, text: $profile.fullEndpoint ) .textFieldStyle(.roundedBorder) @@ -219,29 +243,107 @@ private struct SpeechProfileEditor: View { private var voiceCard: some View { SettingsCard( "声音与朗读方式", - subtitle: "中文和英文可以使用不同 Voice;Instructions 可用于控制语速与语气。" + subtitle: voiceSubtitle ) { HStack(alignment: .top, spacing: 14) { - SettingsField("中文 Voice") { - TextField("例如:coral", text: $profile.chineseVoice) - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } + languageVoiceColumn( + title: "中文", + systemImage: "character.book.closed", + voice: $profile.chineseVoice, + instructions: $profile.chineseInstructions, + speed: $profile.chineseSpeed + ) - SettingsField("英文 Voice") { - TextField("例如:coral", text: $profile.englishVoice) - .textFieldStyle(.roundedBorder) - .controlSize(.large) + languageVoiceColumn( + title: "English", + systemImage: "textformat.abc", + voice: $profile.englishVoice, + instructions: $profile.englishInstructions, + speed: $profile.englishSpeed + ) + } + + if !supportsInstructions, isDashScope { + Label( + "qwen3-tts-flash 不支持朗读指令。需要慢速学习朗读时,请改用 qwen3-tts-instruct-flash。", + systemImage: "info.circle" + ) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + + private func languageVoiceColumn( + title: String, + systemImage: String, + voice: Binding, + instructions: Binding, + speed: Binding + ) -> some View { + VStack(alignment: .leading, spacing: 14) { + Label(title, systemImage: systemImage) + .font(.headline) + + SettingsField("Voice") { + TextField(voicePlaceholder, text: voice) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + + if supportsInstructions { + SettingsField( + "Instructions", + help: title == "中文" + ? "仅在朗读中文时发送。" + : "Only sent when reading English." + ) { + SettingsTextEditor(text: instructions, minHeight: 92) } } - SettingsField( - "Instructions", - help: "例如:Speak slowly and clearly for a language learner." - ) { - SettingsTextEditor(text: $profile.instructions, minHeight: 84) + if !isDashScope { + SettingsField( + "朗读速度", + help: "范围 0.25×–4.00×;学习建议从 0.75× 开始。" + ) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Slider( + value: speed, + in: SpeechProfile.minimumSpeed...SpeechProfile.maximumSpeed, + step: 0.05 + ) + + Text("\(speed.wrappedValue, specifier: "%.2f")×") + .monospacedDigit() + .frame(width: 54, alignment: .trailing) + } + + HStack(spacing: 8) { + Button("学习 0.75×") { + speed.wrappedValue = 0.75 + } + Button("正常 1.00×") { + speed.wrappedValue = SpeechProfile.defaultSpeed + } + .disabled( + speed.wrappedValue == SpeechProfile.defaultSpeed + ) + } + .controlSize(.small) + } + } } } + .padding(14) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.55)) + } } private var authorizationCard: some View { @@ -307,16 +409,26 @@ private struct SpeechProfileEditor: View { } private var requestBehaviorCard: some View { - SettingsCard("请求行为") { + SettingsCard( + "请求行为", + subtitle: isDashScope + ? "百炼 Qwen 的输出格式由服务决定。" + : "输出格式与超时属于请求传输设置。" + ) { HStack(alignment: .top, spacing: 14) { SettingsField("输出格式") { - Picker("输出格式", selection: $profile.outputFormat) { - ForEach(SpeechOutputFormat.allCases) { - Text($0.rawValue.uppercased()).tag($0) + if isDashScope { + Label("WAV(服务返回)", systemImage: "waveform") + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Picker("输出格式", selection: $profile.outputFormat) { + ForEach(SpeechOutputFormat.allCases) { + Text($0.rawValue.uppercased()).tag($0) + } } + .labelsHidden() + .pickerStyle(.menu) } - .labelsHidden() - .pickerStyle(.menu) } SettingsField("请求超时") { @@ -330,6 +442,7 @@ private struct SpeechProfileEditor: View { } } } + } } @@ -377,6 +490,93 @@ private struct SpeechProfileEditor: View { || statusMessage == "密钥与请求头已保存" } + private var isDashScope: Bool { + profile.protocolKind == .dashScopeQwenHTTP + } + + private var supportsInstructions: Bool { + !isDashScope || profile.model.lowercased().contains("instruct") + } + + private var basicInformationSubtitle: String { + if isDashScope { + return "使用百炼原生 Qwen HTTP 协议;支持 qwen3-tts-flash 与 qwen3-tts-instruct-flash。" + } + return "模型名称会原样发送给 OpenAI-compatible Speech 服务。" + } + + private var endpointSubtitle: String { + if isDashScope { + return "兼容模式的 /audio/speech 不适用于 Qwen3-TTS;请使用工作空间域名加百炼原生 HTTP 路径。" + } + return "可以分别填写 Base URL 与 Path,也可以直接使用完整 Endpoint。" + } + + private var voiceSubtitle: String { + if isDashScope { + return "中文和英文可独立配置 Voice;Instruct 模型还可分别配置 Instructions。" + } + return "中文和英文可独立配置 Voice、Instructions 与朗读速度。" + } + + private var modelPlaceholder: String { + isDashScope ? "例如:qwen3-tts-instruct-flash" : "例如:gpt-4o-mini-tts" + } + + private var voicePlaceholder: String { + isDashScope ? "例如:Cherry" : "例如:coral" + } + + private var baseURLPlaceholder: String { + isDashScope + ? "https://你的工作空间.cn-beijing.maas.aliyuncs.com" + : "https://api.openai.com" + } + + private var fullEndpointPlaceholder: String { + let base = isDashScope + ? "https://dashscope.aliyuncs.com" + : "https://api.openai.com" + return base + profile.protocolKind.defaultPath + } + + private func applyDashScopeRecommendedConfiguration() { + let baseURL = inferredDashScopeBaseURL() + profile.protocolKind = .dashScopeQwenHTTP + profile.endpointMode = .baseURLAndPath + profile.baseURL = baseURL + profile.path = SpeechProtocolKind.dashScopeQwenHTTP.defaultPath + profile.fullEndpoint = "" + profile.model = "qwen3-tts-instruct-flash" + profile.chineseVoice = "Cherry" + profile.englishVoice = "Cherry" + profile.outputFormat = .wav + profile.authMode = .bearer + profile.chineseInstructions = "以清晰自然的教学语气缓慢朗读,发音准确,短语之间自然停顿,句末停顿稍长。只朗读提供的文本,不添加、删除、翻译或解释任何内容。" + profile.englishInstructions = "Read slowly in a clear, natural teaching tone with accurate pronunciation and natural pauses. Read only the provided text without adding, removing, translating, or explaining it." + if profile.name == "新语音配置" { + profile.name = "百炼 Qwen TTS" + } + statusMessage = "已应用百炼推荐配置,请保存 API Key 后测试连接" + } + + private func inferredDashScopeBaseURL() -> String { + let candidate = profile.endpointMode == .fullEndpoint + ? profile.fullEndpoint + : profile.baseURL + guard let components = URLComponents(string: candidate), + let scheme = components.scheme, + let host = components.host?.lowercased(), + host.hasSuffix(".maas.aliyuncs.com") else { + return "https://dashscope.aliyuncs.com" + } + var base = URLComponents() + base.scheme = scheme + base.host = host + base.port = components.port + return base.url?.absoluteString ?? "https://dashscope.aliyuncs.com" + } + private func loadSecrets() async { let currentProfile = profile let profileID = currentProfile.id diff --git a/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift b/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift new file mode 100644 index 0000000..da46c3a --- /dev/null +++ b/TextFlow/Features/Speech/DashScopeQwenSpeechProvider.swift @@ -0,0 +1,296 @@ +import Foundation + +struct DashScopeQwenSpeechProvider: SpeechProviding { + let configuration: ResolvedSpeechConfiguration + private let dataLoader: any SpeechDataLoading + private let responseLimit: Int + + init( + configuration: ResolvedSpeechConfiguration, + session: URLSession? = nil, + responseLimit: Int = SpeechResourceLimits.maximumResponseBytes + ) { + self.configuration = configuration + self.responseLimit = responseLimit + let sessionConfiguration: URLSessionConfiguration + if let session { + sessionConfiguration = session.configuration + } else { + sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.timeoutIntervalForRequest = + configuration.profile.timeoutSeconds + sessionConfiguration.timeoutIntervalForResource = + configuration.profile.timeoutSeconds + sessionConfiguration.requestCachePolicy = + .reloadIgnoringLocalAndRemoteCacheData + sessionConfiguration.urlCache = nil + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.httpShouldSetCookies = false + } + dataLoader = LimitedURLSessionDataLoader( + configuration: sessionConfiguration + ) + } + + func synthesize(_ request: SpeechRequest) async throws -> SpeechAudio { + try await synthesize(request, maximumBytes: responseLimit) + } + + func synthesize( + _ request: SpeechRequest, + maximumBytes: Int + ) async throws -> SpeechAudio { + let effectiveLimit = min(responseLimit, maximumBytes) + guard effectiveLimit > 0 else { + throw AppError.responseTooLarge + } + + let metadata = try await requestAudioMetadata(for: request) + try Task.checkCancellation() + let audioURL = try Self.trustedAudioURL(from: metadata) + return try await downloadAudio( + from: audioURL, + maximumBytes: effectiveLimit + ) + } + + func testConnection() async throws -> ProviderTestResult { + _ = try await synthesize( + SpeechRequest(text: "Test", language: .english) + ) + return .success + } + + private func requestAudioMetadata( + for request: SpeechRequest + ) async throws -> DashScopeResponse { + var urlRequest = URLRequest(url: configuration.endpoint) + urlRequest.httpMethod = "POST" + urlRequest.timeoutInterval = configuration.profile.timeoutSeconds + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + for (name, value) in configuration.customHeaders { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + switch configuration.profile.authMode { + case .bearer: + guard let apiKey = configuration.apiKey, !apiKey.isEmpty else { + throw AppError.authenticationFailed + } + urlRequest.setValue( + "Bearer \(apiKey)", + forHTTPHeaderField: "Authorization" + ) + case .customHeadersOnly, .none: + break + } + + let voice: String + let languageType: String + let languageInstructions: String + switch request.language { + case .chinese: + voice = configuration.profile.chineseVoice + languageType = "Chinese" + languageInstructions = configuration.profile.chineseInstructions + case .english: + voice = configuration.profile.englishVoice + languageType = "English" + languageInstructions = configuration.profile.englishInstructions + } + let instructions: String? + if configuration.profile.model.lowercased().contains("instruct"), + !languageInstructions.isEmpty { + instructions = languageInstructions + } else { + instructions = nil + } + urlRequest.httpBody = try JSONEncoder().encode( + DashScopeRequest( + model: configuration.profile.model, + input: .init( + text: request.text, + voice: voice, + languageType: languageType, + instructions: instructions + ) + ) + ) + + do { + let (data, response) = try await dataLoader.data( + for: urlRequest, + maximumBytes: SpeechResourceLimits.maximumMetadataResponseBytes + ) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { + throw AppError.invalidResponse + } + try Self.validateHTTPStatus(response.statusCode, body: data) + do { + return try JSONDecoder().decode(DashScopeResponse.self, from: data) + } catch { + throw AppError.invalidResponse + } + } catch let error as URLError { + throw Self.mapNetworkError(error) + } + } + + private func downloadAudio( + from url: URL, + maximumBytes: Int + ) async throws -> SpeechAudio { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = configuration.profile.timeoutSeconds + + do { + let (data, response) = try await dataLoader.data( + for: request, + maximumBytes: maximumBytes + ) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { + throw AppError.invalidResponse + } + try Self.validateHTTPStatus(response.statusCode, body: data) + guard !data.isEmpty, data.count <= maximumBytes else { + throw data.count > maximumBytes + ? AppError.responseTooLarge + : AppError.invalidResponse + } + return SpeechAudio( + data: data, + format: Self.audioFormat(for: url, response: response) + ) + } catch let error as URLError { + throw Self.mapNetworkError(error) + } + } + + private static func validateHTTPStatus( + _ statusCode: Int, + body: Data + ) throws { + switch statusCode { + case 200..<300: + return + case 401, 403: + throw AppError.authenticationFailed + case 404: + throw AppError.endpointNotFound + case 429: + throw AppError.rateLimited + case 500..<600: + throw AppError.serviceUnavailable + default: + if let response = try? JSONDecoder().decode( + DashScopeErrorResponse.self, + from: body + ), (response.code + " " + (response.message ?? "")) + .lowercased() + .contains("model") { + throw AppError.modelNotFound + } + throw AppError.incompatibleRequest + } + } + + private static func trustedAudioURL( + from response: DashScopeResponse + ) throws -> URL { + guard let value = response.output?.audio?.url, !value.isEmpty else { + throw AppError.invalidResponse + } + guard var components = URLComponents(string: value), + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + !host.isEmpty, + components.user == nil, + components.password == nil, + components.fragment == nil, + (scheme == "http" || scheme == "https"), + components.port == nil || components.port == 443, + host.hasSuffix(".aliyuncs.com") else { + throw AppError.untrustedSpeechAudioURL + } + let labels = host.split(separator: ".") + guard labels.contains(where: { $0 == "oss" || $0.hasPrefix("oss-") }) else { + throw AppError.untrustedSpeechAudioURL + } + components.scheme = "https" + guard let url = components.url else { + throw AppError.untrustedSpeechAudioURL + } + return url + } + + private static func audioFormat( + for url: URL, + response: HTTPURLResponse + ) -> SpeechOutputFormat { + switch url.pathExtension.lowercased() { + case "mp3": return .mp3 + case "aac", "m4a": return .aac + case "flac": return .flac + case "opus": return .opus + case "pcm": return .pcm + default: + if response.mimeType?.lowercased().contains("mpeg") == true { + return .mp3 + } + return .wav + } + } + + private static func mapNetworkError(_ error: URLError) -> Error { + switch error.code { + case .cancelled: + return CancellationError() + case .timedOut: + return AppError.timedOut + case .secureConnectionFailed, .serverCertificateHasBadDate, + .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid: + return AppError.tlsFailure + default: + return AppError.networkFailure + } + } +} + +private struct DashScopeRequest: Encodable { + let model: String + let input: Input + + struct Input: Encodable { + let text: String + let voice: String + let languageType: String + let instructions: String? + + private enum CodingKeys: String, CodingKey { + case text + case voice + case languageType = "language_type" + case instructions + } + } +} + +private struct DashScopeResponse: Decodable { + let output: Output? + + struct Output: Decodable { + let audio: Audio? + } + + struct Audio: Decodable { + let url: String? + } +} + +private struct DashScopeErrorResponse: Decodable { + let code: String + let message: String? +} diff --git a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift index d190e24..9fb35eb 100644 --- a/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift +++ b/TextFlow/Features/Speech/OpenAICompatibleSpeechProvider.swift @@ -1,5 +1,23 @@ import Foundation +private struct OpenAISpeechRequestBody: Encodable { + let model: String + let input: String + let voice: String + let responseFormat: String + let instructions: String? + let speed: Double? + + private enum CodingKeys: String, CodingKey { + case model + case input + case voice + case responseFormat = "response_format" + case instructions + case speed + } +} + struct OpenAICompatibleSpeechProvider: SpeechProviding { let configuration: ResolvedSpeechConfiguration private let dataLoader: any SpeechDataLoading @@ -63,22 +81,31 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { } let voice: String + let instructions: String + let speed: Double switch request.language { case .chinese: voice = configuration.profile.chineseVoice + instructions = configuration.profile.chineseInstructions + speed = SpeechProfile.normalizedSpeed( + configuration.profile.chineseSpeed + ) case .english: voice = configuration.profile.englishVoice + instructions = configuration.profile.englishInstructions + speed = SpeechProfile.normalizedSpeed( + configuration.profile.englishSpeed + ) } - var body: [String: Any] = [ - "model": configuration.profile.model, - "input": request.text, - "voice": voice, - "response_format": configuration.profile.outputFormat.rawValue - ] - if !configuration.profile.instructions.isEmpty { - body["instructions"] = configuration.profile.instructions - } - urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + let body = OpenAISpeechRequestBody( + model: configuration.profile.model, + input: request.text, + voice: voice, + responseFormat: configuration.profile.outputFormat.rawValue, + instructions: instructions.isEmpty ? nil : instructions, + speed: speed == SpeechProfile.defaultSpeed ? nil : speed + ) + urlRequest.httpBody = try JSONEncoder().encode(body) do { let (data, response) = try await dataLoader.data( @@ -104,8 +131,12 @@ struct OpenAICompatibleSpeechProvider: SpeechProviding { throw AppError.authenticationFailed case 404: throw AppError.endpointNotFound + case 408: + throw AppError.timedOut case 429: throw AppError.rateLimited + case 500..<600: + throw AppError.serviceUnavailable default: throw AppError.incompatibleRequest } diff --git a/TextFlow/Features/Speech/SpeechResourceLimits.swift b/TextFlow/Features/Speech/SpeechResourceLimits.swift index ae39e8f..bc7052e 100644 --- a/TextFlow/Features/Speech/SpeechResourceLimits.swift +++ b/TextFlow/Features/Speech/SpeechResourceLimits.swift @@ -1,6 +1,7 @@ import Foundation enum SpeechResourceLimits { + static let maximumMetadataResponseBytes = 1 * 1024 * 1024 static let maximumResponseBytes = 25 * 1024 * 1024 static let maximumOperationBytes = 64 * 1024 * 1024 static let maximumCacheBytes = 64 * 1024 * 1024 diff --git a/TextFlow/Features/Speech/SpeechService.swift b/TextFlow/Features/Speech/SpeechService.swift index 91034a5..5fde900 100644 --- a/TextFlow/Features/Speech/SpeechService.swift +++ b/TextFlow/Features/Speech/SpeechService.swift @@ -18,7 +18,7 @@ final class SpeechService { throw AppError.speechConfigurationMissing } let resolved = try await resolve(profile) - let provider = OpenAICompatibleSpeechProvider(configuration: resolved) + let provider = makeProvider(configuration: resolved) let chunks = try await CancellableDetachedTask.run(priority: .userInitiated) { try Task.checkCancellation() let chunks = TextChunker.chunks(text) @@ -59,9 +59,18 @@ final class SpeechService { func testConnection(profile: SpeechProfile) async throws -> ProviderTestResult { let resolved = try await resolve(profile) - return try await OpenAICompatibleSpeechProvider( - configuration: resolved - ).testConnection() + return try await makeProvider(configuration: resolved).testConnection() + } + + private func makeProvider( + configuration: ResolvedSpeechConfiguration + ) -> any SpeechProviding { + switch configuration.profile.protocolKind { + case .openAISpeech: + OpenAICompatibleSpeechProvider(configuration: configuration) + case .dashScopeQwenHTTP: + DashScopeQwenSpeechProvider(configuration: configuration) + } } private func resolve( diff --git a/TextFlowTests/DashScopeQwenSpeechProviderTests.swift b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift new file mode 100644 index 0000000..016e937 --- /dev/null +++ b/TextFlowTests/DashScopeQwenSpeechProviderTests.swift @@ -0,0 +1,348 @@ +import Foundation +import XCTest +@testable import TextFlow + +final class DashScopeQwenSpeechProviderTests: XCTestCase { + override func tearDown() { + MockURLProtocol.handler = nil + super.tearDown() + } + + func testUsesNativeRequestShapeAndDownloadsTrustedWAVOverHTTPS() async throws { + var requestCount = 0 + MockURLProtocol.handler = { request in + requestCount += 1 + if requestCount == 1 { + XCTAssertEqual( + request.url?.absoluteString, + "https://speech.mock/api/v1/services/aigc/multimodal-generation/generation" + ) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer speech-secret" + ) + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-Region"), + "local-test" + ) + + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(json["model"] as? String, "qwen3-tts-instruct-flash") + let input = try XCTUnwrap(json["input"] as? [String: Any]) + XCTAssertEqual(input["text"] as? String, "hello") + XCTAssertEqual(input["voice"] as? String, "Cherry") + XCTAssertEqual(input["language_type"] as? String, "English") + XCTAssertEqual( + input["instructions"] as? String, + "Speak slowly and clearly." + ) + XCTAssertNil(json["response_format"]) + XCTAssertNil(json["speed"]) + + return .json( + try JSONSerialization.data(withJSONObject: [ + "output": [ + "audio": [ + "url": "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.wav?Expires=123" + ] + ], + "usage": ["characters": 5] + ]) + ) + } + + XCTAssertEqual(request.url?.scheme, "https") + XCTAssertEqual( + request.url?.host, + "dashscope-result-bj.oss-cn-beijing.aliyuncs.com" + ) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + XCTAssertNil(request.value(forHTTPHeaderField: "X-Region")) + return .init( + status: 200, + headers: ["Content-Type": "audio/x-wav"], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + let audio = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + + XCTAssertEqual(requestCount, 2) + XCTAssertEqual(audio.data, Data([0x52, 0x49, 0x46, 0x46])) + XCTAssertEqual(audio.format, .wav) + } + + func testPlainFlashOmitsUnsupportedInstructions() async throws { + let configuration = try configuration(model: "qwen3-tts-flash") + var requestCount = 0 + MockURLProtocol.handler = { request in + requestCount += 1 + if requestCount == 1 { + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let input = try XCTUnwrap(json["input"] as? [String: Any]) + XCTAssertNil(input["instructions"]) + XCTAssertEqual(input["language_type"] as? String, "Chinese") + return .json(try self.metadata(url: self.trustedAudioURL)) + } + return self.wavResponse + } + let provider = DashScopeQwenSpeechProvider( + configuration: configuration, + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "你好", language: .chinese) + ) + + XCTAssertEqual(requestCount, 2) + } + + func testInstructModelUsesChineseInstructionsForChineseText() async throws { + var requestCount = 0 + MockURLProtocol.handler = { request in + requestCount += 1 + if requestCount == 1 { + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let input = try XCTUnwrap(json["input"] as? [String: Any]) + XCTAssertEqual(input["language_type"] as? String, "Chinese") + XCTAssertEqual(input["instructions"] as? String, "请清晰缓慢地朗读。") + return .json(try self.metadata( + url: "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/chinese.wav" + )) + } + return .init( + status: 200, + headers: ["Content-Type": "audio/x-wav"], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "你好", language: .chinese) + ) + + XCTAssertEqual(requestCount, 2) + } + + func testRejectsUntrustedAudioURLWithoutDownloading() async throws { + var requestCount = 0 + MockURLProtocol.handler = { _ in + requestCount += 1 + return .json( + try self.metadata(url: "https://attacker.example/audio.wav") + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + XCTFail("Expected untrusted audio URL to fail") + } catch { + XCTAssertEqual(error as? AppError, .untrustedSpeechAudioURL) + } + XCTAssertEqual(requestCount, 1) + } + + func testRejectsMissingAudioURLAsInvalidResponse() async throws { + MockURLProtocol.handler = { _ in + .json(Data(#"{"output":{"audio":{}},"usage":{"characters":5}}"#.utf8)) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + XCTFail("Expected missing audio URL to fail") + } catch { + XCTAssertEqual(error as? AppError, .invalidResponse) + } + } + + func testMapsCancelledTransportToCancellationError() async throws { + MockURLProtocol.handler = { _ in + .init( + status: 200, + headers: [:], + chunks: [], + error: URLError(.cancelled) + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english) + ) + XCTFail("Expected cancellation") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + } + + func testRejectsOversizedAudioBeforeBuffering() async throws { + var requestCount = 0 + MockURLProtocol.handler = { _ in + requestCount += 1 + if requestCount == 1 { + return .json(try self.metadata(url: self.trustedAudioURL)) + } + return .init( + status: 200, + headers: [ + "Content-Type": "audio/x-wav", + "Content-Length": "4" + ], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + do { + _ = try await provider.synthesize( + SpeechRequest(text: "hello", language: .english), + maximumBytes: 3 + ) + XCTFail("Expected oversized audio to fail") + } catch { + XCTAssertEqual(error as? AppError, .responseTooLarge) + } + } + + func testMapsGenerationHTTPAndNetworkErrors() async throws { + let cases: [(MockURLProtocol.Response, AppError)] = [ + (.init(status: 401, headers: [:], chunks: [Data()], error: nil), .authenticationFailed), + (.init(status: 404, headers: [:], chunks: [Data()], error: nil), .endpointNotFound), + (.init(status: 429, headers: [:], chunks: [Data()], error: nil), .rateLimited), + (.init(status: 500, headers: [:], chunks: [Data()], error: nil), .serviceUnavailable), + (.init(status: 200, headers: [:], chunks: [], error: URLError(.timedOut)), .timedOut) + ] + + for (response, expected) in cases { + MockURLProtocol.handler = { _ in response } + let provider = DashScopeQwenSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + do { + _ = try await provider.synthesize( + SpeechRequest(text: "test", language: .english) + ) + XCTFail("Expected failure") + } catch { + XCTAssertEqual(error as? AppError, expected) + } + } + } + + private var trustedAudioURL: String { + "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.wav?Expires=123" + } + + private var wavResponse: MockURLProtocol.Response { + .init( + status: 200, + headers: ["Content-Type": "audio/x-wav"], + chunks: [Data([0x52, 0x49, 0x46, 0x46])], + error: nil + ) + } + + private func metadata(url: String) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "output": ["audio": ["url": url]], + "usage": ["characters": 5] + ]) + } + + private func configuration( + model: String = "qwen3-tts-instruct-flash" + ) throws -> ResolvedSpeechConfiguration { + var profile = SpeechProfile.empty() + profile.protocolKind = .dashScopeQwenHTTP + profile.endpointMode = .fullEndpoint + profile.fullEndpoint = "https://speech.mock/api/v1/services/aigc/multimodal-generation/generation" + profile.model = model + profile.chineseVoice = "Cherry" + profile.englishVoice = "Cherry" + profile.outputFormat = .wav + profile.chineseInstructions = "请清晰缓慢地朗读。" + profile.englishInstructions = "Speak slowly and clearly." + return ResolvedSpeechConfiguration( + profile: profile, + endpoint: try URLBuilder.speechEndpoint(for: profile), + apiKey: "speech-secret", + customHeaders: ["X-Region": "local-test"] + ) + } + + private func makeMockSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + configuration.urlCache = nil + configuration.httpCookieStorage = nil + return URLSession(configuration: configuration) + } + + private func requestBody(_ request: URLRequest) throws -> Data? { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + return nil + } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw stream.streamError ?? URLError(.cannotDecodeContentData) + } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } +} diff --git a/TextFlowTests/OriginalTextSubmitPolicyTests.swift b/TextFlowTests/OriginalTextSubmitPolicyTests.swift new file mode 100644 index 0000000..7834a79 --- /dev/null +++ b/TextFlowTests/OriginalTextSubmitPolicyTests.swift @@ -0,0 +1,73 @@ +import AppKit +import XCTest +@testable import TextFlow + +final class OriginalTextSubmitPolicyTests: XCTestCase { + func testPlainReturnSubmitsTranslation() { + XCTAssertTrue( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [], + hasMarkedText: false + ) + ) + XCTAssertTrue( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.keypadEnterKeyCode, + modifiers: [.numericPad, .function], + hasMarkedText: false + ) + ) + } + + func testInputMethodCompositionKeepsDefaultReturnBehavior() { + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [], + hasMarkedText: true + ) + ) + } + + func testShiftAndOptionReturnInsertNewline() { + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.shift], + hasMarkedText: false + ) + ) + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.option], + hasMarkedText: false + ) + ) + } + + func testOtherKeysAndCommandCombinationsAreNotSubmits() { + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: 0, + modifiers: [], + hasMarkedText: false + ) + ) + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.command], + hasMarkedText: false + ) + ) + XCTAssertFalse( + OriginalTextSubmitPolicy.shouldSubmit( + keyCode: OriginalTextSubmitPolicy.returnKeyCode, + modifiers: [.control], + hasMarkedText: false + ) + ) + } +} diff --git a/TextFlowTests/PermissionSeparationTests.swift b/TextFlowTests/PermissionSeparationTests.swift index 681ecab..11c1dca 100644 --- a/TextFlowTests/PermissionSeparationTests.swift +++ b/TextFlowTests/PermissionSeparationTests.swift @@ -32,4 +32,16 @@ final class PermissionSeparationTests: XCTestCase { ) ) } + + func testOnlyNonBlankClipboardTextIsOfferedAfterFailedAcquisition() { + XCTAssertNil(SelectedTextAcquisitionPolicy.offerableClipboardText(nil)) + XCTAssertNil(SelectedTextAcquisitionPolicy.offerableClipboardText("")) + XCTAssertNil( + SelectedTextAcquisitionPolicy.offerableClipboardText(" \n\t ") + ) + XCTAssertEqual( + SelectedTextAcquisitionPolicy.offerableClipboardText(" hello "), + " hello " + ) + } } diff --git a/TextFlowTests/SelectionOverlayWindowTests.swift b/TextFlowTests/SelectionOverlayWindowTests.swift index 27aaab5..3f8d560 100644 --- a/TextFlowTests/SelectionOverlayWindowTests.swift +++ b/TextFlowTests/SelectionOverlayWindowTests.swift @@ -19,4 +19,29 @@ final class SelectionOverlayWindowTests: XCTestCase { Self.retainedWindows.append(window) } } + + func testOverlayHintDisappearsOnceDraggingStarts() throws { + let view = SelectionOverlayView( + frame: NSRect(x: 0, y: 0, width: 400, height: 300) + ) + XCTAssertTrue(view.showsHint) + XCTAssertEqual(SelectionOverlayView.hintText, "拖拽框选文字 · Esc 取消") + + let mouseDown = try XCTUnwrap( + NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 20, y: 20), + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) + ) + view.mouseDown(with: mouseDown) + + XCTAssertFalse(view.showsHint) + } } diff --git a/TextFlowTests/SettingsStoreTests.swift b/TextFlowTests/SettingsStoreTests.swift index 5d52cda..1cebecf 100644 --- a/TextFlowTests/SettingsStoreTests.swift +++ b/TextFlowTests/SettingsStoreTests.swift @@ -62,6 +62,31 @@ final class SettingsStoreTests: XCTestCase { ) } + func testInteractionDefaultsAreRegisteredAndPersist() { + let suiteName = "SettingsStoreInteractionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let settings = SettingsStore(defaults: defaults) + XCTAssertFalse(settings.autoTranslateClipboardFallback) + XCTAssertTrue(settings.autoCopyOCRText) + XCTAssertFalse(settings.autoCopyTranslation) + XCTAssertFalse(settings.autoSpeakTranslation) + + settings.autoTranslateClipboardFallback = true + settings.autoCopyOCRText = false + settings.autoCopyTranslation = true + settings.autoSpeakTranslation = true + + let reloaded = SettingsStore(defaults: defaults) + XCTAssertTrue(reloaded.autoTranslateClipboardFallback) + XCTAssertFalse(reloaded.autoCopyOCRText) + XCTAssertTrue(reloaded.autoCopyTranslation) + XCTAssertTrue(reloaded.autoSpeakTranslation) + } + func testSilentShortcutRollbackPersistsWithoutRecursiveNotification() { let suiteName = "SettingsStoreShortcutTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! diff --git a/TextFlowTests/SpeechAudioCacheTests.swift b/TextFlowTests/SpeechAudioCacheTests.swift index 0918f66..3e56ede 100644 --- a/TextFlowTests/SpeechAudioCacheTests.swift +++ b/TextFlowTests/SpeechAudioCacheTests.swift @@ -28,12 +28,18 @@ final class SpeechAudioCacheTests: XCTestCase { profile: profile ) - profile.instructions = "Speak slowly." + profile.englishInstructions = "Speak slowly." let changedPrompt = SpeechAudioCacheKey( text: "hello", language: .english, profile: profile ) + profile.englishSpeed = 0.75 + let changedSpeed = SpeechAudioCacheKey( + text: "hello", + language: .english, + profile: profile + ) let changedText = SpeechAudioCacheKey( text: "hello again", language: .english, @@ -46,6 +52,7 @@ final class SpeechAudioCacheTests: XCTestCase { ) XCTAssertNotEqual(original, changedPrompt) + XCTAssertNotEqual(changedPrompt, changedSpeed) XCTAssertNotEqual(original, changedText) XCTAssertNotEqual(original, changedLanguage) } diff --git a/TextFlowTests/SpeechProfileSecurityTests.swift b/TextFlowTests/SpeechProfileSecurityTests.swift index 54c665a..7fadbee 100644 --- a/TextFlowTests/SpeechProfileSecurityTests.swift +++ b/TextFlowTests/SpeechProfileSecurityTests.swift @@ -15,5 +15,68 @@ final class SpeechProfileSecurityTests: XCTestCase { XCTAssertTrue(json.contains("X-Speech-Key")) XCTAssertFalse(json.lowercased().contains("apikey")) XCTAssertFalse(json.contains("speech-secret")) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + ) + XCTAssertNil(object["instructions"]) + XCTAssertNil(object["speed"]) + XCTAssertNotNil(object["chineseInstructions"]) + XCTAssertNotNil(object["englishInstructions"]) + XCTAssertNotNil(object["chineseSpeed"]) + XCTAssertNotNil(object["englishSpeed"]) + } + + func testLegacyProfileDefaultsToOpenAISpeechProtocol() throws { + let legacyProfile = """ + { + "id": "942D2786-01AA-4D5C-871F-C881A1B8B803", + "name": "Legacy", + "endpointMode": "baseURLAndPath", + "baseURL": "https://api.openai.com", + "path": "/v1/audio/speech", + "fullEndpoint": "", + "model": "gpt-4o-mini-tts", + "chineseVoice": "coral", + "englishVoice": "coral", + "outputFormat": "mp3", + "instructions": "Speak slowly.", + "speed": 0.85, + "authMode": "bearer", + "customHeaders": [], + "timeoutSeconds": 60 + } + """ + + let profile = try JSONDecoder().decode( + SpeechProfile.self, + from: Data(legacyProfile.utf8) + ) + + XCTAssertEqual(profile.protocolKind, .openAISpeech) + XCTAssertEqual(profile.chineseInstructions, "Speak slowly.") + XCTAssertEqual(profile.englishInstructions, "Speak slowly.") + XCTAssertEqual(profile.chineseSpeed, 0.85) + XCTAssertEqual(profile.englishSpeed, 0.85) + } + + func testPersistedSpeedIsClampedToOfficialRange() throws { + var profile = SpeechProfile.empty() + profile.chineseSpeed = 9.0 + profile.englishSpeed = -1.0 + let encoded = try JSONEncoder().encode(profile) + let decoded = try JSONDecoder().decode(SpeechProfile.self, from: encoded) + + XCTAssertEqual(decoded.chineseSpeed, SpeechProfile.maximumSpeed) + XCTAssertEqual(decoded.englishSpeed, SpeechProfile.minimumSpeed) + } + + func testAssignedSpeedIsNormalizedToTwoDecimalPlaces() { + var profile = SpeechProfile.empty() + + profile.chineseSpeed = 0.8500000000000001 + profile.englishSpeed = 0.8000000000000002 + + XCTAssertEqual(profile.chineseSpeed, 0.85) + XCTAssertEqual(profile.englishSpeed, 0.8) } } diff --git a/TextFlowTests/SpeechProviderIntegrationTests.swift b/TextFlowTests/SpeechProviderIntegrationTests.swift index d8175ba..f26cb59 100644 --- a/TextFlowTests/SpeechProviderIntegrationTests.swift +++ b/TextFlowTests/SpeechProviderIntegrationTests.swift @@ -36,6 +36,124 @@ final class SpeechProviderIntegrationTests: XCTestCase { XCTAssertEqual(audio.format, .mp3) } + func testSendsConfiguredSpeedToOpenAICompatibleEndpoint() async throws { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(json["speed"] as? Double, 0.75) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try configuration(speed: 0.75), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Learn at this pace.", language: .english) + ) + } + + func testUsesLanguageSpecificInstructionsAndSpeeds() async throws { + let cases: [( + language: SpeechLanguage, + expectedInstructions: String, + expectedSpeed: Double + )] = [ + (.chinese, "清晰缓慢地朗读。", 0.8), + (.english, "Read clearly for a learner.", 0.9) + ] + + for testCase in cases { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual( + json["instructions"] as? String, + testCase.expectedInstructions + ) + XCTAssertEqual(json["speed"] as? Double, testCase.expectedSpeed) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try languageSpecificConfiguration(), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Language specific.", language: testCase.language) + ) + } + } + + func testSerializesSliderSpeedsWithoutBinaryFloatingPointTails() async throws { + let cases: [(speed: Double, expectedJSONNumber: String)] = [ + (0.8, "0.8"), + (0.8500000000000001, "0.85") + ] + + for testCase in cases { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + XCTAssertEqual( + try self.jsonNumber(forKey: "speed", in: body), + testCase.expectedJSONNumber + ) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try configuration(speed: testCase.speed), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Exact speed.", language: .english) + ) + } + } + + func testDefaultSpeedIsOmittedForLegacyCompatibleEndpoints() async throws { + MockURLProtocol.handler = { request in + let body = try XCTUnwrap(try self.requestBody(request)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertNil(json["speed"]) + return .init( + status: 200, + headers: ["Content-Type": "audio/mpeg"], + chunks: [Data([0x49, 0x44, 0x33, 0x04])], + error: nil + ) + } + let provider = OpenAICompatibleSpeechProvider( + configuration: try configuration(), + session: makeMockSession() + ) + + _ = try await provider.synthesize( + SpeechRequest(text: "Default pace.", language: .english) + ) + } + func testMapsSpeechHTTPAndNetworkErrors() async throws { let cases: [(MockURLProtocol.Response, AppError)] = [ ( @@ -65,6 +183,24 @@ final class SpeechProviderIntegrationTests: XCTestCase { ), .rateLimited ), + ( + .init( + status: 500, + headers: [:], + chunks: [Data()], + error: nil + ), + .serviceUnavailable + ), + ( + .init( + status: 503, + headers: [:], + chunks: [Data()], + error: nil + ), + .serviceUnavailable + ), ( .init( status: 200, @@ -121,10 +257,30 @@ final class SpeechProviderIntegrationTests: XCTestCase { } } - private func configuration() throws -> ResolvedSpeechConfiguration { + private func configuration( + speed: Double = 1.0 + ) throws -> ResolvedSpeechConfiguration { var profile = SpeechProfile.empty() profile.endpointMode = .fullEndpoint profile.fullEndpoint = "https://speech.mock/v1/audio/speech" + profile.chineseSpeed = speed + profile.englishSpeed = speed + return ResolvedSpeechConfiguration( + profile: profile, + endpoint: try URLBuilder.speechEndpoint(for: profile), + apiKey: "speech-secret", + customHeaders: ["X-Region": "local-test"] + ) + } + + private func languageSpecificConfiguration() throws -> ResolvedSpeechConfiguration { + var profile = SpeechProfile.empty() + profile.endpointMode = .fullEndpoint + profile.fullEndpoint = "https://speech.mock/v1/audio/speech" + profile.chineseInstructions = "清晰缓慢地朗读。" + profile.englishInstructions = "Read clearly for a learner." + profile.chineseSpeed = 0.8 + profile.englishSpeed = 0.9 return ResolvedSpeechConfiguration( profile: profile, endpoint: try URLBuilder.speechEndpoint(for: profile), @@ -140,4 +296,38 @@ final class SpeechProviderIntegrationTests: XCTestCase { configuration.httpCookieStorage = nil return URLSession(configuration: configuration) } + + private func requestBody(_ request: URLRequest) throws -> Data? { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + return nil + } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw stream.streamError ?? URLError(.cannotDecodeContentData) + } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } + + private func jsonNumber(forKey key: String, in data: Data) throws -> String { + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + let pattern = "\\\"\(NSRegularExpression.escapedPattern(for: key))\\\"\\s*:\\s*[-+0-9.eE]+" + let fieldRange = try XCTUnwrap( + json.range(of: pattern, options: .regularExpression) + ) + let field = json[fieldRange] + let separator = try XCTUnwrap(field.firstIndex(of: ":")) + return field[field.index(after: separator)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + } } diff --git a/TextFlowTests/TranslationPanelControllerTests.swift b/TextFlowTests/TranslationPanelControllerTests.swift index 2b37e42..eb451ec 100644 --- a/TextFlowTests/TranslationPanelControllerTests.swift +++ b/TextFlowTests/TranslationPanelControllerTests.swift @@ -2,6 +2,30 @@ import AppKit import XCTest @testable import TextFlow +/// 只记录收到的编辑命令,避免测试写入系统剪贴板。 +@MainActor +private final class EditingCommandRecorder: NSView { + private(set) var received: [String] = [] + + override var acceptsFirstResponder: Bool { true } + + override func selectAll(_ sender: Any?) { + received.append("selectAll:") + } + + @objc func copy(_ sender: Any?) { + received.append("copy:") + } + + @objc func paste(_ sender: Any?) { + received.append("paste:") + } + + @objc func cut(_ sender: Any?) { + received.append("cut:") + } +} + @MainActor final class TranslationPanelControllerTests: XCTestCase { private static var retainedControllers: [TranslationPanelController] = [] @@ -102,6 +126,61 @@ final class TranslationPanelControllerTests: XCTestCase { Self.retainedControllers.append(controller) } + func testPinnedVisiblePanelKeepsItsPositionOnNextShow() throws { + let suiteName = "TranslationPanelPinPositionTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let viewModel = TranslationPanelViewModel() + let controller = TranslationPanelController( + viewModel: viewModel, + settings: SettingsStore(defaults: defaults), + diagnostics: DiagnosticsService() + ) + let panel = try XCTUnwrap(controller.window as? TranslationPanel) + let screenFrame = try XCTUnwrap(NSScreen.main?.visibleFrame) + + viewModel.isPinned = true + controller.show(anchor: CGPoint(x: screenFrame.midX, y: screenFrame.midY)) + let pinnedFrame = panel.frame + + controller.show(anchor: CGPoint(x: screenFrame.minX + 10, y: screenFrame.minY + 10)) + XCTAssertEqual(panel.frame, pinnedFrame) + + controller.showWithoutActivation( + anchor: CGPoint(x: screenFrame.maxX - 10, y: screenFrame.maxY - 10) + ) + XCTAssertEqual(panel.frame, pinnedFrame) + + viewModel.isPinned = false + controller.show(anchor: CGPoint(x: screenFrame.minX + 10, y: screenFrame.minY + 10)) + XCTAssertNotEqual(panel.frame, pinnedFrame) + + panel.orderOut(nil) + Self.retainedControllers.append(controller) + } + + func testPanelPositionPolicySkipsOnlyPinnedVisiblePanels() { + XCTAssertFalse( + PanelPositionPolicy.shouldReposition( + isPinned: true, + windowIsVisible: true + ) + ) + XCTAssertTrue( + PanelPositionPolicy.shouldReposition( + isPinned: true, + windowIsVisible: false + ) + ) + XCTAssertTrue( + PanelPositionPolicy.shouldReposition( + isPinned: false, + windowIsVisible: true + ) + ) + } + func testOutsideMonitorPolicyOnlyInstallsForVisibleUnpinnedPanel() { XCTAssertTrue( OutsideMonitorPolicy.shouldInstall( @@ -133,6 +212,144 @@ final class TranslationPanelControllerTests: XCTestCase { ) } + func testPanelKeyCommandMappingCoversStandardEditingAndTranslationCopy() { + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "a", + modifiers: [.command] + ), + .selectAll + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "c", + modifiers: [.command] + ), + .copy + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "v", + modifiers: [.command] + ), + .paste + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "x", + modifiers: [.command] + ), + .cut + ) + XCTAssertEqual( + PanelKeyCommand.command( + charactersIgnoringModifiers: "C", + modifiers: [.command, .shift] + ), + .copyTranslation + ) + XCTAssertNil( + PanelKeyCommand.command( + charactersIgnoringModifiers: "c", + modifiers: [] + ) + ) + XCTAssertNil( + PanelKeyCommand.command( + charactersIgnoringModifiers: "c", + modifiers: [.command, .option] + ) + ) + } + + func testCommandShiftCCopiesTranslationFromAnywhereInThePanel() throws { + let panel = TranslationPanel( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 330), + styleMask: [.borderless, .resizable], + backing: .buffered, + defer: false + ) + var copyRequests = 0 + panel.onCopyTranslation = { copyRequests += 1 } + + let event = try XCTUnwrap( + Self.keyEvent( + characters: "C", + modifiers: [.command, .shift], + keyCode: 8, + window: panel + ) + ) + + XCTAssertTrue(panel.performKeyEquivalent(with: event)) + XCTAssertEqual(copyRequests, 1) + } + + func testBorderlessPanelForwardsEditingShortcutsToFirstResponder() throws { + let suiteName = "TranslationPanelEditingTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let controller = TranslationPanelController( + viewModel: TranslationPanelViewModel(), + settings: SettingsStore(defaults: defaults), + diagnostics: DiagnosticsService() + ) + let panel = try XCTUnwrap(controller.window as? TranslationPanel) + let responder = EditingCommandRecorder( + frame: NSRect(x: 0, y: 0, width: 10, height: 10) + ) + controller.show() + panel.contentView?.addSubview(responder) + defer { + responder.removeFromSuperview() + panel.orderOut(nil) + Self.retainedControllers.append(controller) + } + XCTAssertTrue(panel.makeFirstResponder(responder)) + + for (characters, keyCode, expected) in [ + ("a", CGKeyCode(0), "selectAll:"), + ("c", CGKeyCode(8), "copy:"), + ("v", CGKeyCode(9), "paste:"), + ("x", CGKeyCode(7), "cut:") + ] { + let event = try XCTUnwrap( + Self.keyEvent( + characters: characters, + modifiers: [.command], + keyCode: UInt16(keyCode), + window: panel + ) + ) + XCTAssertTrue( + panel.performKeyEquivalent(with: event), + "⌘\(characters) 未被浮窗分发" + ) + XCTAssertEqual(responder.received.last, expected) + } + } + + private static func keyEvent( + characters: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + window: NSWindow + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) + } + func testPanelVisualSnapshot() throws { let suiteName = "TranslationPanelVisualTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) diff --git a/TextFlowTests/TranslationPanelViewModelTests.swift b/TextFlowTests/TranslationPanelViewModelTests.swift index dd55f84..bcf0ab3 100644 --- a/TextFlowTests/TranslationPanelViewModelTests.swift +++ b/TextFlowTests/TranslationPanelViewModelTests.swift @@ -1,8 +1,27 @@ +import AppKit +import SwiftUI import XCTest @testable import TextFlow @MainActor final class TranslationPanelViewModelTests: XCTestCase { + private var testPasteboards: [NSPasteboard] = [] + + override func tearDown() { + testPasteboards.forEach { $0.releaseGlobally() } + testPasteboards.removeAll() + super.tearDown() + } + + /// 独立剪贴板,避免单元测试破坏开发机上的系统剪贴板。 + private func makeTestPasteboard() -> NSPasteboard { + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("TextFlowTests-\(UUID().uuidString)") + ) + testPasteboards.append(pasteboard) + return pasteboard + } + func testNewSessionPreventsOldTranslationFromOverwriting() async throws { let viewModel = TranslationPanelViewModel() viewModel.translationRunner = { request in @@ -232,6 +251,423 @@ final class TranslationPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.phase, .ready) } + func testTranslateWhileTranslatingCancelsAndRerunsWithCurrentText() async throws { + let viewModel = TranslationPanelViewModel() + var requestedTexts: [String] = [] + viewModel.translationRunner = { request in + requestedTexts.append(request.text) + return AsyncThrowingStream { continuation in + let task = Task { + if request.text == "hello" { + try? await Task.sleep(for: .seconds(1)) + } + guard !Task.isCancelled else { return } + continuation.yield(.delta(request.text.uppercased())) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + try await Task.sleep(for: .milliseconds(20)) + XCTAssertEqual(viewModel.phase, .translating) + + viewModel.originalText = "hello world" + viewModel.translate() + for _ in 0..<100 where viewModel.phase == .translating { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(requestedTexts, ["hello", "hello world"]) + XCTAssertEqual(viewModel.translatedText, "HELLO WORLD") + XCTAssertEqual(viewModel.phase, .ready) + } + + func testEmptyOriginalTextDisablesTranslateAction() { + let viewModel = TranslationPanelViewModel() + + XCTAssertFalse(viewModel.canTranslate) + viewModel.originalText = " \n " + XCTAssertFalse(viewModel.canTranslate) + viewModel.originalText = "hello" + XCTAssertTrue(viewModel.canTranslate) + } + + func testFailedTranslationOffersRetryThatReusesCurrentText() async throws { + let viewModel = TranslationPanelViewModel() + var attempts = 0 + var requestedTexts: [String] = [] + viewModel.translationRunner = { request in + attempts += 1 + requestedTexts.append(request.text) + let shouldFail = attempts == 1 + return AsyncThrowingStream { continuation in + if shouldFail { + continuation.finish(throwing: AppError.networkFailure) + } else { + continuation.yield(.delta("你好")) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + for _ in 0..<100 where viewModel.phase != .error { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(viewModel.phase, .error) + XCTAssertTrue(viewModel.canRetryTranslation) + XCTAssertFalse(viewModel.canOpenSettings) + + viewModel.translate() + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(requestedTexts, ["hello", "hello"]) + XCTAssertEqual(viewModel.translatedText, "你好") + XCTAssertFalse(viewModel.canRetryTranslation) + } + + func testMissingTranslationConfigurationOffersSettingsWithoutRetry() { + let viewModel = TranslationPanelViewModel() + var settingsRequests = 0 + viewModel.onOpenSettings = { settingsRequests += 1 } + viewModel.originalText = "hello" + + viewModel.translate() + + XCTAssertEqual(viewModel.phase, .error) + XCTAssertEqual( + viewModel.errorMessage, + AppError.configurationMissing.errorDescription + ) + XCTAssertTrue(viewModel.canOpenSettings) + XCTAssertFalse(viewModel.canRetryTranslation) + XCTAssertEqual(settingsRequests, 0) + + viewModel.requestOpenSettings() + XCTAssertEqual(settingsRequests, 1) + } + + func testClipboardFallbackFillsOriginalTextWithoutTranslating() async throws { + let viewModel = TranslationPanelViewModel() + var translationRequests = 0 + viewModel.translationRunner = { _ in + translationRequests += 1 + return AsyncThrowingStream { $0.finish() } + } + + viewModel.showClipboardFallbackContent( + text: "clipboard text", + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(viewModel.originalText, "clipboard text") + XCTAssertEqual(translationRequests, 0) + XCTAssertTrue(viewModel.canTranslateClipboardText) + XCTAssertTrue(viewModel.canOfferOCR) + XCTAssertNotNil(viewModel.noticeMessage) + XCTAssertNil(viewModel.errorMessage) + + viewModel.translate() + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(translationRequests, 1) + XCTAssertNil(viewModel.noticeMessage) + XCTAssertFalse(viewModel.canTranslateClipboardText) + XCTAssertFalse(viewModel.canOfferOCR) + } + + func testClipboardFallbackTranslatesImmediatelyWhenEnabled() async throws { + let viewModel = TranslationPanelViewModel() + var requestedTexts: [String] = [] + viewModel.translationRunner = { request in + requestedTexts.append(request.text) + return AsyncThrowingStream { $0.finish() } + } + + viewModel.showClipboardFallbackContent( + text: "clipboard text", + targetLanguage: .english, + autoTranslate: true + ) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(requestedTexts, ["clipboard text"]) + XCTAssertFalse(viewModel.canTranslateClipboardText) + XCTAssertNotNil(viewModel.noticeMessage) + } + + func testOCRContentCanAutomaticallyCopyRecognizedText() { + let pasteboard = makeTestPasteboard() + let viewModel = TranslationPanelViewModel(pasteboard: pasteboard) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false, + autoCopyOriginal: true + ) + + XCTAssertEqual(pasteboard.string(forType: .string), "recognized") + XCTAssertEqual(viewModel.copiedField, .original) + } + + func testOCRContentDoesNotCopyWhenSettingIsOff() { + let pasteboard = makeTestPasteboard() + let viewModel = TranslationPanelViewModel(pasteboard: pasteboard) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + + XCTAssertNil(pasteboard.string(forType: .string)) + XCTAssertNil(viewModel.copiedField) + } + + func testCompletedTranslationCopiesTranslationOnlyWhenEnabled() async throws { + let pasteboard = makeTestPasteboard() + let viewModel = TranslationPanelViewModel(pasteboard: pasteboard) + var autoCopy = false + viewModel.shouldAutoCopyTranslation = { autoCopy } + viewModel.translationRunner = { _ in + AsyncThrowingStream { continuation in + continuation.yield(.delta("你好")) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertNil(pasteboard.string(forType: .string)) + XCTAssertNil(viewModel.copiedField) + + autoCopy = true + viewModel.translate() + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(pasteboard.string(forType: .string), "你好") + XCTAssertEqual(viewModel.copiedField, .translation) + } + + func testManualTargetLanguageSticksUntilPanelCloses() async throws { + let viewModel = TranslationPanelViewModel() + viewModel.translationRunner = { _ in + AsyncThrowingStream { $0.finish() } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + viewModel.selectTargetLanguage(.japanese) + try await Task.sleep(for: .milliseconds(20)) + + // 面板仍然可见时,后续取词的自动语言检测不再覆盖用户的选择。 + viewModel.showContent( + originalText: "second", + source: .selectedText, + targetLanguage: .english, + autoTranslate: false + ) + XCTAssertEqual(viewModel.targetLanguage, .japanese) + + viewModel.close() + viewModel.showContent( + originalText: "third", + source: .selectedText, + targetLanguage: .english, + autoTranslate: false + ) + XCTAssertEqual(viewModel.targetLanguage, .english) + } + + func testSessionPresetOverridesDefaultUntilPanelCloses() async throws { + let viewModel = TranslationPanelViewModel() + var requestedPresetIDs: [UUID] = [] + viewModel.availablePresetsProvider = { TranslationPreset.defaults } + viewModel.translationPresetProvider = { TranslationPreset.defaults[0] } + viewModel.translationRunner = { request in + requestedPresetIDs.append(request.preset.id) + return AsyncThrowingStream { $0.finish() } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + try await Task.sleep(for: .milliseconds(20)) + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.generalID) + + viewModel.selectPreset(TranslationPreset.defaults[2]) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.naturalID) + XCTAssertEqual( + requestedPresetIDs, + [TranslationPreset.generalID, TranslationPreset.naturalID] + ) + + viewModel.close() + XCTAssertNil(viewModel.sessionPresetID) + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.generalID) + } + + func testSessionPresetSwitchDoesNotTranslateEmptyOriginalText() async throws { + let viewModel = TranslationPanelViewModel() + var translationRequests = 0 + viewModel.availablePresetsProvider = { TranslationPreset.defaults } + viewModel.translationRunner = { _ in + translationRequests += 1 + return AsyncThrowingStream { $0.finish() } + } + + viewModel.selectPreset(TranslationPreset.defaults[2]) + try await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(translationRequests, 0) + XCTAssertEqual(viewModel.activePreset.id, TranslationPreset.naturalID) + } + + func testEveryTranslationStartsANewStreamRun() async throws { + let viewModel = TranslationPanelViewModel() + viewModel.translationRunner = { _ in + AsyncThrowingStream { $0.finish() } + } + let initialRunID = viewModel.translationRunID + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + let firstRunID = viewModel.translationRunID + XCTAssertNotEqual(firstRunID, initialRunID) + + viewModel.translate() + XCTAssertNotEqual(viewModel.translationRunID, firstRunID) + } + + func testOnlyOCRResultsOfferRegionRecapture() { + let viewModel = TranslationPanelViewModel() + var ocrRequests = 0 + viewModel.onStartOCR = { ocrRequests += 1 } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + XCTAssertFalse(viewModel.canRecaptureOCRRegion) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + XCTAssertTrue(viewModel.canRecaptureOCRRegion) + + viewModel.requestOCR() + XCTAssertEqual(ocrRequests, 1) + + viewModel.showAcquiringSelectedText() + XCTAssertFalse(viewModel.canRecaptureOCRRegion) + } + + func testOriginalTextIsLockedWhileCapturingOrRecognizing() { + let viewModel = TranslationPanelViewModel() + + viewModel.showAcquiringSelectedText() + XCTAssertFalse(viewModel.isOriginalEditable) + + viewModel.showProgress(.recognizingText) + XCTAssertFalse(viewModel.isOriginalEditable) + + viewModel.showProgress(.capturingScreen) + XCTAssertTrue(viewModel.isOriginalEditable) + + viewModel.showContent( + originalText: "recognized", + source: .ocr, + targetLanguage: .simplifiedChinese, + autoTranslate: false + ) + XCTAssertTrue(viewModel.isOriginalEditable) + } + + func testCompletedTranslationSpeaksOnlyWhenEnabled() async throws { + let viewModel = TranslationPanelViewModel() + var autoSpeak = false + var spokenText: [String] = [] + viewModel.shouldAutoSpeakTranslation = { autoSpeak } + viewModel.onSpeakText = { spokenText.append($0) } + viewModel.translationRunner = { _ in + AsyncThrowingStream { continuation in + continuation.yield(.delta("你好")) + continuation.yield(.completed(usage: nil)) + continuation.finish() + } + } + + viewModel.showContent( + originalText: "hello", + source: .selectedText, + targetLanguage: .simplifiedChinese, + autoTranslate: true + ) + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(spokenText.isEmpty) + + autoSpeak = true + viewModel.translate() + for _ in 0..<100 where viewModel.phase != .ready { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(spokenText, ["你好"]) + XCTAssertEqual(viewModel.activeSpeechTarget, .translation) + } + func testCancellingTranslationFlushesPendingPartialText() async throws { let viewModel = TranslationPanelViewModel() viewModel.translationRunner = { _ in @@ -263,6 +699,89 @@ final class TranslationPanelViewModelTests: XCTestCase { } } +final class TargetLanguageMenuTests: XCTestCase { + func testMenuTitleShowsSelectedSecondaryLanguage() { + XCTAssertEqual( + TargetLanguageMenu.title(for: .japanese), + "日本語" + ) + XCTAssertEqual( + TargetLanguageMenu.title(for: .traditionalChinese), + "繁體中文" + ) + } + + func testMenuTitleStaysGenericForPrimaryLanguages() { + XCTAssertEqual( + TargetLanguageMenu.title(for: .simplifiedChinese), + TargetLanguageMenu.fallbackTitle + ) + XCTAssertEqual( + TargetLanguageMenu.title(for: .english), + TargetLanguageMenu.fallbackTitle + ) + XCTAssertFalse(TargetLanguageMenu.isSecondary(.english)) + XCTAssertTrue(TargetLanguageMenu.isSecondary(.korean)) + } + + func testPrimaryAndSecondaryGroupsCoverEveryTargetLanguage() { + XCTAssertEqual( + Set(TargetLanguageMenu.primary + TargetLanguageMenu.secondary), + Set(TargetLanguage.allCases) + ) + } +} + +final class TranslationStreamFollowStateTests: XCTestCase { + func testFollowsBottomUntilUserScrollsUp() { + var state = TranslationStreamFollowState() + XCTAssertTrue(state.isFollowing) + + state.updateNearBottom(false) + state.handleScrollPhase(.interacting) + XCTAssertFalse(state.isFollowing) + } + + func testProgrammaticScrollAndBottomInteractionKeepFollowing() { + var state = TranslationStreamFollowState() + + state.updateNearBottom(false) + state.handleScrollPhase(.animating) + XCTAssertTrue(state.isFollowing) + + state.updateNearBottom(true) + state.handleScrollPhase(.tracking) + XCTAssertTrue(state.isFollowing) + } + + func testNewTranslationRunRestoresFollowing() { + var state = TranslationStreamFollowState() + state.updateNearBottom(false) + state.handleScrollPhase(.decelerating) + XCTAssertFalse(state.isFollowing) + + state.reset() + XCTAssertTrue(state.isFollowing) + } + + func testNearBottomUsesContainerAndContentGeometry() { + XCTAssertTrue( + TranslationStreamFollowState.isNearBottom( + contentOffset: 400, + containerHeight: 100, + contentHeight: 500 + ) + ) + XCTAssertFalse( + TranslationStreamFollowState.isNearBottom( + contentOffset: 100, + containerHeight: 100, + contentHeight: 500 + ) + ) + } +} + final class OperationGenerationTests: XCTestCase { func testStaleOperationCannotFinishNewGeneration() { var generation = OperationGeneration()