feat(keyboard): add clipboard voice command mode

Long-press mic runs ASR as an instruction over eligible clipboard text,
with host-confirm recording UI, min-record gate, and light ASR prewarm.
Bump CURRENT_PROJECT_VERSION to 53.
This commit is contained in:
Rocky
2026-08-07 16:08:42 +08:00
parent 98ae211b3d
commit f197b68573
22 changed files with 1362 additions and 124 deletions
+156 -54
View File
@@ -75,6 +75,10 @@ final class FlowSessionManager: ObservableObject {
private var terminalUtteranceIds: Set<UUID> = []
/// Cursor context captured by the keyboard at the final insertion point.
private var pendingFieldContext: FlowFieldContext?
/// Dictation vs clipboard-command for the live utterance (set on start).
private var currentUtteranceMode: FlowUtteranceMode = .dictation
private var pendingClipboardSnapshot: String?
private var pendingPreviousOutput: String?
private var pendingStopUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
private var lastHandledCommandSeq: Int64 = 0
@@ -988,6 +992,20 @@ final class FlowSessionManager: ObservableObject {
utteranceId: command.utteranceId,
commandSeq: command.commandSeq
) else { return }
currentUtteranceMode = command.resolvedUtteranceMode
if currentUtteranceMode == .clipboardCommand {
pendingClipboardSnapshot = command.clipboardSnapshot.map {
ClipboardMaterialFilter.truncateSnapshot($0)
}
pendingPreviousOutput = command.previousOutput?
.trimmingCharacters(in: .whitespacesAndNewlines)
if pendingPreviousOutput?.isEmpty == true {
pendingPreviousOutput = nil
}
} else {
pendingClipboardSnapshot = nil
pendingPreviousOutput = nil
}
guard let startUtteranceId = currentUtteranceId else { return }
let startToken = FlowUtteranceStartToken(
generation: utteranceGeneration,
@@ -1019,6 +1037,10 @@ final class FlowSessionManager: ObservableObject {
case .abort:
guard currentUtteranceId == command.utteranceId else { return }
abortUtterance()
case .prewarm:
// No utterance identity warm SpeechAnalyzer / cloud prep only.
scheduleASRWarmup()
FlowDiagnostics.log("prewarm ASR requested seq=\(command.commandSeq)")
}
}
@@ -1043,7 +1065,8 @@ final class FlowSessionManager: ObservableObject {
text: trimmed,
rawText: trimmed,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision()
revision: Self.resultRevision(),
utteranceMode: currentUtteranceMode
)
)
}
@@ -1065,7 +1088,8 @@ final class FlowSessionManager: ObservableObject {
warning: warning,
rawText: trimmed,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision()
revision: Self.resultRevision(),
utteranceMode: currentUtteranceMode
)
)
}
@@ -1085,7 +1109,8 @@ final class FlowSessionManager: ObservableObject {
text: message,
errorKind: kind,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision()
revision: Self.resultRevision(),
utteranceMode: currentUtteranceMode
)
)
}
@@ -1583,6 +1608,9 @@ final class FlowSessionManager: ObservableObject {
) async {
let pipelineStarted = Date()
let fieldContext = pendingFieldContext
let utteranceMode = currentUtteranceMode
let clipboardSnapshot = pendingClipboardSnapshot
let previousOutput = pendingPreviousOutput
// ALWAYS clear the processing gate for this utterance. The previous
// guard required currentUtteranceId to still match; a racing
// fail/abort/cancel path could nil the id (or leave processing stuck)
@@ -1590,6 +1618,11 @@ final class FlowSessionManager: ObservableObject {
// while host logs still said "utterance finalized".
defer {
pendingFieldContext = nil
pendingClipboardSnapshot = nil
pendingPreviousOutput = nil
if currentUtteranceMode == utteranceMode {
currentUtteranceMode = .dictation
}
completeFinalizeCleanup(
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId
@@ -1707,15 +1740,51 @@ final class FlowSessionManager: ObservableObject {
var delivered = text
let polishStarted = Date()
let polishMode = pipelineStore.polishModeForPipeline
let isClipboardCommand = utteranceMode == .clipboardCommand
let polishMode: PolishingService.PolishMode = isClipboardCommand
? .polish
: pipelineStore.polishModeForPipeline
let clipboardPrompt: (system: String, user: String)? = {
guard isClipboardCommand,
let snapshot = clipboardSnapshot,
!snapshot.isEmpty else { return nil }
let bias = ClipboardCommandPromptComposer.styleBias(
styleID: pipelineStore.activePolishStyleId,
catalog: pipelineStore.polishStyleCatalog
)
let input = ClipboardCommandPromptComposer.Input(
snapshot: snapshot,
instruction: textForPolish,
previousOutput: previousOutput,
styleBias: bias
)
return (
ClipboardCommandPromptComposer.compose(input),
ClipboardCommandPromptComposer.userMessage(input)
)
}()
if isClipboardCommand, clipboardPrompt == nil {
FlowDiagnostics.log("clipboard command missing snapshot — failing closed")
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.clipboardCommandFailed"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
return
}
FlowDiagnostics.log(
"finalize LLM mode=\(Self.polishModeLogLabel(polishMode)) " +
"finalize LLM mode=\(isClipboardCommand ? "clipboardCommand" : Self.polishModeLogLabel(polishMode)) " +
"translationTarget=\(pipelineStore.translationTargetLocaleId)"
)
FlowTrace.transcript(
"polish.input",
textForPolish,
"mode=\(Self.polishModeLogLabel(polishMode)) engine=\(engineMode) "
"mode=\(isClipboardCommand ? "clipboardCommand" : Self.polishModeLogLabel(polishMode)) engine=\(engineMode) "
+ "provider=\(pipelineStore.polishProviderIdOverride ?? "default") "
+ "recordedSeconds=\(String(format: "%.2f", recordingDuration))"
)
@@ -1723,29 +1792,31 @@ final class FlowSessionManager: ObservableObject {
// If the finalize task was cancelled (cold-start churn / abort),
// skip the LLM round-trip and deliver the raw transcript so the
// keyboard is not left waiting on a result that never arrives.
// Clipboard-command mode must never insert the instruction ASR.
if Task.isCancelled {
throw CancellationError()
}
let outcome = try await Self.polishWithHostTimeout(
polisher: polisher,
text: textForPolish,
text: clipboardPrompt?.user ?? textForPolish,
mode: polishMode,
systemPrompt: clipboardPrompt?.system,
providerIdOverride: pipelineStore.polishProviderIdOverride,
context: polishContext
context: isClipboardCommand ? nil : polishContext
)
let polished = outcome.text
delivered = polished
FlowTrace.transcript(
"polish.output",
polished,
"mode=\(Self.polishModeLogLabel(polishMode)) inputLen=\(text.count) "
"mode=\(isClipboardCommand ? "clipboardCommand" : Self.polishModeLogLabel(polishMode)) inputLen=\(text.count) "
+ "changed=\(polished == text ? 0 : 1) "
+ "elapsed=\(FlowTrace.seconds(since: polishStarted))s"
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedResult(
polished,
rawText: text,
rawText: isClipboardCommand ? nil : text,
warning: Self.combinedWarning(
chunkNote,
outcome.qualityDegraded
@@ -1760,51 +1831,77 @@ final class FlowSessionManager: ObservableObject {
"polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " +
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
)
} catch {
// CancellationError is common when the user jumps back via
// startflow mid-polish; still deliver raw text. Other errors
// keep the existing polish-warning fallback.
let fallback = Self.makeFallbackDelivery(
rawText: text,
error: error,
SpeechHistoryStore.shared.recordUtterance(
text: delivered,
engineMode: engineMode,
chunkWarning: chunkNote
)
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
)
FlowTrace.warn(
"polish.failed",
"mode=\(Self.polishModeLogLabel(polishMode)) engine=\(engineMode) "
+ "elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "error=\(error.localizedDescription)"
)
FlowTrace.transcript(
"polish.fallback",
fallback.text,
"reason=polishFailed rawLen=\(text.count)"
)
delivered = fallback.text
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedResult(
fallback.text,
rawText: text,
warning: fallback.polishWarning,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
duration: recordingDuration,
wasTranslation: isClipboardCommand ? false : pipelineStore.isTranslationEffective
)
} catch {
if isClipboardCommand {
FlowDiagnostics.log(
"clipboard command failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
)
FlowTrace.warn(
"clipboardCommand.failed",
"elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "error=\(error.localizedDescription)"
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.clipboardCommandFailed"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
} else {
// CancellationError is common when the user jumps back via
// startflow mid-polish; still deliver raw text. Other errors
// keep the existing polish-warning fallback.
let fallback = Self.makeFallbackDelivery(
rawText: text,
error: error,
engineMode: engineMode,
chunkWarning: chunkNote
)
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
)
FlowTrace.warn(
"polish.failed",
"mode=\(Self.polishModeLogLabel(polishMode)) engine=\(engineMode) "
+ "elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "error=\(error.localizedDescription)"
)
FlowTrace.transcript(
"polish.fallback",
fallback.text,
"reason=polishFailed rawLen=\(text.count)"
)
delivered = fallback.text
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedResult(
fallback.text,
rawText: text,
warning: fallback.polishWarning,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
SpeechHistoryStore.shared.recordUtterance(
text: delivered,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: pipelineStore.isTranslationEffective
)
}
}
SpeechHistoryStore.shared.recordUtterance(
text: delivered,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: pipelineStore.isTranslationEffective
)
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
@@ -1882,7 +1979,8 @@ final class FlowSessionManager: ObservableObject {
rawText: rawText,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext)
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
utteranceMode: currentUtteranceMode
)
)
}
@@ -1905,7 +2003,8 @@ final class FlowSessionManager: ObservableObject {
rawText: trimmed,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext)
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
utteranceMode: currentUtteranceMode
)
)
}
@@ -1935,7 +2034,8 @@ final class FlowSessionManager: ObservableObject {
errorKind: kind,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext)
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
utteranceMode: currentUtteranceMode
)
)
}
@@ -2069,6 +2169,7 @@ final class FlowSessionManager: ObservableObject {
polisher: PolishingService,
text: String,
mode: PolishingService.PolishMode,
systemPrompt: String? = nil,
providerIdOverride: String?,
context: PolishContext?
) async throws -> PolishingService.PolishOutcome {
@@ -2077,6 +2178,7 @@ final class FlowSessionManager: ObservableObject {
try await polisher.polishWithOutcome(
text,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: context
)
+1
View File
@@ -336,6 +336,7 @@
/* Flow session */
"flow.error.noSpeech" = "No speech detected. Please try again.";
"flow.error.clipboardCommandFailed" = "Couldn't process the clipboard. Please try again.";
"flow.error.recognitionInterrupted" = "Recognition did not finish. Please try again.";
"keyboard.denied.mic" = "Microphone access denied";
"keyboard.denied.speech" = "Speech recognition denied";
@@ -335,6 +335,7 @@
/* Flow session */
"flow.error.noSpeech" = "未检测到语音,请重试。";
"flow.error.clipboardCommandFailed" = "剪贴板处理失败,请重试。";
"flow.error.recognitionInterrupted" = "识别未完成,请再试一次。";
"keyboard.denied.mic" = "麦克风权限被拒绝";
"keyboard.denied.speech" = "语音识别权限被拒绝";