feat(transcription): add shared voice input support (#4232)
* feat(webui): add voice transcription input * feat(webui): render ANSI output in code blocks * refactor(webui): isolate voice recorder logic * refactor(transcription): keep websocket ingress thin * refactor(transcription): resolve channel audio settings on demand * style(webui): neutralize voice waveform color * feat(webui): add voice input tooltip * feat(webui): add voice input keyboard shortcut * fix(webui): distinguish voice shortcut platforms * fix(webui): place voice button after model selector * refactor(webui): share voice hold recording helpers * fix(desktop): allow microphone voice input * fix(webui): stabilize token usage month labels * feat(webui): show voice input on settings overview * fix(webui): label voice capability as recognition * fix(webui): align capability overview status * refactor(webui): isolate transcription socket handling * fix(webui): soften silent voice waveform * refactor(audio): clarify transcription service location * docs(transcription): clarify audio and provider boundaries * fix(exec): reduce session output polling flake
This commit is contained in:
@@ -15,6 +15,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
from nanobot.audio.transcription import resolve_transcription_config
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.providers.image_generation import (
|
||||
@@ -90,6 +91,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
"2:3",
|
||||
"21:9",
|
||||
}
|
||||
_TRANSCRIPTION_PROVIDERS = ("groq", "openai")
|
||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
|
||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
@@ -576,6 +578,22 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
|
||||
return rows
|
||||
|
||||
|
||||
def _transcription_provider_rows(config: Any) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name in _TRANSCRIPTION_PROVIDERS:
|
||||
spec = find_by_name(name)
|
||||
provider_config = getattr(config.providers, name, None)
|
||||
rows.append({
|
||||
"name": name,
|
||||
"label": spec.label if spec is not None else name,
|
||||
"configured": bool(getattr(provider_config, "api_key", None)),
|
||||
"api_key_hint": _mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||
"api_base": getattr(provider_config, "api_base", None),
|
||||
"default_api_base": spec.default_api_base if spec and spec.default_api_base else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def settings_payload(
|
||||
*,
|
||||
requires_restart: bool = False,
|
||||
@@ -633,6 +651,7 @@ def settings_payload(
|
||||
|
||||
search_config = config.tools.web.search
|
||||
image_config = config.tools.image_generation
|
||||
transcription = resolve_transcription_config(config)
|
||||
search_provider = (
|
||||
search_config.provider
|
||||
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
||||
@@ -733,6 +752,16 @@ def settings_payload(
|
||||
"save_dir": image_config.save_dir,
|
||||
"providers": image_providers,
|
||||
},
|
||||
"transcription": {
|
||||
"enabled": transcription.enabled,
|
||||
"provider": transcription.provider,
|
||||
"provider_configured": transcription.configured,
|
||||
"model": transcription.model,
|
||||
"language": transcription.language,
|
||||
"max_duration_sec": transcription.max_duration_sec,
|
||||
"max_upload_mb": transcription.max_upload_mb,
|
||||
"providers": _transcription_provider_rows(config),
|
||||
},
|
||||
"runtime": {
|
||||
"config_path": str(get_config_path().expanduser()),
|
||||
"workspace_path": str(config.workspace_path),
|
||||
@@ -1311,3 +1340,71 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=changed)
|
||||
|
||||
|
||||
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
transcription = config.transcription
|
||||
changed = False
|
||||
|
||||
enabled = _query_first(query, "enabled")
|
||||
if enabled is not None:
|
||||
parsed_enabled = _parse_bool(enabled, "enabled")
|
||||
if transcription.enabled != parsed_enabled:
|
||||
transcription.enabled = parsed_enabled
|
||||
changed = True
|
||||
|
||||
provider = _query_first(query, "provider")
|
||||
if provider is not None:
|
||||
provider = provider.strip().lower()
|
||||
if provider not in _TRANSCRIPTION_PROVIDERS:
|
||||
raise WebUISettingsError("unknown transcription provider")
|
||||
if transcription.provider != provider:
|
||||
transcription.provider = provider # type: ignore[assignment]
|
||||
changed = True
|
||||
|
||||
model = _query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip() or None
|
||||
if model is not None and len(model) > 200:
|
||||
raise WebUISettingsError("transcription model is too long")
|
||||
if transcription.model != model:
|
||||
transcription.model = model
|
||||
changed = True
|
||||
|
||||
language = _query_first(query, "language")
|
||||
if language is not None:
|
||||
language = language.strip().lower() or None
|
||||
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
|
||||
raise WebUISettingsError("transcription language must be 2-3 lowercase letters")
|
||||
if transcription.language != language:
|
||||
transcription.language = language
|
||||
changed = True
|
||||
|
||||
max_duration_sec = _query_first_alias(query, "max_duration_sec", "maxDurationSec")
|
||||
if max_duration_sec is not None:
|
||||
try:
|
||||
parsed_duration = int(max_duration_sec)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_duration_sec must be an integer") from None
|
||||
if parsed_duration < 1 or parsed_duration > 600:
|
||||
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
|
||||
if transcription.max_duration_sec != parsed_duration:
|
||||
transcription.max_duration_sec = parsed_duration
|
||||
changed = True
|
||||
|
||||
max_upload_mb = _query_first_alias(query, "max_upload_mb", "maxUploadMb")
|
||||
if max_upload_mb is not None:
|
||||
try:
|
||||
parsed_upload = int(max_upload_mb)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_upload_mb must be an integer") from None
|
||||
if parsed_upload < 1 or parsed_upload > 100:
|
||||
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
|
||||
if transcription.max_upload_mb != parsed_upload:
|
||||
transcription.max_upload_mb = parsed_upload
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
Reference in New Issue
Block a user