Skip to content
Closed
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
39 changes: 38 additions & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { createVoiceInput, type VoiceErrorKind } from "./prompt-input/voice"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
Expand Down Expand Up @@ -1101,6 +1102,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}

const voice = createVoiceInput({
lang: () => language.locale(),
onFinal: (text) => {
addPart({ type: "text", content: text, start: 0, end: text.length })
},
onError: (kind) => {
const keys: Partial<Record<VoiceErrorKind, string>> = {
"not-allowed": "prompt.voice.error.notAllowed",
"no-speech": "prompt.voice.error.noSpeech",
"audio-capture": "prompt.voice.error.audioCapture",
network: "prompt.voice.error.network",
"service-not-allowed": "prompt.voice.error.serviceNotAllowed",
"language-not-supported": "prompt.voice.error.languageNotSupported",
unknown: "prompt.voice.error.generic",
}
const key = keys[kind]
if (!key) return
showToast({ variant: "error", title: language.t(key) })
},
})

const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
}
Expand Down Expand Up @@ -1501,7 +1523,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-voice"]')) {
return
}
editorRef?.focus()
Expand Down Expand Up @@ -1575,6 +1597,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>

<div class="flex items-center gap-1 pointer-events-auto">
<Show when={voice.supported() && store.mode === "normal"}>
<Tooltip placement="top" value={voice.listening() ? language.t("prompt.action.voiceStop") : language.t("prompt.action.voiceInput")}>
<IconButton
data-action="prompt-voice"
type="button"
variant={voice.listening() ? "primary" : "ghost"}
icon={voice.listening() ? "mic-active" : "mic"}
class="size-8"
aria-label={
voice.listening() ? language.t("prompt.action.voiceStop") : language.t("prompt.action.voiceInput")
}
onClick={() => voice.toggle()}
/>
</Tooltip>
</Show>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
Expand Down
175 changes: 175 additions & 0 deletions packages/app/src/components/prompt-input/voice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { createSignal, onCleanup } from "solid-js"
import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters"

export type VoiceErrorKind =
| "not-allowed"
| "no-speech"
| "audio-capture"
| "network"
| "service-not-allowed"
| "aborted"
| "language-not-supported"
| "unknown"

type SpeechRecognitionLike = {
lang: string
continuous: boolean
interimResults: boolean
onresult: ((event: any) => void) | null
onerror: ((event: any) => void) | null
onend: (() => void) | null
start: () => void
stop: () => void
abort: () => void
}

export type VoiceInputOptions = {
lang: () => string
onFinal: (text: string) => void
onError: (kind: VoiceErrorKind) => void
}

const LOCALE_TAGS: Record<string, string> = {
ru: "ru-RU",
uk: "uk-UA",
en: "en-US",
zh: "zh-CN",
zht: "zh-TW",
ko: "ko-KR",
de: "de-DE",
es: "es-ES",
fr: "fr-FR",
da: "da-DK",
ja: "ja-JP",
pl: "pl-PL",
ar: "ar-SA",
no: "nb-NO",
br: "pt-BR",
th: "th-TH",
bs: "bs-BA",
tr: "tr-TR",
hi: "hi-IN",
nl: "nl-NL",
id: "id-ID",
vi: "vi-VN",
it: "it-IT",
ur: "ur-PK",
pa: "pa-IN",
az: "az-AZ",
fi: "fi-FI",
sv: "sv-SE",
am: "am-ET",
cs: "cs-CZ",
hu: "hu-HU",
ro: "ro-RO",
ca: "ca-ES",
sk: "sk-SK",
}

const fallbackLang = () =>
typeof navigator !== "undefined" && typeof navigator.language === "string" && navigator.language
? navigator.language
: "en-US"

export function createVoiceInput(options: VoiceInputOptions) {
const supported =
typeof window !== "undefined" && typeof getSpeechRecognitionCtor<SpeechRecognitionLike>(window) !== "undefined"
const [listening, setListening] = createSignal(false)

let recognition: SpeechRecognitionLike | undefined
let active = false
let disposed = false

const speechLang = () => {
const locale = options.lang()
const base = locale.split("-")[0]?.toLowerCase() ?? ""
return LOCALE_TAGS[base] ?? fallbackLang()
}

const ensure = () => {
if (recognition) return recognition
const Ctor = getSpeechRecognitionCtor<SpeechRecognitionLike>(window)
if (!Ctor) throw new Error("Speech recognition is not supported")
const rec = new Ctor()
rec.lang = speechLang()
rec.continuous = true
rec.interimResults = true
rec.onresult = (event) => {
const results = event.results
for (let i = event.resultIndex; i < results.length; i++) {
const result = results[i]
if (!result.isFinal) continue
const text = String(result[0]?.transcript ?? "").trim()
if (text) options.onFinal(text)
}
}
rec.onerror = (event) => {
const kind = String(event.error ?? "unknown")
const interrupted = kind === "aborted" || kind === "no-speech"
active = false
setListening(false)
if (!disposed && !interrupted) options.onError(kind as VoiceErrorKind)
}
rec.onend = () => {
const wasActive = active
active = false
setListening(false)
if (wasActive && !disposed) {
try {
rec.start()
active = true
setListening(true)
} catch {
// restart failed, surface as generic error
options.onError("unknown")
}
}
}
recognition = rec
return rec
}

const start = () => {
if (!supported || active || disposed) return
try {
const rec = ensure()
active = true
setListening(true)
rec.start()
} catch {
active = false
setListening(false)
options.onError("unknown")
}
}

const stop = () => {
if (!recognition || !active) return
active = false
setListening(false)
try {
recognition.stop()
} catch {
try {
recognition.abort()
} catch {
// ignore
}
}
}

const toggle = () => {
if (active) {
stop()
return
}
start()
}

onCleanup(() => {
disposed = true
stop()
})

return { supported: () => supported, listening, toggle, start, stop }
}
Loading
Loading