feat: voice command mapping — context-gated approve/reject over voice
While a tool-permission gate is held on the active tab, spoken confirm phrases resolve it via the existing approve/reject WS channel — no server change (byte-shuttle intact). Everything else stays ordinary dictation. Safety: whole-utterance-exact matching + leading-negation guard + reject precedence + confidence gate (approve only) + a cancellable 1.5s confirm window whose commit re-validates gate identity/epoch/connectivity (TOCTOU guard) + stale-gate epoch bound at PTT-start. New pure modules public/voice-commands.ts and public/voice-confirm.ts at 100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass, typecheck clean, build:web OK. Independent code + security reviews flagged a confirm-window TOCTOU and an unwired cancel path — both fixed and covered. Plan: docs/PLAN_VOICE_COMMANDS.md.
This commit is contained in:
@@ -42,8 +42,13 @@ if (joinId) {
|
||||
let settings: Settings = loadSettings()
|
||||
app.applySettings(settings)
|
||||
|
||||
// Key bar (mobile + desktop) sends to whichever tab is active.
|
||||
mountKeybar((data) => app.sendToActive(data))
|
||||
// Key bar (mobile + desktop) sends to whichever tab is active. onVoiceTrigger
|
||||
// activates the full voice path (dictation + context-gated confirm/reject, VC)
|
||||
// and un-dormants the 🎤 button (createVoiceInput / matchCommand are otherwise
|
||||
// unreachable dead code without this wiring).
|
||||
mountKeybar((data) => app.sendToActive(data), {
|
||||
onVoiceTrigger: (action) => app.handleVoiceTrigger(action),
|
||||
})
|
||||
|
||||
// Multi-device: when this device regains focus, re-assert the active tab's size
|
||||
// so a shared session snaps to THIS screen (latest-writer-wins) — full-screen on
|
||||
|
||||
130
public/tabs.ts
130
public/tabs.ts
@@ -32,6 +32,8 @@ import { mountPushToggle } from './push.js'
|
||||
import { mountQuickReply } from './quick-reply.js'
|
||||
import { mountTimeline, type TimelineHandle } from './timeline.js'
|
||||
import { createVoiceInput, type VoiceInput } from './voice.js'
|
||||
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
|
||||
import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
|
||||
|
||||
const TABS_KEY = 'web-terminal:tabs'
|
||||
const ACTIVE_KEY = 'web-terminal:active'
|
||||
@@ -42,6 +44,9 @@ const PERMISSION_MODE_KEY = 'web-terminal:permission-mode'
|
||||
* (class `tg-stale`) once telemetry is older than this (B2). */
|
||||
const STATUSLINE_TTL_MS = 30_000
|
||||
|
||||
/** VC decision C: how long the "未连接" not-connected flash stays visible. */
|
||||
const VOICE_NOT_CONNECTED_FLASH_MS = 1500
|
||||
|
||||
/** All `--permission-mode` values (B4). 'auto' is high-risk and only offered
|
||||
* when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */
|
||||
const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
|
||||
@@ -102,6 +107,11 @@ export class TabApp {
|
||||
private timelineBtn: HTMLButtonElement | null = null
|
||||
private voice: VoiceInput | null = null // A2: push-to-talk recognizer
|
||||
private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay
|
||||
// VC: context-gated confirm/reject command mapping (PLAN_VOICE_COMMANDS.md).
|
||||
private readonly approveConfirm: ApproveConfirm = createApproveConfirm() // decision B
|
||||
private voiceConfirmOverlay: HTMLElement | null = null // "听到:批准" / "未连接" (B, C)
|
||||
private voicePttSession: TerminalSession | null = null // session captured at PTT-start (safeguard 5)
|
||||
private voicePttEpoch = 0 // session.pendingEpoch captured at PTT-start (safeguard 5)
|
||||
|
||||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||||
this.paneHost = paneHost
|
||||
@@ -173,13 +183,23 @@ export class TabApp {
|
||||
document.body.appendChild(this.timelinePanel)
|
||||
}
|
||||
|
||||
/** A2: overlay that shows the live interim transcript while dictating. */
|
||||
/** A2: overlay that shows the live interim transcript while dictating. VC: a
|
||||
* SEPARATE overlay carries "听到:批准" / "未连接" so hideInterim() (which
|
||||
* fires on PTT release) never clobbers it mid-confirm-window (§6). */
|
||||
private setupVoiceOverlay(): void {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'voice-interim'
|
||||
overlay.style.display = 'none'
|
||||
document.body.appendChild(overlay)
|
||||
this.voiceOverlay = overlay
|
||||
|
||||
const confirmOverlay = document.createElement('div')
|
||||
confirmOverlay.id = 'voice-confirm'
|
||||
confirmOverlay.style.display = 'none'
|
||||
// Tap-to-cancel an armed approve confirm window (§4 cancel trigger).
|
||||
confirmOverlay.addEventListener('click', () => this.cancelArmedApprove())
|
||||
document.body.appendChild(confirmOverlay)
|
||||
this.voiceConfirmOverlay = confirmOverlay
|
||||
}
|
||||
|
||||
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
|
||||
@@ -351,11 +371,17 @@ export class TabApp {
|
||||
|
||||
private startVoice(): void {
|
||||
if (!this.voice) {
|
||||
this.voice = createVoiceInput((text) => this.sendToActive(text), {
|
||||
onInterim: (t) => this.showInterim(t),
|
||||
})
|
||||
this.voice = createVoiceInput(
|
||||
(text, confidence) => this.handleVoiceTranscript(text, confidence),
|
||||
{ onInterim: (t) => this.showInterim(t) },
|
||||
)
|
||||
}
|
||||
if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3)
|
||||
// VC safeguard 5: bind this utterance to the CURRENTLY active session/epoch
|
||||
// so a slow transcript can't approve a newer gate or a different tab.
|
||||
const active = this.tabs[this.activeIndex]?.session ?? null
|
||||
this.voicePttSession = active
|
||||
this.voicePttEpoch = active?.pendingEpoch ?? 0
|
||||
this.showInterim('')
|
||||
this.voice.start()
|
||||
}
|
||||
@@ -375,6 +401,102 @@ export class TabApp {
|
||||
if (this.voiceOverlay) this.voiceOverlay.style.display = 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* VC — dispatch a final voice transcript (PLAN_VOICE_COMMANDS.md §6):
|
||||
* context-gated confirm/reject resolve the held permission via the existing
|
||||
* approve()/reject() WS channel; everything else (including a stale gate or
|
||||
* a tab switch mid-utterance) falls through to ordinary dictation, byte-for-
|
||||
* byte unchanged. Raw bytes are NEVER synthesized for approve/reject.
|
||||
*/
|
||||
private handleVoiceTranscript(text: string, confidence?: number): void {
|
||||
// A NEW utterance supersedes any pending approve confirm window: the user
|
||||
// has spoken again, so never let a stale timer commit behind a fresh
|
||||
// command (this is the "say another word" cancel trigger, §4). Combined
|
||||
// with tap-to-cancel and the commit-time re-check below, it closes the
|
||||
// TOCTOU race where a delayed approve could resolve a different gate.
|
||||
this.cancelArmedApprove()
|
||||
|
||||
const session = this.tabs[this.activeIndex]?.session
|
||||
if (!session) {
|
||||
this.sendToActive(text)
|
||||
return
|
||||
}
|
||||
const ctx: VoiceMatchContext = {
|
||||
pendingApproval: session.pendingApproval,
|
||||
gate: session.pendingGate,
|
||||
confidence,
|
||||
}
|
||||
const action = matchCommand(text, ctx)
|
||||
// Stale-gate guard: a command can only resolve the SAME session+gate it
|
||||
// was spoken against; anything else (tab switch, a new pending flip) is
|
||||
// downgraded to plain dictation rather than silently dropped.
|
||||
const stale = session !== this.voicePttSession || session.pendingEpoch !== this.voicePttEpoch
|
||||
if (action !== 'text' && stale) {
|
||||
this.sendToActive(text)
|
||||
return
|
||||
}
|
||||
if (action === 'reject') {
|
||||
if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
|
||||
session.reject()
|
||||
this.updateApprovalBar()
|
||||
return
|
||||
}
|
||||
if (action === 'approve') {
|
||||
if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
|
||||
// Decision B: arm a cancellable window instead of committing immediately.
|
||||
// The commit RE-VALIDATES the gate identity (TOCTOU fix): if the held
|
||||
// permission resolved and a NEW gate was raised during the ~1.5s window
|
||||
// (pendingEpoch bumps), or the tab/WS changed, drop the approve — never
|
||||
// silently approve a different, unseen/unheard gate.
|
||||
const armedSession = session
|
||||
const armedEpoch = session.pendingEpoch
|
||||
this.approveConfirm.arm(
|
||||
() => this.flashVoiceStatus('听到:批准(点此取消)'),
|
||||
() => {
|
||||
this.hideVoiceStatus()
|
||||
const fresh =
|
||||
armedSession.pendingApproval &&
|
||||
armedSession.pendingGate === 'tool' &&
|
||||
armedSession.pendingEpoch === armedEpoch &&
|
||||
this.isVoiceConnected(armedSession)
|
||||
if (!fresh) return // gate changed / WS dropped during the window → drop
|
||||
armedSession.approve()
|
||||
this.updateApprovalBar()
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
this.sendToActive(text) // UNCHANGED dictation path
|
||||
}
|
||||
|
||||
/** Cancel a still-armed approve confirm window and clear its overlay. Invoked
|
||||
* on a new utterance, a reject, and a tap on the overlay (§4 cancel triggers). */
|
||||
private cancelArmedApprove(): void {
|
||||
if (!this.approveConfirm.isArmed()) return
|
||||
this.approveConfirm.cancel()
|
||||
this.hideVoiceStatus()
|
||||
}
|
||||
|
||||
/** Decision C: approve()/reject() are silently dropped by sendMsg when the WS
|
||||
* isn't open — flash feedback instead of a silent no-op. */
|
||||
private isVoiceConnected(session: TerminalSession): boolean {
|
||||
return session.status === 'connected'
|
||||
}
|
||||
|
||||
/** Transient voice-status text ("听到:批准" / "未连接") on a DIFFERENT overlay
|
||||
* than the interim transcript, so hideInterim() (PTT release) never clears
|
||||
* it mid-confirm-window. XSS-safe: textContent only. */
|
||||
private flashVoiceStatus(text: string, autoHideMs?: number): void {
|
||||
if (!this.voiceConfirmOverlay) return
|
||||
this.voiceConfirmOverlay.textContent = text
|
||||
this.voiceConfirmOverlay.style.display = 'block'
|
||||
if (autoHideMs !== undefined) setTimeout(() => this.hideVoiceStatus(), autoHideMs)
|
||||
}
|
||||
|
||||
private hideVoiceStatus(): void {
|
||||
if (this.voiceConfirmOverlay) this.voiceConfirmOverlay.style.display = 'none'
|
||||
}
|
||||
|
||||
/** Toggle the activity-timeline panel for the active session (A4). */
|
||||
private toggleTimeline(): void {
|
||||
this.timelineOpen = !this.timelineOpen
|
||||
|
||||
@@ -95,6 +95,11 @@ export class TerminalSession {
|
||||
private pendingToolValue: string | undefined = undefined
|
||||
private telemetryValue: StatusTelemetry | null = null
|
||||
private pendingGateValue: PermissionGate | null = null
|
||||
// VC: nonce that increments on every false→true pendingApproval flip — lets a
|
||||
// caller (e.g. a voice command captured at PTT-start) detect a stale gate: a
|
||||
// slow transcript must not resolve a NEWER held permission than the one it
|
||||
// was spoken against (PLAN_VOICE_COMMANDS.md §5, safeguard 5).
|
||||
private pendingEpochValue = 0
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private sessionId: string | null
|
||||
@@ -189,6 +194,11 @@ export class TerminalSession {
|
||||
return this.pendingGateValue
|
||||
}
|
||||
|
||||
/** Nonce counting false→true pendingApproval flips (VC stale-gate guard, §5). */
|
||||
get pendingEpoch(): number {
|
||||
return this.pendingEpochValue
|
||||
}
|
||||
|
||||
private setStatus(s: SessionStatus): void {
|
||||
this.statusValue = s
|
||||
this.onStatus?.(s)
|
||||
@@ -293,9 +303,11 @@ export class TerminalSession {
|
||||
break
|
||||
}
|
||||
case 'status': {
|
||||
const nextPending = msg.pending === true
|
||||
if (nextPending && !this.pendingApprovalValue) this.pendingEpochValue++
|
||||
this.claudeStatusValue = msg.status
|
||||
this.pendingApprovalValue = msg.pending === true
|
||||
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
|
||||
this.pendingApprovalValue = nextPending
|
||||
this.pendingToolValue = nextPending ? msg.detail : undefined
|
||||
this.pendingGateValue = msg.gate ?? null
|
||||
this.onClaudeStatus?.(msg.status, msg.detail)
|
||||
break
|
||||
|
||||
91
public/voice-commands.ts
Normal file
91
public/voice-commands.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* public/voice-commands.ts — pure, DOM-free/WS-free voice command matcher.
|
||||
*
|
||||
* Context-gated confirm-class command mapping: while the active tab has a
|
||||
* HELD tool-gate permission (`pendingApproval===true`, `gate==='tool'`),
|
||||
* spoken confirm/deny phrases resolve to `approve`/`reject`; every other
|
||||
* transcript (or context) falls through to ordinary dictation (`text`),
|
||||
* byte-for-byte unchanged. See docs/PLAN_VOICE_COMMANDS.md §3/§7 for the
|
||||
* locked decisions and security rationale — this module is intentionally
|
||||
* whole-utterance-exact (no substring/token matching) so incidental
|
||||
* dictation can never auto-approve a shell-granting gate.
|
||||
*/
|
||||
|
||||
export type VoiceAction = 'approve' | 'reject' | 'text'
|
||||
|
||||
export interface VoiceMatchContext {
|
||||
readonly pendingApproval: boolean // active session held-permission flag
|
||||
readonly gate: 'tool' | 'plan' | null // v1 acts ONLY on 'tool'
|
||||
readonly confidence?: number // optional ASR confidence (decision D)
|
||||
}
|
||||
|
||||
/** Approve requires ASR confidence at or above this threshold; reject is unaffected. */
|
||||
export const MIN_APPROVE_CONFIDENCE = 0.6
|
||||
|
||||
/** Normalized whole-utterance affirmative phrases (see normalizeTranscript). */
|
||||
export const APPROVE_PHRASES: readonly string[] = [
|
||||
'确认', '批准', '同意', '通过', '允许', '可以', '好', '好的', '好吧', '好啊',
|
||||
'是', '是的', '对', '行', '继续', '回车',
|
||||
'yes', 'yeah', 'yep', 'ok', 'okay', 'confirm', 'approve', 'accept', 'proceed', 'go ahead',
|
||||
]
|
||||
|
||||
/** Normalized whole-utterance negative phrases (see normalizeTranscript). */
|
||||
export const REJECT_PHRASES: readonly string[] = [
|
||||
'拒绝', '取消', '不行', '不要', '不用', '不同意', '不批准', '不可以', '不允许', '不通过',
|
||||
'中断', '停止', '算了', '别', '不',
|
||||
'no', 'nope', 'reject', 'deny', 'cancel', 'abort', 'decline', 'stop',
|
||||
]
|
||||
|
||||
/** Leading tokens that negate an utterance; a match can never approve. */
|
||||
export const NEGATION_PREFIXES: readonly string[] = [
|
||||
'不', '别', '没', '未', '勿',
|
||||
'no', 'not', 'dont', 'cannot', 'wont', 'never',
|
||||
]
|
||||
|
||||
/** ASCII + CJK punctuation and quote marks, plus straight/curly apostrophes. */
|
||||
const PUNCTUATION_RE =
|
||||
/['’.,!?;:,。!?;:、「」『』“”‘’()()[\]{}【】〈〉《》…—\-_/\\|"`~@#$%^&*+=<>]/g
|
||||
|
||||
/**
|
||||
* `trim → toLowerCase → strip punctuation (so don't→dont) → collapse ASCII
|
||||
* whitespace → trim`. Keeps CJK + ASCII letters/digits.
|
||||
*/
|
||||
export function normalizeTranscript(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(PUNCTUATION_RE, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** True if a normalized transcript begins with a negation token (word-boundary for ASCII). */
|
||||
export function startsWithNegation(norm: string): boolean {
|
||||
return NEGATION_PREFIXES.some((prefix) => {
|
||||
if (!norm.startsWith(prefix)) return false
|
||||
// CJK negation particles (不/别/没/未/勿) have no word boundary, so any
|
||||
// following character still counts as negation. ASCII negators require a
|
||||
// word boundary after them, so "now" is NOT negated by the "no" prefix.
|
||||
if (!/^[a-z0-9]/.test(prefix)) return true
|
||||
const rest = norm.slice(prefix.length)
|
||||
return rest === '' || !/^[a-z0-9]/.test(rest)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-utterance-exact, reject-first, negation-guarded command match.
|
||||
* Falls through to `'text'` (ordinary dictation) for anything not an exact,
|
||||
* gate-eligible, confidence-sufficient command phrase.
|
||||
*/
|
||||
export function matchCommand(transcript: string, ctx: VoiceMatchContext): VoiceAction {
|
||||
const norm = normalizeTranscript(transcript)
|
||||
if (!ctx.pendingApproval || norm === '') return 'text'
|
||||
if (ctx.gate !== 'tool') return 'text'
|
||||
if (startsWithNegation(norm)) return REJECT_PHRASES.includes(norm) ? 'reject' : 'text'
|
||||
if (REJECT_PHRASES.includes(norm)) return 'reject'
|
||||
if (APPROVE_PHRASES.includes(norm)) {
|
||||
if (ctx.confidence !== undefined && ctx.confidence < MIN_APPROVE_CONFIDENCE) return 'text'
|
||||
return 'approve'
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
65
public/voice-confirm.ts
Normal file
65
public/voice-confirm.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* public/voice-confirm.ts — Approve confirm window controller (VC / V1b, decision B).
|
||||
*
|
||||
* PLAN_VOICE_COMMANDS.md §4: a spoken "approve" is irreversible (grants shell
|
||||
* command execution via the held permission gate), so instead of committing
|
||||
* immediately it arms a short cancellable window. `arm()` fires `onHeard`
|
||||
* synchronously (so the caller can show a "听到:批准" style affordance) and
|
||||
* starts a `windowMs` timer; if `cancel()` is not called first, `onCommit`
|
||||
* fires when the timer elapses.
|
||||
*
|
||||
* Pure timing logic — NO DOM here. The caller (tabs.ts, lane V2) owns the
|
||||
* overlay UI and calls `session.approve()` inside `onCommit`. Kept dependency-
|
||||
* free (setTimeout/clearTimeout only) so tests can use vi.useFakeTimers
|
||||
* without any DOM/WS coupling.
|
||||
*/
|
||||
|
||||
/** Default confirm-window duration in milliseconds (PLAN_VOICE_COMMANDS §4). */
|
||||
const DEFAULT_WINDOW_MS = 1500
|
||||
|
||||
/** A cancellable approve confirm window. Obtain via createApproveConfirm(). */
|
||||
export interface ApproveConfirm {
|
||||
/**
|
||||
* Arm the confirm window: calls `onHeard` immediately, then starts a
|
||||
* `windowMs` timer that calls `onCommit` unless `cancel()` runs first.
|
||||
* Re-arming (calling `arm` again while already armed) clears the prior
|
||||
* timer, so only the latest arm's `onCommit` can ever fire.
|
||||
*/
|
||||
arm(onHeard: () => void, onCommit: () => void): void
|
||||
/** Cancel the pending window, if any. No-op (does not throw) if not armed. */
|
||||
cancel(): void
|
||||
/** True while a confirm window is pending (armed and not yet committed/cancelled). */
|
||||
isArmed(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an approve confirm window controller.
|
||||
*
|
||||
* @param windowMs Duration of the cancellable window in ms. Default: 1500 (B).
|
||||
*/
|
||||
export function createApproveConfirm(windowMs: number = DEFAULT_WINDOW_MS): ApproveConfirm {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
return {
|
||||
arm(onHeard: () => void, onCommit: () => void): void {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
onHeard()
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
onCommit()
|
||||
}, windowMs)
|
||||
},
|
||||
|
||||
cancel(): void {
|
||||
if (timer === undefined) return
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
},
|
||||
|
||||
isArmed(): boolean {
|
||||
return timer !== undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,13 @@ function getSRConstructor(): SpeechRecognitionCtor | undefined {
|
||||
* Returns null when the browser does not support SpeechRecognition — the
|
||||
* caller should hide the mic UI in that case (AC-A2.3).
|
||||
*
|
||||
* @param onTranscript Receives the final recognised text (+ '\r' if autoSend).
|
||||
* @param onTranscript Receives the final recognised text (+ '\r' if autoSend),
|
||||
* plus the top alternative's ASR confidence (0-1) as an
|
||||
* optional 2nd arg when the engine reports one (VC decision D).
|
||||
* @param opts See VoiceInputOptions.
|
||||
*/
|
||||
export function createVoiceInput(
|
||||
onTranscript: (text: string) => void,
|
||||
onTranscript: (text: string, confidence?: number) => void,
|
||||
opts?: VoiceInputOptions,
|
||||
): VoiceInput | null {
|
||||
const SRClass = getSRConstructor()
|
||||
@@ -130,11 +132,17 @@ export function createVoiceInput(
|
||||
if (disposed) return
|
||||
let interimText = ''
|
||||
let finalText = ''
|
||||
let finalConfidence: number | undefined
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
const text = result?.[0]?.transcript ?? ''
|
||||
const alt = result?.[0]
|
||||
const text = alt?.transcript ?? ''
|
||||
if (result?.isFinal) {
|
||||
finalText += text
|
||||
// VC (decision D): forward the top alternative's confidence so the
|
||||
// caller can gate risk-asymmetric decisions (e.g. approve). Still
|
||||
// transcription-only — no decision-making happens in this module.
|
||||
finalConfidence = alt?.confidence ?? finalConfidence
|
||||
} else {
|
||||
interimText += text
|
||||
}
|
||||
@@ -143,7 +151,7 @@ export function createVoiceInput(
|
||||
opts.onInterim(interimText)
|
||||
}
|
||||
if (finalText) {
|
||||
onTranscript(opts?.autoSend ? finalText + '\r' : finalText)
|
||||
onTranscript(opts?.autoSend ? finalText + '\r' : finalText, finalConfidence)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user