import Foundation import Observation /// T-iOS-31 · 语音 PTT(按住说话)+ 显式确认 + 1.5s 撤销 + epoch 防误发。 /// /// 本文件 = 缝 + 纯归约 + 状态机。同任务的另两块因 800 行硬上限拆出为兄弟文件: /// `VoicePTTBanner.swift`(SwiftUI 状态卡)与 `SpeechDictation.swift`(真机识别器)。 /// /// 三件套逐条移植 web(plan §7「端口匹配器 / 1.5s 撤销 / epoch 防误发」= port /// `public/voice-commands.ts` 的匹配器): /// - `public/voice.ts`:PTT 生命周期,且 `autoSend` 默认 false ⇒ **口述绝不自己 /// 回车**(误听不得直接执行命令); /// - `public/voice-commands.ts`:**整句精确**命令匹配器 + 否定守卫 + 0.6 置信度门; /// - `public/voice-confirm.ts`:`DEFAULT_WINDOW_MS = 1500` 的可撤销窗口。 /// /// 安全边界(本文件是全部收口点): /// 1. 转写来自系统识别器 = **不可信输入**:`VoiceTranscript.sanitize` 剥掉全部 /// C0/C1 控制字符(含 `\r`/ESC),折成单行、限长,然后才可能进 PTY; /// 2. **确认前零注入**:`.confirming` 态不发一个字节,用户点「发送」才 arm; /// 3. **1.5s 撤销窗口**:arm 后仍不发,到期才发 —— 字节一旦进 PTY 就撤不回来, /// 所以撤销只能靠延迟发送(这是唯一可实现的语义); /// 4. **epoch 两道闸**:口述开始时抓一次 epoch,`confirm()` 与窗口到期时各校验 /// 一次;不等就丢弃 —— 会话切换/重连之间的口述绝不可能注入到别的会话。 /// /// 识别器经 `VoiceDictating` 协议注入,故匹配器/窗口/epoch 全部可纯单测;真机 /// 口述走 `SpeechDictation`(需硬件麦克风,端到端验证属 DEFERRED)。 // MARK: - Recognizer seam /// 授权结果(麦克风与语音识别是两个独立的 TCC 开关,文案必须分开)。 enum VoiceAuthorization: Equatable, Sendable { case granted case deniedMicrophone case deniedSpeech case restricted var denialReason: VoiceDenialReason? { switch self { case .granted: nil case .deniedMicrophone: .microphone case .deniedSpeech: .speech case .restricted: .restricted } } } /// 被拒原因 → 用户能照做的指引文案。 enum VoiceDenialReason: Equatable, Sendable { case microphone case speech case restricted var message: String { switch self { case .microphone: "麦克风权限被拒绝。到 设置 → 隐私与安全性 → 麦克风 里打开 WebTerm 才能按住说话。" case .speech: "语音识别权限被拒绝。到 设置 → 隐私与安全性 → 语音识别 里打开 WebTerm。" case .restricted: "本设备的语音识别被限制(如「屏幕使用时间」策略),无法使用语音输入。" } } } /// 一次口述的结果。`confidence` 只有识别器报了才有(供匹配器的置信度门用)。 struct VoiceDictationResult: Equatable, Sendable { let transcript: String let confidence: Double? } /// 识别器缝。生产实现 `SpeechDictation`(Speech + AVAudioEngine),测试注入替身。 @MainActor protocol VoiceDictating: AnyObject { /// 申请麦克风 + 语音识别授权(两个 TCC 开关)。 func requestAuthorization() async -> VoiceAuthorization /// 开始流式识别;中途假设经 `onPartial` 回来。 func start(onPartial: @escaping @MainActor (String) -> Void) async throws /// 停止采集并给出当前最佳转写。 func stop() async -> VoiceDictationResult } // MARK: - Transcript sanitisation (security boundary) /// 转写清洗:识别器输出是**不可信输入**,绝不原样进 PTY。 enum VoiceTranscript { /// 单次口述允许注入的最大字符数。 static let maxLength = 2000 /// 制表/换行类空白(折成空格),其余 C0/C1 一律剥除。 private static let whitespaceControls: Set = [0x09, 0x0A, 0x0B, 0x0C, 0x0D] private static func isControl(_ scalar: Unicode.Scalar) -> Bool { scalar.value < 0x20 || scalar.value == 0x7F || (0x80...0x9F).contains(scalar.value) } /// 剥控制字符 → 折单行 → 合并空白 → 限长。 /// /// `\r`(0x0D)被剥掉是**刻意的**:口述若能自带回车,一次误听就能直接执行 /// 命令(等价 web 的 `autoSend: false` 默认)。用户自己按 ⏎ 才算执行。 static func sanitize(_ raw: String) -> String { let scalars = raw.unicodeScalars.compactMap { scalar -> Unicode.Scalar? in if whitespaceControls.contains(scalar.value) { return " " } return isControl(scalar) ? nil : scalar } let collapsed = String(String.UnicodeScalarView(scalars)) .split(whereSeparator: \.isWhitespace) .joined(separator: " ") return String(collapsed.prefix(maxLength)) } /// 清洗后还有内容才值得进确认态。 static func isInjectable(_ sanitized: String) -> Bool { !sanitized.isEmpty } } // MARK: - Command matcher (port of public/voice-commands.ts) /// 匹配器判定。`.text` = 普通口述(绝大多数情况)。 enum VoiceAction: String, Equatable, Sendable { case approve case reject case text } /// 待批 gate 的种类 —— v1 只对 tool gate 生效(与 web 一致)。 enum VoiceGateKind: String, Equatable, Sendable { case tool case plan } /// 匹配上下文快照(不含置信度 —— 那个来自 ASR 结果)。 struct VoiceGateSnapshot: Equatable, Sendable { let pendingApproval: Bool let gate: VoiceGateKind? } /// 匹配上下文(web `VoiceMatchContext` 的等价物)。 struct VoiceMatchContext: Equatable, Sendable { let pendingApproval: Bool let gate: VoiceGateKind? let confidence: Double? } /// **整句精确**、拒绝优先、否定守卫的命令匹配器 —— 逐条移植 /// `public/voice-commands.ts`。故意不做子串/分词匹配:顺口说出的一句话绝不能把 /// 一个能执行 shell 的权限门给批了。 enum VoiceCommandMatcher { /// `.approve` 需要的最低 ASR 置信度;`.reject` 不受此门约束(拒绝是安全方向)。 static let minApproveConfidence = 0.6 static let approvePhrases: Set = [ "确认", "批准", "同意", "通过", "允许", "可以", "好", "好的", "好吧", "好啊", "是", "是的", "对", "行", "继续", "回车", "yes", "yeah", "yep", "ok", "okay", "confirm", "approve", "accept", "proceed", "go ahead", ] static let rejectPhrases: Set = [ "拒绝", "取消", "不行", "不要", "不用", "不同意", "不批准", "不可以", "不允许", "不通过", "中断", "停止", "算了", "别", "不", "no", "nope", "reject", "deny", "cancel", "abort", "decline", "stop", ] static let negationPrefixes: [String] = [ "不", "别", "没", "未", "勿", "no", "not", "dont", "cannot", "wont", "never", ] /// web `PUNCTUATION_RE` 的字符集等价物(ASCII + CJK 标点与各种引号)。 private static let punctuation = CharacterSet( charactersIn: "'’.,!?;:,。!?;:、「」『』“”‘’()()[]{}【】〈〉《》…—-_/\\|\"`~@#$%^&*+=<>" ) private static func isASCIIWord(_ scalar: Unicode.Scalar) -> Bool { ("a"..."z").contains(scalar) || ("0"..."9").contains(scalar) } /// `trim → 小写 → 去标点(don't→dont)→ 合并空白 → trim`(逐条对齐 web)。 static func normalize(_ raw: String) -> String { let lowered = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let stripped = String(String.UnicodeScalarView( lowered.unicodeScalars.filter { !punctuation.contains($0) } )) return stripped.split(whereSeparator: \.isWhitespace).joined(separator: " ") } /// 归一化后的话语是否以否定词起头(ASCII 否定词要求词边界,故 `now` 不算否定)。 static func startsWithNegation(_ normalized: String) -> Bool { negationPrefixes.contains { prefix in guard normalized.hasPrefix(prefix) else { return false } guard let head = prefix.unicodeScalars.first, isASCIIWord(head) else { return true } guard let next = normalized.dropFirst(prefix.count).unicodeScalars.first else { return true } return !isASCIIWord(next) } } /// 匹配一次。任何非「整句精确 + gate 合格 + 置信度足够」的输入都落 `.text`。 static func match(transcript: String, context: VoiceMatchContext) -> VoiceAction { let normalized = normalize(transcript) guard context.pendingApproval, !normalized.isEmpty else { return .text } guard context.gate == .tool else { return .text } if startsWithNegation(normalized) { return rejectPhrases.contains(normalized) ? .reject : .text } if rejectPhrases.contains(normalized) { return .reject } guard approvePhrases.contains(normalized) else { return .text } if let confidence = context.confidence, confidence < minApproveConfidence { return .text } return .approve } } // MARK: - Epoch (mis-send guard) /// 口述绑定的会话世代。`sessionId` 抓的是服务器采纳的 id,`generation` 每次**连接 /// 丢失**递增 —— 两者任一变化都作废在飞的口述。 struct VoiceEpoch: Equatable, Sendable { let sessionId: UUID? let generation: Int } /// epoch 作废策略(纯谓词,单一判定点)。 enum VoiceEpochPolicy { /// 该连接状态是否作废在飞的口述。 /// /// `.reconnecting` ⇒ 作废:连接掉过之后服务器**可能**给的是一条新 PTY(旧会话 /// 被 IDLE_TTL 回收时就会),而客户端在收到新的 `attached` 之前分辨不了。 /// 误丢弃只是重说一遍,误注入是把命令打进别的会话 —— 走安全方向。 static func invalidates(_ banner: TerminalViewModel.ConnectionBanner) -> Bool { switch banner { case .reconnecting: true case .none, .connecting: false } } } /// epoch 源:由 `TerminalScreen` 喂入 `TerminalViewModel` 的 sessionId / banner 变化, /// 供 PTT 在口述开始与注入时刻各取一次快照。 @MainActor @Observable final class VoiceEpochSource { private(set) var generation = 0 private(set) var sessionId: UUID? var epoch: VoiceEpoch { VoiceEpoch(sessionId: sessionId, generation: generation) } func noteSession(_ id: UUID?) { sessionId = id } func noteBanner(_ banner: TerminalViewModel.ConnectionBanner) { guard VoiceEpochPolicy.invalidates(banner) else { return } generation += 1 } } // MARK: - Pending action / resolution /// 用户口述被判成什么动作。 enum VoiceIntent: Equatable, Sendable { /// 普通口述文本(清洗后)。 case text(String) /// 命令短语 —— 对已 held 的 tool gate 做决定。 case decision(VoiceDecision) } /// gate 决定(approve/reject);映射到既有 gate 通道由 wiring 负责。 enum VoiceDecision: String, Equatable, Sendable { case approve case reject } /// 待确认动作 —— 连同它**当时**的 epoch 一起冻结(防误发的核心)。 struct VoicePendingAction: Equatable, Sendable { let intent: VoiceIntent let epoch: VoiceEpoch /// 给用户看的确认文案(清洗后的转写)。 let preview: String } /// 一次口述最终怎么了 —— UI 的提示文案与测试的断言点。 enum VoiceResolution: Equatable, Sendable { case committed(VoiceIntent) case undone case discarded case staleEpoch case empty case readOnly } /// gate 桥:上下文快照 + 决定出口**成对**注入。nil ⇒ 没有 gate 通道,匹配器恒 /// 退化为普通口述(与 web「没有 held gate 就一律 dictation」同义)。 struct VoiceGateBridge { let snapshot: @MainActor () -> VoiceGateSnapshot let decide: @MainActor (VoiceDecision) -> Void } // MARK: - ViewModel /// PTT 状态机。全部时间靠注入的 `Clock` 推进(测试零真睡)。 @MainActor @Observable final class VoicePTTViewModel { /// 可撤销窗口时长 —— 对齐 web `voice-confirm.ts` 的 `DEFAULT_WINDOW_MS = 1500`。 static let undoWindow: Duration = .milliseconds(1500) enum Phase: Equatable { case idle case requestingPermission case listening(partial: String) case confirming(VoicePendingAction) case undoWindow(VoicePendingAction) case denied(VoiceDenialReason) case failed(message: String) } /// 用户可见文案(named constants)。 enum Copy { static let hold = "按住说话" static let requesting = "正在请求麦克风权限…" static let listening = "正在听…松手结束" static let listeningHint = "松手后要你确认才会发送" static let confirmTitle = "确认发送到终端" static let approveTitle = "确认批准这次工具调用" static let rejectTitle = "确认拒绝这次工具调用" static let send = "发送" static let cancel = "取消" static let undo = "撤销" static let sending = "1.5 秒后发送 —— 可撤销" static let deciding = "1.5 秒后提交决定 —— 可撤销" static let recognizerFailed = "语音识别没能启动" static let noticeUndone = "已撤销,什么都没发送。" static let noticeDiscarded = "已丢弃这段口述。" static let noticeStaleEpoch = "会话已切换或重连过,这段口述没有发送(避免打进别的会话)。" static let noticeEmpty = "没听到内容,什么都没发送。" static let noticeReadOnly = "这个会话已结束,不能再输入。" static let dismissNotice = "关闭提示" } private(set) var phase: Phase = .idle private(set) var lastResolution: VoiceResolution? @ObservationIgnored private let dictation: any VoiceDictating @ObservationIgnored private let clock: any Clock @ObservationIgnored private let epoch: @MainActor () -> VoiceEpoch @ObservationIgnored private let isReadOnly: @MainActor () -> Bool @ObservationIgnored private let inject: @MainActor (String) -> Void @ObservationIgnored private let gate: VoiceGateBridge? /// 手指是否还按着 —— PTT 竞态(还没起来就松手)的唯一判据。 @ObservationIgnored private var isPressed = false /// `dictation.start` 正在飞 —— 此时松手交给 start 的收尾分支处理。 @ObservationIgnored private var isStarting = false /// 口述开始那一刻的 epoch,冻结进 `VoicePendingAction`。 @ObservationIgnored private var dictationEpoch = VoiceEpoch(sessionId: nil, generation: 0) @ObservationIgnored private var windowTask: Task? init( dictation: any VoiceDictating, clock: any Clock, epoch: @escaping @MainActor () -> VoiceEpoch, isReadOnly: @escaping @MainActor () -> Bool, inject: @escaping @MainActor (String) -> Void, gate: VoiceGateBridge? = nil ) { self.dictation = dictation self.clock = clock self.epoch = epoch self.isReadOnly = isReadOnly self.inject = inject self.gate = gate } // MARK: Derived UI state var isUndoWindowOpen: Bool { if case .undoWindow = phase { return true } return false } var isListening: Bool { if case .listening = phase { return true } return false } /// 待确认动作(nil = 现在没有要确认的东西)。 var pendingAction: VoicePendingAction? { switch phase { case .confirming(let pending), .undoWindow(let pending): pending default: nil } } /// 结果提示文案(`.committed` 不提示 —— 文字已经出现在终端里)。 var noticeText: String? { switch lastResolution { case .none, .committed: nil case .undone: Copy.noticeUndone case .discarded: Copy.noticeDiscarded case .staleEpoch: Copy.noticeStaleEpoch case .empty: Copy.noticeEmpty case .readOnly: Copy.noticeReadOnly } } func clearNotice() { lastResolution = nil } // MARK: PTT /// 按下麦克风键。已有待确认内容时不抢(先处理完那一条)。 func pressDown() async { guard pendingAction == nil else { return } isPressed = true lastResolution = nil guard !isReadOnly() else { isPressed = false phase = .idle lastResolution = .readOnly return } dictationEpoch = epoch() phase = .requestingPermission let authorization = await dictation.requestAuthorization() guard authorization == .granted else { isPressed = false phase = .denied(authorization.denialReason ?? .restricted) return } // 授权期间就松手了 —— 麦克风一次都不开。 guard isPressed else { phase = .idle return } await beginListening() } /// 松手。录音还在启动中时只落下标记,由 `beginListening` 收尾。 func pressUp() async { isPressed = false guard !isStarting, isListening else { return } await finishListening() } private func beginListening() async { phase = .listening(partial: "") isStarting = true do { try await dictation.start { [weak self] partial in guard let self, self.isListening else { return } self.phase = .listening(partial: partial) } } catch { isStarting = false isPressed = false phase = .failed(message: Self.failureMessage(error)) return } isStarting = false // 启动期间松手了 → 立刻收尾(麦克风绝不留在开着的状态)。 guard isPressed else { await finishListening() return } } private func finishListening() async { let result = await dictation.stop() let text = VoiceTranscript.sanitize(result.transcript) guard VoiceTranscript.isInjectable(text) else { phase = .idle lastResolution = .empty return } let snapshot = gate?.snapshot() ?? VoiceGateSnapshot(pendingApproval: false, gate: nil) let context = VoiceMatchContext( pendingApproval: snapshot.pendingApproval, gate: snapshot.gate, confidence: result.confidence ) // 匹配器吃**原始**转写(它自己归一化,与 web 完全一致)。 let intent: VoiceIntent = switch VoiceCommandMatcher.match( transcript: result.transcript, context: context ) { case .text: .text(text) case .approve: .decision(.approve) case .reject: .decision(.reject) } phase = .confirming( VoicePendingAction(intent: intent, epoch: dictationEpoch, preview: text) ) } private static func failureMessage(_ error: any Error) -> String { guard let described = (error as? any LocalizedError)?.errorDescription, !described.isEmpty else { return Copy.recognizerFailed } return "\(Copy.recognizerFailed):\(described)" } // MARK: Confirm / undo window /// 显式确认。第一道 epoch 闸在这里 —— 不等就直接丢弃,绝不 arm。 func confirm() { guard case .confirming(let pending) = phase else { return } guard epoch() == pending.epoch else { discard(.staleEpoch) return } phase = .undoWindow(pending) windowTask = Task { [weak self] in guard let self else { return } do { try await clock.sleep(for: Self.undoWindow, tolerance: nil) } catch { return // 被 undo 取消 —— 什么都不做 } commit() } } /// 确认前取消。 func cancel() { guard case .confirming = phase else { return } phase = .idle lastResolution = .discarded } /// 撤销窗口内撤销 —— 字节从未离开 App。 func undo() { guard case .undoWindow = phase else { return } windowTask?.cancel() phase = .idle lastResolution = .undone } /// 窗口到期。第二道 epoch 闸在这里(confirm 之后仍可能切了会话)。 private func commit() { guard case .undoWindow(let pending) = phase else { return } guard epoch() == pending.epoch else { discard(.staleEpoch) return } switch pending.intent { case .text(let text): inject(text) case .decision(let decision): guard let gate else { discard(.discarded) // 无 gate 通道 —— 绝不静默丢,给出可见结果 return } gate.decide(decision) } phase = .idle lastResolution = .committed(pending.intent) } private func discard(_ resolution: VoiceResolution) { windowTask?.cancel() phase = .idle lastResolution = resolution } // MARK: Deterministic test barrier /// 等撤销窗口任务收尾(零轮询、零真睡)。 func waitUntilWindowSettled() async { await windowTask?.value } }