feat: translate with situational context in OpenAI

This commit is contained in:
2026-10-18 10:26:43 +02:00
parent 9613cbb994
commit b63565429e
7 changed files with 240 additions and 50 deletions
@@ -812,12 +812,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
tool.dismiss = { [weak self] in self?.dismiss() }
translation = tool
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
tool.replaceInput = { [weak self] in self?.searchField.stringValue = $0 }
tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
tool.languagesChanged = { [weak self, weak tool] in
tool.inputPresentationChanged = { [weak self, weak tool] in
guard let self, let tool else { return }
let attributes = self.searchField.placeholderAttributedString?.attributes(at: 0, effectiveRange: nil) ?? [:]
self.searchField.placeholderAttributedString = NSAttributedString(string: tool.inputPlaceholder, attributes: attributes)
self.searchField.setAccessibilityLabel(tool.inputAccessibilityLabel)
self.commandBack.setAccessibilityLabel(tool.backAccessibilityLabel)
self.commandBack.toolTip = tool.backAccessibilityLabel
}
controller = tool
placeholder = tool.inputPlaceholder
@@ -873,6 +876,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
searchSymbol.isHidden = true
commandBack.setAccessibilityLabel("Back to applications")
commandBack.toolTip = "Back to applications"
commandBack.target = self
commandBack.action = #selector(goBackCommand)
commandBack.isHidden = false
+81 -22
View File
@@ -12,9 +12,18 @@ final class TranslationController: NSViewController {
private(set) var sourceLanguage = "en"
private(set) var targetLanguage = "ja"
var focusSearch: (() -> Void)?
var languagesChanged: (() -> Void)?
var inputPlaceholder: String { "Enter \(languageName(sourceLanguage)) text to translate…" }
var inputAccessibilityLabel: String { "\(languageName(sourceLanguage)) text to translate into \(languageName(targetLanguage))" }
var inputPresentationChanged: (() -> Void)?
var inputPlaceholder: String {
textToTranslate == nil ? "Enter \(languageName(sourceLanguage)) text to translate…"
: "Describe where or how it was used (optional)…"
}
var inputAccessibilityLabel: String {
textToTranslate == nil ? "\(languageName(sourceLanguage)) text to translate into \(languageName(targetLanguage))"
: "Context or situation for the translation (optional)"
}
var backAccessibilityLabel: String {
textToTranslate == nil ? "Back to applications" : "Edit original text"
}
private static let languages = Locale.LanguageCode.isoLanguageCodes.map(\.identifier).filter { $0.count == 2 }
.sorted { languageName($0).localizedCaseInsensitiveCompare(languageName($1)) == .orderedAscending }
private static func languageName(_ code: String) -> String {
@@ -22,19 +31,22 @@ final class TranslationController: NSViewController {
}
private func languageName(_ code: String) -> String { Self.languageName(code) }
private var usesMyMemory: Bool
private var textToTranslate: String?
private let sourcePreview = NSTextField(wrappingLabelWithString: "")
private let output = NSTextView()
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
private let status = NSTextField(wrappingLabelWithString: "")
private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
private var task: Task<Void, Never>?
var input: (() -> String)?
var replaceInput: ((String) -> Void)?
var closed: (() -> Void)?
var dismiss: (() -> Void)?
private let pasteboard: NSPasteboard
private let defaults: UserDefaults
private let credentialsStore: Credentials
private let myMemory: @MainActor (String, String, String) async throws -> String
private let openAI: @MainActor (String, String, String, String) async throws -> String
private let openAI: @MainActor (String, String, String, String, String) async throws -> String
init(
defaults: UserDefaults = .standard,
@@ -42,8 +54,8 @@ final class TranslationController: NSViewController {
myMemory: @escaping @MainActor (String, String, String) async throws -> String = {
try await MyMemoryAPI.translate($0, sourceLanguage: $1, targetLanguage: $2)
},
openAI: @escaping @MainActor (String, String, String, String) async throws -> String = {
try await TranslationAPI.translate($0, apiKey: $3, sourceLanguage: $1, targetLanguage: $2)
openAI: @escaping @MainActor (String, String, String, String, String) async throws -> String = {
try await TranslationAPI.translate($0, apiKey: $3, sourceLanguage: $1, targetLanguage: $2, context: $4)
},
pasteboard: NSPasteboard = .general
) {
@@ -141,13 +153,24 @@ final class TranslationController: NSViewController {
status.font = .systemFont(ofSize: 12)
status.setAccessibilityLabel("Translation status and errors")
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
scroll.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scroll)
sourcePreview.font = .systemFont(ofSize: 14)
sourcePreview.textColor = SunsetPalette.secondaryText
sourcePreview.maximumNumberOfLines = 3
sourcePreview.lineBreakMode = .byTruncatingTail
sourcePreview.setAccessibilityLabel("Original text to translate")
let body = NSStackView(views: [sourcePreview, scroll])
body.orientation = .vertical
body.alignment = .leading
body.spacing = 12
body.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(body)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
scroll.topAnchor.constraint(equalTo: view.topAnchor),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
body.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
body.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
body.topAnchor.constraint(equalTo: view.topAnchor),
body.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
sourcePreview.widthAnchor.constraint(equalTo: body.widthAnchor),
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
@@ -221,7 +244,7 @@ final class TranslationController: NSViewController {
defaults.set(source, forKey: "translationSourceLanguage")
defaults.set(target, forKey: "translationTargetLanguage")
inputChanged()
languagesChanged?()
inputPresentationChanged?()
}
func swapLanguages() { setLanguages(source: targetLanguage, target: sourceLanguage) }
@@ -233,17 +256,36 @@ final class TranslationController: NSViewController {
}
@objc func goBack() {
if actions.isShown { actions.close() } else { close() }
if actions.isShown {
actions.close()
} else if let text = textToTranslate {
textToTranslate = nil
replaceInput?(text)
inputChanged()
inputPresentationChanged?()
focusSearch?()
} else {
close()
}
}
func inputChanged() {
cancelRequest()
output.string = ""
let isEmpty = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
translateButton.title = isEmpty ? "Swap languages" : "Translate"
translateButton.toolTip = isEmpty
? "Swap source and target languages (Return)."
: "Translate the input (Return). Escape dismisses Conjure."
if textToTranslate != nil {
translateButton.title = "Translate"
translateButton.toolTip = "Translate using the context, or leave it blank (Return)."
} else if isEmpty {
translateButton.title = "Swap languages"
translateButton.toolTip = "Swap source and target languages (Return)."
} else {
translateButton.title = usesMyMemory ? "Translate" : "Add context"
translateButton.toolTip = usesMyMemory ? "Translate the input (Return)."
: "Continue to context without sending the text (Return)."
}
sourcePreview.stringValue = textToTranslate.map { "Text to translate: \($0)" } ?? ""
sourcePreview.isHidden = textToTranslate == nil
settingsButton.isHidden = true
languageButton.title = "\(languageName(sourceLanguage)) → \(languageName(targetLanguage))"
output.setAccessibilityLabel("\(languageName(targetLanguage)) translation")
@@ -256,7 +298,12 @@ final class TranslationController: NSViewController {
usesMyMemory = newUsesMyMemory
providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
defaults.set(openAI ? "openai" : "mymemory", forKey: "translationProvider")
if let text = textToTranslate {
textToTranslate = nil
replaceInput?(text)
}
inputChanged()
inputPresentationChanged?()
}
private func cancelRequest() {
@@ -272,11 +319,22 @@ final class TranslationController: NSViewController {
@objc func performPrimaryAction() {
guard task == nil else { return }
if (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if textToTranslate == nil && (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
swapLanguages()
return
}
guard !output.string.isEmpty else { translate(); return }
if output.string.isEmpty {
if !usesMyMemory && textToTranslate == nil {
textToTranslate = input?() ?? ""
replaceInput?("")
inputChanged()
inputPresentationChanged?()
focusSearch?()
} else {
translate()
}
return
}
pasteboard.clearContents()
if pasteboard.setString(output.string, forType: .string) {
dismiss?()
@@ -287,7 +345,8 @@ final class TranslationController: NSViewController {
@objc func translate() {
guard task == nil else { return }
let text = input?() ?? ""
let text = textToTranslate ?? input?() ?? ""
let context = textToTranslate == nil ? "" : input?() ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setStatus("Enter \(languageName(sourceLanguage)) text in the search bar.", isError: true)
return
@@ -319,7 +378,7 @@ final class TranslationController: NSViewController {
do {
let translation = try await myMemory
? myMemoryTranslate(text, source, target)
: openAITranslate(text, source, target, key)
: openAITranslate(text, source, target, key, context)
guard !Task.isCancelled, let self else { return }
self.output.string = translation
self.setStatus("")
@@ -34,7 +34,7 @@ final class TranslationSettingsController: NSViewController {
"MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory only when you translate; avoid sensitive content."
)
let openAI = NSTextField(wrappingLabelWithString:
"OpenAI · gpt-6-luna · Text is sent to OpenAI only when you explicitly translate. Saving a key does not send your text. Your API key is stored in Keychain."
"OpenAI · gpt-6-luna · Text and optional situation/context are sent to OpenAI only when you explicitly translate, not when advancing to context. Saving a key does not send your text. Your API key is stored in Keychain."
)
for label in [status, myMemory, openAI] {
label.textColor = SunsetPalette.secondaryText