Conversation
- 移除预览弹窗中的翻译按钮及相关实现(显示/隐藏翻译覆盖层、保存/复制带译文图片) - 简化 PreviewViewModel,移除翻译相关状态和方法 - 更新设置界面颜色以符合 macOS 26 规范 - 移除设置中废弃的 OCR 引擎/翻译引擎/翻译模式配置 - 添加 VLM API 连通性测试功能 - 修复 Command+Shift+A 快捷键无法设置的问题(A 键虚拟键码为 0) - 为菜单栏菜单项添加 SF Symbols 图标 - 修复 ImmersiveTranslationView 状态修改警告 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
📝 Walkthrough高层概述该PR移除了翻译相关功能,包括翻译UI、导出和保存工作流,同时为VLM API连接测试添加了新功能。简化了预览界面,优化了菜单栏图标,并增强了OpenAI VLM提供商以支持多轮对话。 变更
序列图sequenceDiagram
participant User as 用户
participant UI as SettingsView
participant VM as SettingsViewModel
participant Provider as VLMProvider<br/>(OpenAI/Claude/<br/>Ollama)
participant Result as UI State
User->>UI: 点击"测试连接"按钮
UI->>VM: testVLMAPI()
Note over VM: isTestingVLM = true
VM->>Provider: 执行特定提供商<br/>连接测试
alt 连接成功
Provider-->>VM: 成功响应
VM->>Result: vlmTestSuccess = true<br/>vlmTestResult = "连接成功"
else 认证失败
Provider-->>VM: 401错误
VM->>Result: vlmTestSuccess = false<br/>vlmTestResult = "认证失败"
else 网络错误
Provider-->>VM: 网络超时
VM->>Result: vlmTestSuccess = false<br/>vlmTestResult = "网络错误"
end
Note over VM: isTestingVLM = false
VM-->>UI: 更新UI
UI->>User: 显示测试结果
估计代码审查工作量🎯 4 (复杂) | ⏱️ ~60 分钟 可能相关的PR
诗文
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@ScreenTranslate/Features/BilingualResult/BilingualResultView.swift`:
- Around line 10-28: 当前实现同时在 scaleEffect(viewModel.scale) 和 frame(width:
CGFloat(viewModel.imageWidth) * viewModel.scale, height:
CGFloat(viewModel.imageHeight) * viewModel.scale) 上应用缩放,导致等效缩放为
scale^2;请只保留一种缩放方式——例如保留 scaleEffect(viewModel.scale) 并把 frame 改回不乘 scale(使用
viewModel.imageWidth / imageHeight 原始尺寸),或相反地移除 scaleEffect 并只在 frame 上乘以
viewModel.scale;在 ScrollView/Image 上调整 frame/scaleEffect 以只应用一次缩放,保持
onScrollWheelZoom、zoomIn 和 zoomOut 不变以便继续处理交互。
In `@ScreenTranslate/Features/Preview/PreviewActionButtons.swift`:
- Around line 115-128: The OCR icon-only button (ocrButton) lacks an
accessibility label so screen readers announce the system image name; add a
localized accessibility label to the Button (or its label Image/ProgressView)
such as .accessibilityLabel(String(localized: "preview.accessibility.ocr")) so
the role is clear, keeping existing .disabled and .help behavior and ensuring
the label is used whether viewModel.isPerformingOCR shows ProgressView or
Image(systemName:).
In `@ScreenTranslate/Services/OpenAIVLMProvider.swift`:
- Around line 170-175: The current extractContentAndStatus(from:) implementation
prints the raw response (rawJSON) which can leak sensitive content; change this
to avoid logging full responses in production by replacing the unconditional
print with either a conditional debug-only log (e.g., wrap in `#if` DEBUG) or use
a centralized Logger that respects privacy levels and only logs a
redacted/trimmed preview (no more than a few characters or a hash) and include
context (e.g., "[OpenAI] response preview"). Update the
extractContentAndStatus(from:) function to remove the unconditional
print(rawJSON) and use the chosen debug/Logger approach so production builds
never emit raw response bodies.
- Around line 122-131: The parseVLMContent call is missing the isTruncated flag
so the repair path (attemptToRepairJSON) never runs; update parseVLMContent to
accept a second parameter (e.g., isTruncated: Bool) and change the call here
from parseVLMContent(content) to parseVLMContent(content, isTruncated:
isTruncated) (and update the parseVLMContent signature and any callers
accordingly) so that when isTruncated is true the function can invoke
attemptToRepairJSON and return the repaired result.
- Around line 159-163: The continuation user prompt appended via
OpenAIChatMessage in conversationMessages currently asks for a "JSON array"
which conflicts with the initial prompt's expected object shape
{"segments":[...]}; update the prompt content text to explicitly request the
same complete JSON object format (for example: "Return ONLY the complete JSON
object with remaining segments in the format: {\"segments\":[...]}") so the
model returns an object with a segments property instead of a bare array.
🧹 Nitpick comments (5)
ScreenTranslate/Resources/DesignSystem.swift (1)
72-79: 建议改用系统分隔线色以适配深色/高对比模式。
Color.black.opacity(0.05)在深色或高对比模式下可能过暗或不可见。建议用Color(.separatorColor)(或同类语义色)让系统自动适配。♻️ 建议修改
.overlay( RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color.black.opacity(0.05), lineWidth: 0.5) + .stroke(Color(.separatorColor), lineWidth: 0.5) )ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift (1)
294-301: 建议将 Provider/Model 标签本地化。"Provider:" 和 "Model:" 字符串未使用本地化,与应用其他部分的国际化风格不一致。虽然这是调试辅助信息,但为保持一致性,建议使用
String(localized:)。♻️ 建议的修改
case .analysisFailure: let settings = AppSettings.shared - errorDetails += "\n\nProvider: \(settings.vlmProvider.localizedName)" - errorDetails += "\nModel: \(settings.vlmModelName)" + errorDetails += "\n\n" + String(localized: "translationFlow.error.provider") + ": \(settings.vlmProvider.localizedName)" + errorDetails += "\n" + String(localized: "translationFlow.error.model") + ": \(settings.vlmModelName)"ScreenTranslate/Features/MenuBar/MenuBarController.swift (1)
172-175: 建议为图标添加无障碍描述(可选优化)。当前图标的
accessibilityDescription设为nil。虽然菜单项本身有标题可供屏幕阅读器使用,但为图标添加描述可以进一步提升无障碍体验。♻️ 可选的无障碍优化
if let imageName = imageName, - let image = NSImage(systemSymbolName: imageName, accessibilityDescription: nil) { + let image = NSImage(systemSymbolName: imageName, accessibilityDescription: comment) { item.image = image }这样可以复用
comment参数作为图标的无障碍描述。ScreenTranslate/Features/Preview/ImmersiveTranslationView.swift (1)
36-39: 函数签名重构合理元组返回模式清晰地封装了布局计算结果。建议考虑将底部间距
20提取为命名常量以提高可读性。private let bottomPadding: CGFloat = 20 // ... let requiredHeight = max(imageHeight, maxYExtension + bottomPadding)Also applies to: 92-94
ScreenTranslate/Features/Settings/SettingsViewModel.swift (1)
572-720: 配置变更后建议清空上次测试结果当前测试结果会在配置变更后继续显示,可能造成“已验证但实际上配置已变”的误解。建议在配置变更时重置测试状态。
♻️ 可选改进示例
var vlmProvider: VLMProviderType { get { settings.vlmProvider } set { settings.vlmProvider = newValue + resetVLMTestState() if vlmBaseURL.isEmpty || vlmBaseURL == settings.vlmProvider.defaultBaseURL { vlmBaseURL = newValue.defaultBaseURL } if vlmModelName.isEmpty || vlmModelName == settings.vlmProvider.defaultModelName { vlmModelName = newValue.defaultModelName } } } var vlmAPIKey: String { get { settings.vlmAPIKey } - set { settings.vlmAPIKey = newValue } + set { settings.vlmAPIKey = newValue; resetVLMTestState() } } var vlmBaseURL: String { get { settings.vlmBaseURL } - set { settings.vlmBaseURL = newValue } + set { settings.vlmBaseURL = newValue; resetVLMTestState() } } var vlmModelName: String { get { settings.vlmModelName } - set { settings.vlmModelName = newValue } + set { settings.vlmModelName = newValue; resetVLMTestState() } } +private func resetVLMTestState() { + guard !isTestingVLM else { return } + vlmTestResult = nil + vlmTestSuccess = false +}
| ScrollView([.horizontal, .vertical], showsIndicators: true) { | ||
| Image(decorative: viewModel.image, scale: 1.0) | ||
| .resizable() | ||
| .aspectRatio(contentMode: .fit) | ||
| .scaleEffect(viewModel.scale) | ||
| .frame( | ||
| width: CGFloat(viewModel.imageWidth) * viewModel.scale, | ||
| height: CGFloat(viewModel.imageHeight) * viewModel.scale | ||
| ) | ||
| .onScrollWheelZoom { delta in | ||
| if delta > 0 { | ||
| viewModel.zoomIn() | ||
| } else { | ||
| viewModel.zoomOut() | ||
| } | ||
| } | ||
| .frame(maxWidth: .infinity, maxHeight: .infinity) | ||
| .background(Color(nsColor: .windowBackgroundColor)) | ||
|
|
||
| if viewModel.isLoading { | ||
| loadingOverlay | ||
| } | ||
| } | ||
| } | ||
| .frame(maxWidth: .infinity, maxHeight: .infinity) | ||
| .background(Color(nsColor: .windowBackgroundColor)) |
There was a problem hiding this comment.
避免缩放被应用两次导致缩放比例失真
Line 14 的 scaleEffect(viewModel.scale) 与 Line 15-18 的 frame 同时按 viewModel.scale 放大,等效缩放为 scale^2,会导致缩放百分比显示不准且缩放步进过快。建议保留一种缩放方式即可。
🔧 建议修正
Image(decorative: viewModel.image, scale: 1.0)
.resizable()
.aspectRatio(contentMode: .fit)
- .scaleEffect(viewModel.scale)
.frame(
width: CGFloat(viewModel.imageWidth) * viewModel.scale,
height: CGFloat(viewModel.imageHeight) * viewModel.scale
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ScrollView([.horizontal, .vertical], showsIndicators: true) { | |
| Image(decorative: viewModel.image, scale: 1.0) | |
| .resizable() | |
| .aspectRatio(contentMode: .fit) | |
| .scaleEffect(viewModel.scale) | |
| .frame( | |
| width: CGFloat(viewModel.imageWidth) * viewModel.scale, | |
| height: CGFloat(viewModel.imageHeight) * viewModel.scale | |
| ) | |
| .onScrollWheelZoom { delta in | |
| if delta > 0 { | |
| viewModel.zoomIn() | |
| } else { | |
| viewModel.zoomOut() | |
| } | |
| } | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| .background(Color(nsColor: .windowBackgroundColor)) | |
| if viewModel.isLoading { | |
| loadingOverlay | |
| } | |
| } | |
| } | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| .background(Color(nsColor: .windowBackgroundColor)) | |
| ScrollView([.horizontal, .vertical], showsIndicators: true) { | |
| Image(decorative: viewModel.image, scale: 1.0) | |
| .resizable() | |
| .aspectRatio(contentMode: .fit) | |
| .frame( | |
| width: CGFloat(viewModel.imageWidth) * viewModel.scale, | |
| height: CGFloat(viewModel.imageHeight) * viewModel.scale | |
| ) | |
| .onScrollWheelZoom { delta in | |
| if delta > 0 { | |
| viewModel.zoomIn() | |
| } else { | |
| viewModel.zoomOut() | |
| } | |
| } | |
| } | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| .background(Color(nsColor: .windowBackgroundColor)) |
🤖 Prompt for AI Agents
In `@ScreenTranslate/Features/BilingualResult/BilingualResultView.swift` around
lines 10 - 28, 当前实现同时在 scaleEffect(viewModel.scale) 和 frame(width:
CGFloat(viewModel.imageWidth) * viewModel.scale, height:
CGFloat(viewModel.imageHeight) * viewModel.scale) 上应用缩放,导致等效缩放为
scale^2;请只保留一种缩放方式——例如保留 scaleEffect(viewModel.scale) 并把 frame 改回不乘 scale(使用
viewModel.imageWidth / imageHeight 原始尺寸),或相反地移除 scaleEffect 并只在 frame 上乘以
viewModel.scale;在 ScrollView/Image 上调整 frame/scaleEffect 以只应用一次缩放,保持
onScrollWheelZoom、zoomIn 和 zoomOut 不变以便继续处理交互。
| private var ocrButton: some View { | ||
| Button { | ||
| viewModel.performOCR() | ||
| } label: { | ||
| if viewModel.isPerformingOCR { | ||
| ProgressView() | ||
| .controlSize(.small) | ||
| .frame(width: 16, height: 16) | ||
| } else { | ||
| Image(systemName: "text.viewfinder") | ||
| } | ||
| .disabled(!viewModel.hasTranslationResults || viewModel.isCopyingWithTranslations) | ||
| .help(String(localized: "preview.tooltip.copy.with.translations")) | ||
| .accessibilityLabel(Text( | ||
| viewModel.isCopyingWithTranslations ? "Copying translated image" : "Copy image with translations" | ||
| )) | ||
| } | ||
| .disabled(viewModel.isPerformingOCR) | ||
| .help(String(localized: "preview.tooltip.ocr")) |
There was a problem hiding this comment.
为 OCR 按钮补充无障碍标签。
当前按钮仅有图标,屏幕阅读器会读出系统图标名,用户难以理解功能。
♿ 建议补充无障碍标签
private var ocrButton: some View {
Button {
viewModel.performOCR()
} label: {
if viewModel.isPerformingOCR {
ProgressView()
.controlSize(.small)
.frame(width: 16, height: 16)
} else {
Image(systemName: "text.viewfinder")
}
}
.disabled(viewModel.isPerformingOCR)
.help(String(localized: "preview.tooltip.ocr"))
+ .accessibilityLabel(Text("preview.tooltip.ocr"))
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private var ocrButton: some View { | |
| Button { | |
| viewModel.performOCR() | |
| } label: { | |
| if viewModel.isPerformingOCR { | |
| ProgressView() | |
| .controlSize(.small) | |
| .frame(width: 16, height: 16) | |
| } else { | |
| Image(systemName: "text.viewfinder") | |
| } | |
| .disabled(!viewModel.hasTranslationResults || viewModel.isCopyingWithTranslations) | |
| .help(String(localized: "preview.tooltip.copy.with.translations")) | |
| .accessibilityLabel(Text( | |
| viewModel.isCopyingWithTranslations ? "Copying translated image" : "Copy image with translations" | |
| )) | |
| } | |
| .disabled(viewModel.isPerformingOCR) | |
| .help(String(localized: "preview.tooltip.ocr")) | |
| private var ocrButton: some View { | |
| Button { | |
| viewModel.performOCR() | |
| } label: { | |
| if viewModel.isPerformingOCR { | |
| ProgressView() | |
| .controlSize(.small) | |
| .frame(width: 16, height: 16) | |
| } else { | |
| Image(systemName: "text.viewfinder") | |
| } | |
| } | |
| .disabled(viewModel.isPerformingOCR) | |
| .help(String(localized: "preview.tooltip.ocr")) | |
| .accessibilityLabel(String(localized: "preview.tooltip.ocr")) | |
| } |
🤖 Prompt for AI Agents
In `@ScreenTranslate/Features/Preview/PreviewActionButtons.swift` around lines 115
- 128, The OCR icon-only button (ocrButton) lacks an accessibility label so
screen readers announce the system image name; add a localized accessibility
label to the Button (or its label Image/ProgressView) such as
.accessibilityLabel(String(localized: "preview.accessibility.ocr")) so the role
is clear, keeping existing .disabled and .help behavior and ensuring the label
is used whether viewModel.isPerformingOCR shows ProgressView or
Image(systemName:).
| // Try to parse this response | ||
| do { | ||
| let response = try parseVLMContent(content) | ||
| allSegments.append(contentsOf: response.segments) | ||
| print("[OpenAI] Parsed \(response.segments.count) segments from this response") | ||
|
|
||
| if !isTruncated { | ||
| // Complete - return merged result | ||
| print("[OpenAI] Complete response received, total \(allSegments.count) segments") | ||
| return VLMAnalysisResponse(segments: allSegments) |
There was a problem hiding this comment.
截断响应未传入 wasTruncated,修复路径不会生效。
此处未将 isTruncated 传入解析函数,导致 attemptToRepairJSON 分支无法触发。
🔧 建议传入 isTruncated
- let response = try parseVLMContent(content)
+ let response = try parseVLMContent(content, wasTruncated: isTruncated)🤖 Prompt for AI Agents
In `@ScreenTranslate/Services/OpenAIVLMProvider.swift` around lines 122 - 131, The
parseVLMContent call is missing the isTruncated flag so the repair path
(attemptToRepairJSON) never runs; update parseVLMContent to accept a second
parameter (e.g., isTruncated: Bool) and change the call here from
parseVLMContent(content) to parseVLMContent(content, isTruncated: isTruncated)
(and update the parseVLMContent signature and any callers accordingly) so that
when isTruncated is true the function can invoke attemptToRepairJSON and return
the repaired result.
| // Request continuation - ask for complete output this time | ||
| conversationMessages.append(OpenAIChatMessage( | ||
| role: "user", | ||
| content: .text("Continue from where you left off. Return ONLY the complete JSON array of remaining segments. Do not repeat segments already returned.") | ||
| )) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find and examine the VLMAnalysisResponse structure
rg -n "VLMAnalysisResponse" --type=swift -B 2 -A 10Repository: hubo1989/ScreenTranslate
Length of output: 24889
🏁 Script executed:
# Check for VLMPromptTemplate references
rg -n "VLMPromptTemplate" --type=swift -B 2 -A 5Repository: hubo1989/ScreenTranslate
Length of output: 4043
🏁 Script executed:
# Examine the context around lines 159-163 in OpenAIVLMProvider.swift
fd "OpenAIVLMProvider.swift" --type=fRepository: hubo1989/ScreenTranslate
Length of output: 116
🏁 Script executed:
# Get the VLMPromptTemplate.userPrompt definition
rg -n "userPrompt" ScreenTranslate/Services/VLMProvider.swift -A 30Repository: hubo1989/ScreenTranslate
Length of output: 1546
🏁 Script executed:
# Get the exact context around lines 159-163 in OpenAIVLMProvider.swift
sed -n '155,170p' ScreenTranslate/Services/OpenAIVLMProvider.swiftRepository: hubo1989/ScreenTranslate
Length of output: 764
续写提示与初始提示返回格式不一致,会导致解析失败。
初始提示(第118行)正确要求完整JSON对象格式:{"segments":[...]}。但续写提示(第159-163行)错误地要求返回"JSON array",会导致模型返回纯数组格式[...],而解析器期望的是具有segments属性的对象结构。修正续写提示文本,让它要求与初始提示相同的格式,例如:"Return ONLY the complete JSON object with remaining segments in the format: {"segments":[...]}"
🤖 Prompt for AI Agents
In `@ScreenTranslate/Services/OpenAIVLMProvider.swift` around lines 159 - 163, The
continuation user prompt appended via OpenAIChatMessage in conversationMessages
currently asks for a "JSON array" which conflicts with the initial prompt's
expected object shape {"segments":[...]}; update the prompt content text to
explicitly request the same complete JSON object format (for example: "Return
ONLY the complete JSON object with remaining segments in the format:
{\"segments\":[...]}") so the model returns an object with a segments property
instead of a bare array.
| /// Extracts content text and truncation status from OpenAI response | ||
| private func extractContentAndStatus(from data: Data) throws -> (content: String, isTruncated: Bool, finishReason: String?) { | ||
| // Log raw response first for debugging | ||
| if let rawJSON = String(data: data, encoding: .utf8) { | ||
| print("[OpenAI] Raw response (\(data.count) bytes): \(rawJSON.prefix(500))...") | ||
| } |
There was a problem hiding this comment.
避免在生产日志中输出完整响应内容。
当前直接打印原始响应,可能泄露用户图像内容/识别文本,属于隐私风险。建议仅在 DEBUG 或通过隐私等级日志输出。
🛡️ 建议仅在 DEBUG 输出或改用 Logger
- if let rawJSON = String(data: data, encoding: .utf8) {
- print("[OpenAI] Raw response (\(data.count) bytes): \(rawJSON.prefix(500))...")
- }
+ `#if` DEBUG
+ if let rawJSON = String(data: data, encoding: .utf8) {
+ print("[OpenAI] Raw response (\(data.count) bytes): \(rawJSON.prefix(500))...")
+ }
+ `#endif`🤖 Prompt for AI Agents
In `@ScreenTranslate/Services/OpenAIVLMProvider.swift` around lines 170 - 175, The
current extractContentAndStatus(from:) implementation prints the raw response
(rawJSON) which can leak sensitive content; change this to avoid logging full
responses in production by replacing the unconditional print with either a
conditional debug-only log (e.g., wrap in `#if` DEBUG) or use a centralized Logger
that respects privacy levels and only logs a redacted/trimmed preview (no more
than a few characters or a hash) and include context (e.g., "[OpenAI] response
preview"). Update the extractContentAndStatus(from:) function to remove the
unconditional print(rawJSON) and use the chosen debug/Logger approach so
production builds never emit raw response bodies.
Summary
本次 PR 对 ScreenTranslate 进行了一系列功能清理和界面优化:
功能变更
界面优化
windowBackgroundColor、controlBackgroundColor),移除渐变效果功能增强
Bug 修复
ImmersiveTranslationView中的 "Modifying state during view update" 警告测试建议
🤖 Generated with Claude Code
Summary by CodeRabbit
发行说明
新功能
改进
移除