Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions Sources/UtterContracts/DeliveryServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import UtterRuntime

package enum DeliveryDisposition: Equatable { case notCommitted, accepted, uncertain }
package enum DeliveryEffect: Hashable { case none, clipboard, paste, keyPress, accessibility }
package enum DeliveryEffect: Hashable { case none, clipboard, paste, keyPress, accessibility, keyboardInsertion }
package enum DeliveryConfirmation: String, Codable, Sendable { case none, clipboardValue, targetValue }
package enum DeliveryStatus: String, Codable, Sendable { case inserted, copied, uncertain, notDelivered }

Expand Down Expand Up @@ -48,17 +48,28 @@ package struct DeliveryRequest {
}
}

package struct SelectionEditReceipt {
package let operationID: UUID
package let originalSelection: String
package let replacement: String
package let anchor: any OutputAnchor
package init(operationID: UUID, originalSelection: String, replacement: String, anchor: any OutputAnchor) {
self.operationID = operationID; self.originalSelection = originalSelection
self.replacement = replacement; self.anchor = anchor
}
}

package struct DeliveryReceipt {
package let operationID: UUID
package let disposition: DeliveryDisposition
package let effects: Set<DeliveryEffect>
package let confirmation: DeliveryConfirmation
package var effect: DeliveryEffect {
[.accessibility, .paste, .keyPress, .clipboard].first { effects.contains($0) } ?? .none
[.accessibility, .paste, .keyPress, .keyboardInsertion, .clipboard].first { effects.contains($0) } ?? .none
}
package var isConfirmedInsertion: Bool {
disposition == .accepted && confirmation == .targetValue
&& !effects.isDisjoint(with: [.accessibility, .paste, .keyPress])
&& !effects.isDisjoint(with: [.accessibility, .paste, .keyPress, .keyboardInsertion])
}
package var status: DeliveryStatus {
if isConfirmedInsertion { return .inserted }
Expand All @@ -67,20 +78,27 @@ package struct DeliveryReceipt {
}
package let reason: String?
package let anchor: (any OutputAnchor)?
package let selectionEdit: SelectionEditReceipt?

package init(operationID: UUID, disposition: DeliveryDisposition, effect: DeliveryEffect,
reason: String? = nil, anchor: (any OutputAnchor)? = nil,
effects: Set<DeliveryEffect>? = nil, confirmation: DeliveryConfirmation = .none) {
effects: Set<DeliveryEffect>? = nil, confirmation: DeliveryConfirmation = .none,
selectionEdit: SelectionEditReceipt? = nil) {
self.operationID = operationID
let committed = (effects ?? (effect == .none ? [] : [effect])).subtracting([.none])
let insertionConfirmed = confirmation == .targetValue && !committed.isDisjoint(with: [.accessibility, .paste, .keyPress])
let insertionConfirmed = confirmation == .targetValue && !committed.isDisjoint(with: [.accessibility, .paste, .keyPress, .keyboardInsertion])
let copyConfirmed = confirmation == .clipboardValue && committed == [.clipboard]
self.disposition = committed.isEmpty ? .notCommitted
: disposition == .accepted && (insertionConfirmed || copyConfirmed) ? .accepted : .uncertain
self.effects = committed
self.confirmation = self.disposition == .accepted ? confirmation : .none
self.reason = reason
self.anchor = self.disposition == .accepted && insertionConfirmed ? anchor : nil
self.selectionEdit = self.anchor != nil && selectionEdit?.operationID == operationID
&& selectionEdit?.anchor === self.anchor
&& selectionEdit?.originalSelection.isEmpty == false
&& selectionEdit?.replacement.isEmpty == false
? selectionEdit : nil
}
}

Expand Down
10 changes: 8 additions & 2 deletions Sources/UtterContracts/LLMFinalTextOutput.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Foundation

package enum LLMFinalTextOutput {
// Selection generation scopes every upstream unwrap, including tool/SSE payloads.
@TaskLocal package static var requiresExplicitFinalText = false
package static func text(from rawText: String) -> String? {
wholeJSONText(from: rawText)
}
Expand All @@ -23,7 +25,7 @@ private extension LLMFinalTextOutput {
"output_text", "outputText", "result_text", "resultText",
]
static let typedFinalTextValues = [
"output_text", "final_text", "formatted_text", "cleaned_text", "rewritten_text",
"output_text", "final_text", "formatted_text", "cleaned_text", "rewritten_text", "result_text",
]
static let typedFinalTextPayloadKeys = [
"text", "content", "value", "output", "data",
Expand Down Expand Up @@ -85,7 +87,7 @@ private extension LLMFinalTextOutput {
if let text = responseWrapperText(in: object) {
return text
}
guard allowsAmbiguousKeys || hasMetadata(in: object) else { return nil }
guard !requiresExplicitFinalText && (allowsAmbiguousKeys || hasMetadata(in: object)) else { return nil }
for key in ambiguousTextKeys {
guard let rawValue = object.value(forCaseInsensitiveKey: key),
let text = finalTextValue(from: rawValue, allowsAmbiguousKeys: true) else {
Expand Down Expand Up @@ -163,6 +165,10 @@ private extension LLMFinalTextOutput {
if let text = value as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if requiresExplicitFinalText,
let data = wholeJSONValueData(from: stripWrappingCodeFence(from: trimmed)) {
return finalText(from: data, allowsAmbiguousKeys: false)
}
return nestedStructuredFinalText(in: trimmed) ?? trimmed
}
if let object = value as? [String: Any] {
Expand Down
26 changes: 26 additions & 0 deletions Sources/UtterContracts/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,29 @@
"model.source.help" = "Changes apply to downloads started afterward. Active downloads keep their source.";
"model.source.mirror_help" = "Public models download from hf-mirror.com. Saved Hugging Face credentials are not sent to the mirror.";
"model.source.confirm" = "Download source: %@";

"selection_edit.review" = "Review the selected text before applying";
"selection_edit.title" = "Edit selection in %@";
"selection_edit.title_generic" = "Edit selected text";
"selection_edit.instruction" = "You said";
"selection_edit.original" = "Original";
"selection_edit.revised" = "Revised";
"selection_edit.apply" = "Apply";
"selection_edit.apply_hint" = "Replaces the selected text. Nothing changes until you press Apply.";
"selection_edit.expired" = "This edit expired or the selection changed. Nothing was changed.";
"selection_edit.applied" = "Selection updated";
"selection_edit.restore" = "Restore original";
"selection_edit.restore_failed" = "Couldn't restore. Nothing was changed.";
"selection_edit.remote_disclosure_required" = "The selected text would be sent to a remote model. Nothing was sent. Confirm before it is sent.";
"selection_edit.remote_title" = "Send selected text to %@?";
"selection_edit.remote_title_generic" = "Send selected text to a remote model?";
"selection_edit.remote_lead" = "Nothing has been recorded or sent yet. If you continue, the selected text, what you say and nearby context from the app are sent to %@.";
"selection_edit.remote_no_provider" = "The remote provider address is missing. Nothing was recorded or sent.";
"selection_edit.remote_selected" = "Selected text";
"selection_edit.remote_continue_hint" = "Starts recording. Your selected text is sent to the provider after you finish speaking.";
"selection_edit.remote_failed" = "Couldn't continue. Nothing was recorded or sent.";
"selection_edit.remote_not_confirmed" = "Not confirmed in time, or the selection changed. Nothing was recorded or sent.";

"selection_edit.restore_shortcut" = "⌘⌥Z Restore original";
"selection_edit.cancel_shortcut" = "Esc Cancel edit";
"selection_edit.processing" = "Editing selection…";
26 changes: 26 additions & 0 deletions Sources/UtterContracts/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,29 @@
"model.source.help" = "更改仅影响之后开始的下载,进行中的下载继续使用原来源。";
"model.source.mirror_help" = "公开模型将从 hf-mirror.com 下载,不向镜像发送已保存的 Hugging Face 凭证。";
"model.source.confirm" = "下载源:%@";

"selection_edit.review" = "请审阅选区修改后再应用";
"selection_edit.title" = "在 %@ 中修改选中文本";
"selection_edit.title_generic" = "修改选中文本";
"selection_edit.instruction" = "你的指令";
"selection_edit.original" = "原文";
"selection_edit.revised" = "修改后";
"selection_edit.apply" = "应用";
"selection_edit.apply_hint" = "将替换选中的文本。点击“应用”之前不会有任何改动。";
"selection_edit.expired" = "此修改已过期,或选区已变化。未做任何改动。";
"selection_edit.applied" = "选区已更新";
"selection_edit.restore" = "恢复原文";
"selection_edit.restore_failed" = "无法恢复,未做任何改动。";
"selection_edit.remote_disclosure_required" = "选中的文本将发送到远程模型。目前尚未发送,需确认后才会发送。";
"selection_edit.remote_title" = "要将选中的文本发送到 %@ 吗?";
"selection_edit.remote_title_generic" = "要将选中的文本发送到远程模型吗?";
"selection_edit.remote_lead" = "目前尚未录音或发送。继续后,选中的文本、你说的话以及应用中的附近上下文会发送到 %@。";
"selection_edit.remote_no_provider" = "远程服务地址缺失。未录音,也未发送任何内容。";
"selection_edit.remote_selected" = "选中的文本";
"selection_edit.remote_continue_hint" = "开始录音。你说完后,选中的文本才会发送给该服务商。";
"selection_edit.remote_failed" = "无法继续。未录音,也未发送任何内容。";
"selection_edit.remote_not_confirmed" = "未及时确认,或选区已变化。未录音,也未发送任何内容。";

"selection_edit.restore_shortcut" = "⌘⌥Z 恢复原文";
"selection_edit.cancel_shortcut" = "Esc 取消修改";
"selection_edit.processing" = "正在修改选区…";
23 changes: 23 additions & 0 deletions Sources/UtterContracts/SelectionEditProposal.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

package struct SelectionEditProposal: Equatable, Sendable {
package let id: UUID
package let sessionID: UUID
package let targetID: UUID
package let processIdentifier: Int32?
package let originalSelection: String
package let recognizedInstruction: String
package let candidate: String
package let expiresAt: Date
package init(id: UUID = UUID(), sessionID: UUID, targetID: UUID, processIdentifier: Int32? = nil,
originalSelection: String, recognizedInstruction: String, candidate: String,
expiresAt: Date = Date().addingTimeInterval(120)) {
self.id = id; self.sessionID = sessionID; self.targetID = targetID
self.processIdentifier = processIdentifier; self.originalSelection = originalSelection
self.recognizedInstruction = recognizedInstruction; self.candidate = candidate; self.expiresAt = expiresAt
}
package var isApplicable: Bool {
Date() < expiresAt && !originalSelection.isEmpty
&& !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
29 changes: 29 additions & 0 deletions Sources/UtterContracts/SelectionEditRemoteConsent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

package struct SelectionEditRemoteConsent: Equatable, Sendable {
package let selectedText: String
package let providerID: String
package let providerBaseURL: String
package let expiresAt: Date
package var providerHost: String { URL(string: providerBaseURL)?.host ?? "" }
package init(selectedText: String, providerID: String, providerBaseURL: String,
expiresAt: Date = Date().addingTimeInterval(120)) {
self.selectedText = selectedText; self.providerID = providerID
self.providerBaseURL = providerBaseURL; self.expiresAt = expiresAt
}
package func authorizes(selectedText: String, options: TextProcessingOptions) -> Bool {
Date() < expiresAt && self.selectedText == selectedText
&& providerID == (options.textProviderID ?? "generation.remote")
&& providerBaseURL == options.remoteBaseURL
}
}

package struct SelectionEditRemoteDisclosureRequired: LocalizedError {
package let consent: SelectionEditRemoteConsent
package let isTargetCurrent: @MainActor @Sendable () -> Bool
package init(consent: SelectionEditRemoteConsent,
isTargetCurrent: @escaping @MainActor @Sendable () -> Bool) {
self.consent = consent; self.isTargetCurrent = isTargetCurrent
}
package var errorDescription: String? { L("selection_edit.remote_disclosure_required") }
}
28 changes: 25 additions & 3 deletions Sources/UtterContracts/SessionExecutionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@ package struct SessionIntent: Equatable, Sendable {
package let request: InputSessionRequest
package let mode: TextProcessingMode?
package let operation: SessionOperation
package let remoteSelectionConsent: SelectionEditRemoteConsent?
package init(id: UUID = UUID(), clientID: String? = nil, input: SessionInput,
request: InputSessionRequest = InputSessionRequest(), mode: TextProcessingMode? = nil,
operation: SessionOperation = .input) {
operation: SessionOperation = .input, remoteSelectionConsent: SelectionEditRemoteConsent? = nil) {
self.id = id
self.clientID = clientID
self.input = input
self.request = request
self.mode = mode
self.operation = operation
self.remoteSelectionConsent = remoteSelectionConsent
}
}

package enum SessionOperation: Equatable, Sendable { case input, applyReplacement(UUID), copy(String) }
package enum SessionOperation: Equatable, Sendable {
case input, applyReplacement(UUID), copy(String)
case restoreSelectionEdit(operationID: UUID)
}

package struct SessionHistoryReplacement {
package let recordID: UUID
Expand All @@ -45,6 +50,8 @@ package enum SessionExecutionPhase: Equatable, Sendable {
case created, preparing, recording, transcribing, processing, delivering, completed, cancelled, failed
}

package enum SessionCaptureFinalizationReason: Equatable, Sendable { case durationLimit }

package struct SessionExecutionSnapshot: Equatable, Sendable {
package let id: UUID?
package let phase: SessionExecutionPhase?
Expand All @@ -58,16 +65,24 @@ package struct SessionExecutionSnapshot: Equatable, Sendable {
package let mode: TextProcessingMode?
package let recoveryAction: SessionRecoveryAction?
package let generationOutcome: EspressoGenerationOutcome?
package let selectionEditProposal: SelectionEditProposal?
package let remoteDisclosure: SelectionEditRemoteConsent?
package let isSelectionEditing: Bool
package init(id: UUID? = nil, phase: SessionExecutionPhase? = nil, transcript: String = "",
text: String = "", error: String? = nil, isBusy: Bool = false, deliveryStatus: DeliveryStatus? = nil,
performance: SessionPerformance? = nil, audioLevel: Float = 0, mode: TextProcessingMode? = nil,
recoveryAction: SessionRecoveryAction? = nil, generationOutcome: EspressoGenerationOutcome? = nil) {
recoveryAction: SessionRecoveryAction? = nil, generationOutcome: EspressoGenerationOutcome? = nil,
selectionEditProposal: SelectionEditProposal? = nil, remoteDisclosure: SelectionEditRemoteConsent? = nil,
isSelectionEditing: Bool = false) {
self.id = id; self.phase = phase; self.transcript = transcript
self.text = text; self.error = error; self.isBusy = isBusy
self.deliveryStatus = deliveryStatus
self.performance = performance
self.audioLevel = audioLevel.isFinite ? min(1, max(0, audioLevel)) : 0
self.mode = mode
self.selectionEditProposal = selectionEditProposal
self.remoteDisclosure = remoteDisclosure
self.isSelectionEditing = isSelectionEditing
self.recoveryAction = recoveryAction
self.generationOutcome = generationOutcome
}
Expand Down Expand Up @@ -129,15 +144,18 @@ package protocol SessionJobControl: AnyObject {
var isStopped: Bool { get }
func update(phase: SessionExecutionPhase, transcript: String)
func cancel()
func waitForSelectionEditDecision(_ proposal: SelectionEditProposal) async throws
func waitForStop() async throws
func beginStage(_ stage: SessionStage) -> UUID
func endStage(_ id: UUID)
func updateAudioLevel(_ level: Float)
@discardableResult func finalizeCapture(reason: SessionCaptureFinalizationReason) -> Bool
}

@MainActor
package protocol SessionJob: AnyObject {
var presentationMode: TextProcessingMode? { get }
var containsSelectedText: Bool { get }
func promoteToTranslation() -> TextProcessingMode?
func bind(input: SessionInput) throws
func attach(control: any SessionJobControl)
Expand All @@ -159,6 +177,9 @@ package protocol SessionWorkflowFactory: AnyObject {
package protocol SessionExecutionService: AnyObject {
@discardableResult func requestStop(_ id: UUID, at timestamp: Duration?) -> Bool
func promoteToTranslation(_ id: UUID, reason: HotkeyPromotion) -> Bool
@discardableResult func applySelectionEdit(sessionID: UUID, proposalID: UUID) -> Bool
func authorizeRemoteDisclosure(_ intentID: UUID) throws
func declineRemoteDisclosure(_ intentID: UUID)
var snapshot: SessionExecutionSnapshot { get }
func reserve(_ intent: SessionIntent) throws
func activate(_ id: UUID, input: SessionInput?) throws
Expand All @@ -184,6 +205,7 @@ extension SessionServices {

extension SessionJob {
package var presentationMode: TextProcessingMode? { nil }
package var containsSelectedText: Bool { false }
package func promoteToTranslation() -> TextProcessingMode? { nil }
package func bind(input: SessionInput) throws { throw IntegrationError.invalidSessionState }
package func attach(control: any SessionJobControl) {}
Expand Down
24 changes: 21 additions & 3 deletions Sources/UtterContracts/SessionOutputStateService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,33 @@ package struct RecentSessionOutput {
package let recordID: UUID
package let text: String
package let anchor: (any OutputAnchor)?
package init(recordID: UUID, text: String, anchor: (any OutputAnchor)?) {
self.recordID = recordID; self.text = text; self.anchor = anchor
package let selectionEdit: SelectionEditReceipt?
package init(recordID: UUID, text: String, anchor: (any OutputAnchor)?, selectionEdit: SelectionEditReceipt? = nil) {
self.recordID = recordID; self.text = text; self.anchor = anchor; self.selectionEdit = selectionEdit
}
}

package struct SessionOutputSnapshot: Equatable {
package let recentText: String
package let pending: DeferredReplacement?
package init(recentText: String = "", pending: DeferredReplacement? = nil) {
package let selectionEditRestoration: SelectionEditRestoration?
package init(recentText: String = "", pending: DeferredReplacement? = nil,
selectionEditRestoration: SelectionEditRestoration? = nil) {
self.recentText = recentText; self.pending = pending
self.selectionEditRestoration = selectionEditRestoration
}
}

package struct SelectionEditRestoration: Equatable, Sendable {
package let operationID: UUID
package let originalSelection: String
package let replacement: String
package let expiresAt: Date
package let isAvailable: Bool
package init(operationID: UUID, originalSelection: String, replacement: String,
expiresAt: Date, isAvailable: Bool) {
self.operationID = operationID; self.originalSelection = originalSelection
self.replacement = replacement; self.expiresAt = expiresAt; self.isAvailable = isAvailable
}
}

Expand All @@ -25,6 +42,7 @@ package protocol SessionOutputStateService: AnyObject {
var recent: RecentSessionOutput? { get }
var pendingAnchor: (any OutputAnchor)? { get }
func remember(_ completion: SessionCompletion, recordID: UUID)
@discardableResult func consumeSelectionEdit(_ operationID: UUID) -> Bool
@discardableResult func installPending(_ replacement: DeferredReplacement, anchor: (any OutputAnchor)?) -> Bool
@discardableResult func updatePending(_ id: UUID, mutation: (inout DeferredReplacement) -> Void) -> Bool
func clearPending(_ id: UUID?)
Expand Down
Loading
Loading