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:
@@ -28,10 +28,6 @@ class BaseChannel(ABC):
|
||||
|
||||
name: str = "base"
|
||||
display_name: str = "Base"
|
||||
transcription_provider: str = "groq"
|
||||
transcription_api_key: str = ""
|
||||
transcription_api_base: str = ""
|
||||
transcription_language: str | None = None
|
||||
send_progress: bool = True
|
||||
send_tool_hints: bool = False
|
||||
show_reasoning: bool = True
|
||||
@@ -51,24 +47,14 @@ class BaseChannel(ABC):
|
||||
|
||||
async def transcribe_audio(self, file_path: str | Path) -> str:
|
||||
"""Transcribe an audio file via Whisper (OpenAI or Groq). Returns empty string on failure."""
|
||||
if not self.transcription_api_key:
|
||||
return ""
|
||||
try:
|
||||
if self.transcription_provider == "openai":
|
||||
from nanobot.providers.transcription import OpenAITranscriptionProvider
|
||||
provider = OpenAITranscriptionProvider(
|
||||
api_key=self.transcription_api_key,
|
||||
api_base=self.transcription_api_base or None,
|
||||
language=self.transcription_language or None,
|
||||
)
|
||||
else:
|
||||
from nanobot.providers.transcription import GroqTranscriptionProvider
|
||||
provider = GroqTranscriptionProvider(
|
||||
api_key=self.transcription_api_key,
|
||||
api_base=self.transcription_api_base or None,
|
||||
language=self.transcription_language or None,
|
||||
)
|
||||
return await provider.transcribe(file_path)
|
||||
from nanobot.audio.transcription import (
|
||||
resolve_transcription_config,
|
||||
transcribe_audio_file,
|
||||
)
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
return await transcribe_audio_file(file_path, resolve_transcription_config(load_config()))
|
||||
except Exception:
|
||||
self.logger.exception("Audio transcription failed")
|
||||
return ""
|
||||
|
||||
@@ -80,11 +80,6 @@ class ChannelManager:
|
||||
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
|
||||
from nanobot.channels.registry import discover_channel_names, discover_enabled
|
||||
|
||||
transcription_provider = self.config.channels.transcription_provider
|
||||
transcription_key = self._resolve_transcription_key(transcription_provider)
|
||||
transcription_base = self._resolve_transcription_base(transcription_provider)
|
||||
transcription_language = self.config.channels.transcription_language
|
||||
|
||||
# Collect enabled module names first, then only import those.
|
||||
# Channel configs live in ChannelsConfig's extra fields (via
|
||||
# extra="allow"), so we enumerate candidates from pkgutil scan
|
||||
@@ -135,10 +130,6 @@ class ChannelManager:
|
||||
)
|
||||
kwargs["gateway"] = gateway
|
||||
channel = cls(section, self.bus, **kwargs)
|
||||
channel.transcription_provider = transcription_provider
|
||||
channel.transcription_api_key = transcription_key
|
||||
channel.transcription_api_base = transcription_base
|
||||
channel.transcription_language = transcription_language
|
||||
channel.send_progress = self._resolve_bool_override(
|
||||
section, "send_progress", self.config.channels.send_progress,
|
||||
)
|
||||
@@ -155,24 +146,6 @@ class ChannelManager:
|
||||
|
||||
self._validate_allow_from()
|
||||
|
||||
def _resolve_transcription_key(self, provider: str) -> str:
|
||||
"""Pick the API key for the configured transcription provider."""
|
||||
try:
|
||||
if provider == "openai":
|
||||
return self.config.providers.openai.api_key
|
||||
return self.config.providers.groq.api_key
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def _resolve_transcription_base(self, provider: str) -> str:
|
||||
"""Pick the API base URL for the configured transcription provider."""
|
||||
try:
|
||||
if provider == "openai":
|
||||
return self.config.providers.openai.api_base or ""
|
||||
return self.config.providers.groq.api_base or ""
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def _validate_allow_from(self) -> None:
|
||||
for name, ch in self.channels.items():
|
||||
cfg = ch.config
|
||||
|
||||
@@ -45,6 +45,7 @@ from nanobot.webui.http_utils import (
|
||||
query_first as _query_first,
|
||||
)
|
||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
|
||||
|
||||
@@ -235,7 +236,7 @@ _VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({
|
||||
|
||||
_UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED
|
||||
|
||||
_DATA_URL_MIME_RE = re.compile(r"^data:([^;]+);base64,", re.DOTALL)
|
||||
_DATA_URL_MIME_RE = re.compile(r"^data:([^;,]+)(?:;[^,]*)*;base64,", re.DOTALL)
|
||||
|
||||
|
||||
def _extract_data_url_mime(url: str) -> str | None:
|
||||
@@ -419,7 +420,6 @@ class WebSocketChannel(BaseChannel):
|
||||
return None
|
||||
|
||||
# -- Server lifecycle and connection ingress ---------------------------
|
||||
# -- Server lifecycle and connection ingress ---------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||
@@ -703,6 +703,10 @@ class WebSocketChannel(BaseChannel):
|
||||
workspace_scope=scope.payload(),
|
||||
)
|
||||
return
|
||||
if t == "transcribe_audio":
|
||||
event, payload = await webui_transcription_event(envelope)
|
||||
await self._send_event(connection, event, **payload)
|
||||
return
|
||||
if t == "message":
|
||||
cid = envelope.get("chat_id")
|
||||
content = envelope.get("content")
|
||||
|
||||
Reference in New Issue
Block a user