mirror of
https://github.com/debpalash/VoiceStudio.git
synced 2026-10-02 01:26:35 +08:00
feat: complete workspace contributors, website previews and local agent interface
This commit is contained in:
@@ -9,6 +9,16 @@ metadata and the backend fallback mirror it.
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
- Ask VoiceStudio Agent adds chat, harness selection, feature presets, read-only planning and autopilot app actions without a source checkout (#2302)
|
||||
- Home credits contributors with over 10 commits in three responsive rows of round avatars stacked from right to left with an All contributors link, with GitHub and X links on Palash's hover card (#2302)
|
||||
- The VoiceStudio.sh Open Source title opens a website preview below the clicked item, within the right content area, with navigation and external-browser controls (#2302)
|
||||
- Home cards use feature-specific SVG icons with brief hover and keyboard-focus animations that respect reduced motion (#2302)
|
||||
- Renamed the Dub workspace to Dubbing across navigation, project labels, and keyboard shortcuts (#2302)
|
||||
- Home prioritizes creation tools with clearer descriptions and controls; the footer can collapse and reopen (#2302)
|
||||
- Top bar adds a GitHub Star shortcut beside Get Pro with a compact count refreshed every 20 minutes (#2302)
|
||||
- Footer adds one X button with follow cards for @idebpalash and @voicestudiosh plus a compact support-links menu beside Pro (#2302)
|
||||
- Enter Studio opens its optional permissions and shortcut settings expanded by default (#2129)
|
||||
- Model packs can be selected and installed before preset-compatible engines are active, with changes applied only when installing or using the chosen pack (#2129)
|
||||
- Electron is now the only desktop and web UI; the retired Tauri shell and legacy entry points are removed (#2343)
|
||||
- Docker and browser deployments now use the same maintained interface as the Electron desktop app (#2341)
|
||||
- Manage Projects with confirmed individual and bulk deletion, retry failed items, and keep exported files and render audio (#2333)
|
||||
@@ -26,6 +36,8 @@ metadata and the backend fallback mirror it.
|
||||
|
||||
### Added
|
||||
|
||||
- Choose 16/24/32-bit WAV precision, sampling effort and mastering in Clone and Design, with file sizes and optional audio checks (#2406)
|
||||
|
||||
- Run local narration, transcription, translation and voice-conversion recipes with resumable steps and WAV/TXT exports (#2333)
|
||||
|
||||
- Call agent backend: place or answer phone calls that hold a task conversation in your verified or designed voice, with an editable AI disclosure, take-over and an after-call summary (#2306)
|
||||
@@ -34,7 +46,11 @@ metadata and the backend fallback mirror it.
|
||||
- Linux AppImages include standard update information and a published `.zsync` file so AppImageUpdate and desktop managers can download only changed bytes (#2327) — thanks @shuvashish76!
|
||||
|
||||
### Changed
|
||||
- Sidebar keeps its quality slider, adds hardware-aware Auto with TTS-first model selection, and groups compact engine status with icon-based Simple, Models and Details views (#2396)
|
||||
|
||||
- LLM setup verifies a model response before enabling features, replaces the misleading engine inventory with connection guidance, and supports authenticated local servers (#2397)
|
||||
- LLM skills can use installed coding CLIs through the repair-agent runner, native paid providers through LiteLLM (#2397)
|
||||
- Dubbing supports Google Cloud Translation and Amazon Translate with explicit provider setup (#2397)
|
||||
- OmniVoice sidecars reuse installed speech recognition for short references without transcripts, matching in-process cloning (#2320)
|
||||
- Electron recovers from OS-denied default backend ports without changing explicitly configured ports (#2358) — thanks @rishi2288!
|
||||
- Home opens directly on project actions, and Integrations lists only connectors with completed in-app setup (#2351)
|
||||
@@ -57,6 +73,9 @@ metadata and the backend fallback mirror it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve float audio and requested seeds through OmniVoice/VoxCPM2 sidecars, and keep playback and saved WAV precision consistent (#2406)
|
||||
- Development launches rebuild main and preload changes so embedded website previews do not keep stale browser IPC after UI updates (#2302)
|
||||
|
||||
- macOS development launches use the maintained Electron version and icon paths (#2351)
|
||||
- Electron detects and repairs incomplete PyTorch, torchaudio and torchvision runtime wheels before backend startup (#2354) — thanks @jonathanmoronta1-lab!
|
||||
- Returning to local mode discards sessions from unsaved remote-backend connection tests (#2356)
|
||||
|
||||
@@ -870,8 +870,10 @@ async def dub_translate(req: TranslateRequest):
|
||||
# Preflight the optional `deep_translator` dep once so we fail with a
|
||||
# single actionable error instead of N identical per-segment
|
||||
# ModuleNotFoundErrors that flood the UI's error badge.
|
||||
from services.translation_apis import PAID_PROVIDERS, Translator as ApiTranslator
|
||||
try:
|
||||
import deep_translator # noqa: F401
|
||||
if provider not in PAID_PROVIDERS:
|
||||
import deep_translator # noqa: F401
|
||||
except ImportError:
|
||||
# Same single-source install command as the Engine selector's Install
|
||||
# button (translation_engines.install_command) — google/deepl/
|
||||
@@ -895,23 +897,11 @@ async def dub_translate(req: TranslateRequest):
|
||||
_msft_key = os.environ.get("MICROSOFT_API_KEY") or api_key
|
||||
|
||||
def _build_translator(src, tgt):
|
||||
if provider == "deepl":
|
||||
from deep_translator import DeeplTranslator
|
||||
tr = DeeplTranslator(api_key=_deepl_key, source=src, target=tgt, use_free_api=False)
|
||||
_custom = os.environ.get("DEEPL_BASE_URL")
|
||||
if _custom:
|
||||
tr._base_url = _custom.rstrip("/") + "/"
|
||||
return tr
|
||||
if provider in PAID_PROVIDERS:
|
||||
return ApiTranslator(provider, src, tgt, api_key=api_key)
|
||||
if provider == "mymemory":
|
||||
from deep_translator import MyMemoryTranslator
|
||||
return MyMemoryTranslator(source=src, target=tgt, proxies=_proxies)
|
||||
if provider == "microsoft":
|
||||
from deep_translator import MicrosoftTranslator
|
||||
tr = MicrosoftTranslator(api_key=_msft_key, source=src, target=tgt, proxies=_proxies)
|
||||
_custom = os.environ.get("MICROSOFT_BASE_URL")
|
||||
if _custom:
|
||||
tr._base_url = _custom.rstrip("/") + "/translate?api-version=3.0"
|
||||
return tr
|
||||
from deep_translator import GoogleTranslator
|
||||
return GoogleTranslator(source=src, target=tgt, proxies=_proxies)
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ def _family_payload(family: str, module):
|
||||
if family == "asr":
|
||||
model = asr_backend._offline_asr_repo(active)
|
||||
elif family == "llm" and active != "off":
|
||||
model = llm_backend.get_active_llm_backend().model_name
|
||||
from services import llm_providers
|
||||
provider = llm_providers.active_provider()
|
||||
model = llm_providers.configured_model(provider) if provider else None
|
||||
elif family == "tts":
|
||||
if active in {"omnivoice", "omnivoice-subprocess"}:
|
||||
from services.model_manager import resolve_omnivoice_checkpoint
|
||||
|
||||
@@ -14,7 +14,8 @@ import weakref
|
||||
from core.render_trace import timed as _render_timed
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from typing import Optional, Literal
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -27,7 +28,7 @@ from services.model_manager import (
|
||||
get_model, _gpu_pool, run_on_gpu_pool_guarded, GpuJobTimeoutError,
|
||||
GpuPoolBusyError,
|
||||
)
|
||||
from services.audio_io import _safe_torchaudio_save
|
||||
from services.generation_audio import save_generation_wav
|
||||
from services.binary_preflight import InvalidBinaryError
|
||||
from core import event_bus
|
||||
from core.render_trace import call as trace_call
|
||||
@@ -47,6 +48,22 @@ _ogg_state_lock = threading.Lock()
|
||||
_ogg_encode_locks = weakref.WeakValueDictionary()
|
||||
|
||||
|
||||
@router.get('/audio/{audio_id}/quality')
|
||||
def generated_audio_quality(audio_id: str):
|
||||
"""Analyze a generated WAV locally, off the async event loop."""
|
||||
from services.audio_quality import analyze_audio
|
||||
|
||||
if not re.fullmatch(r'[0-9a-f]{8}', audio_id):
|
||||
raise HTTPException(status_code=404, detail='Audio file not found')
|
||||
path = _safe_output_path(f'{audio_id}.wav')
|
||||
if path is None or not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail='Audio file not found')
|
||||
try:
|
||||
return analyze_audio(path)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise HTTPException(status_code=422, detail='Audio analysis unavailable') from None
|
||||
|
||||
|
||||
def _ogg_cache_key(path: str) -> tuple[str, int, int, int]:
|
||||
info = os.stat(path)
|
||||
return path, info.st_ino, info.st_mtime_ns, info.st_size
|
||||
@@ -1052,6 +1069,7 @@ def _run_backend_inference(
|
||||
native_proxy = bool(
|
||||
getattr(backend, "supports_native_omnivoice_controls", False)
|
||||
)
|
||||
forwards_seed = native_proxy or getattr(backend, "supports_generation_seed", False)
|
||||
if native_proxy:
|
||||
gen_kwargs.update({
|
||||
key: value for key, value in {
|
||||
@@ -1077,7 +1095,7 @@ def _run_backend_inference(
|
||||
# Per-span duration is left to the engine; an explicit overall
|
||||
# `duration` can't be meaningfully split across spans.
|
||||
span_kwargs = dict(gen_kwargs)
|
||||
if native_proxy and first_span and used_seed is not None:
|
||||
if forwards_seed and first_span and used_seed is not None:
|
||||
span_kwargs["seed"] = used_seed
|
||||
first_span = False
|
||||
return trace_call("synthesis", backend.generate, span_text, duration=None, **span_kwargs)
|
||||
@@ -1098,7 +1116,7 @@ def _run_backend_inference(
|
||||
if used_seed is not None:
|
||||
torch.manual_seed(used_seed + i)
|
||||
chunk_kwargs = dict(gen_kwargs)
|
||||
if native_proxy and used_seed is not None:
|
||||
if forwards_seed and used_seed is not None:
|
||||
chunk_kwargs["seed"] = used_seed + i
|
||||
parts.append(trace_call("synthesis", backend.generate,
|
||||
chunk_text, duration=None, **chunk_kwargs
|
||||
@@ -1108,7 +1126,7 @@ def _run_backend_inference(
|
||||
texts=text_chunks,
|
||||
sink=dropped_sink)
|
||||
else:
|
||||
if native_proxy and used_seed is not None:
|
||||
if forwards_seed and used_seed is not None:
|
||||
gen_kwargs["seed"] = used_seed
|
||||
audio_out = trace_call("synthesis", backend.generate, text, duration=duration, **gen_kwargs)
|
||||
|
||||
@@ -1302,7 +1320,7 @@ def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
|
||||
async def _finalize_generation(
|
||||
audio_tensor, sample_rate, *, text, history_mode, ref_audio_path,
|
||||
language, instruct, resolved_profile_id, used_seed, start_time,
|
||||
already_marked=False,
|
||||
already_marked=False, wav_bits=16,
|
||||
):
|
||||
"""Shared tail of a successful generation: watermark → save WAV →
|
||||
history row (self-healing) → retention prune → event emit.
|
||||
@@ -1341,7 +1359,7 @@ async def _finalize_generation(
|
||||
audio_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{audio_id}.wav"
|
||||
audio_path = os.path.join(OUTPUTS_DIR, audio_filename)
|
||||
_safe_torchaudio_save(audio_path, audio_tensor, sample_rate)
|
||||
save_generation_wav(audio_path, audio_tensor, sample_rate, bits=wav_bits)
|
||||
|
||||
audio_dur = round(audio_tensor.shape[-1] / sample_rate, 2)
|
||||
|
||||
@@ -1575,6 +1593,7 @@ async def generate_speech(
|
||||
profile_id: Optional[str] = Form(None),
|
||||
seed: Optional[int] = Form(None),
|
||||
effect_preset: str = Form("broadcast"),
|
||||
wav_bits: Literal["16", "24", "32"] = Form("16"),
|
||||
engine: Optional[str] = Form(None),
|
||||
# Wave 1.2 — unlimited-length generation: long text is split at sentence
|
||||
# boundaries and crossfaded. 0 disables chunking (whole text to engine).
|
||||
@@ -1985,6 +2004,7 @@ async def generate_speech(
|
||||
"max_chunk_chars": max_chunk_chars,
|
||||
"crossfade_ms": crossfade_ms,
|
||||
"effect_preset": effect_preset,
|
||||
"wav_bits": int(wav_bits),
|
||||
# The requesting user's provenance preference, not the GPU owner's.
|
||||
"watermark": bool(_watermark_enabled()),
|
||||
}
|
||||
@@ -2020,7 +2040,7 @@ async def generate_speech(
|
||||
on_state=on_state,
|
||||
)
|
||||
if getattr(waveform, "ndim", 2) == 1:
|
||||
# `_safe_torchaudio_save` and the local paths deal in
|
||||
# The canonical WAV writer and local paths deal in
|
||||
# (channels, samples); a mono artifact reads back flat.
|
||||
waveform = waveform.unsqueeze(0)
|
||||
return waveform, sample_rate
|
||||
@@ -2108,7 +2128,7 @@ async def generate_speech(
|
||||
audio_tensor, sample_rate, text=text, history_mode=history_mode,
|
||||
ref_audio_path=ref_audio_path, language=language,
|
||||
instruct=instruct, resolved_profile_id=resolved_profile_id,
|
||||
used_seed=used_seed, start_time=start_time, already_marked=True,
|
||||
used_seed=used_seed, start_time=start_time, already_marked=True, wav_bits=int(wav_bits),
|
||||
)
|
||||
# #1330's dropped-chunk warning has no remote carrier yet: the
|
||||
# gateway hands back audio, not the worker's render metadata.
|
||||
@@ -2229,11 +2249,14 @@ async def generate_speech(
|
||||
"layer_penalty_factor": layer_penalty_factor,
|
||||
"position_temperature": position_temperature,
|
||||
"class_temperature": class_temperature,
|
||||
"seed": used_seed + i if used_seed is not None else None,
|
||||
}.items() if value is not None
|
||||
} if getattr(
|
||||
_backend, "supports_native_omnivoice_controls", False
|
||||
) else {}),
|
||||
**({"seed": used_seed + i} if used_seed is not None and (
|
||||
getattr(_backend, "supports_native_omnivoice_controls", False)
|
||||
or getattr(_backend, "supports_generation_seed", False)
|
||||
) else {}),
|
||||
)
|
||||
sr = _backend.sample_rate
|
||||
skip = getattr(_backend, "applies_own_mastering", False)
|
||||
@@ -2427,7 +2450,7 @@ async def generate_speech(
|
||||
audio_tensor, sample_rate, text=text, history_mode=history_mode,
|
||||
ref_audio_path=ref_audio_path, language=language,
|
||||
instruct=instruct, resolved_profile_id=resolved_profile_id,
|
||||
used_seed=used_seed, start_time=start_time,
|
||||
used_seed=used_seed, start_time=start_time, wav_bits=int(wav_bits),
|
||||
)
|
||||
# #1330: before `done`, say what the take is missing. Its own
|
||||
# frame rather than a `done` field so a consumer that only
|
||||
@@ -2582,17 +2605,17 @@ async def generate_speech(
|
||||
audio_tensor, sample_rate, text=text, history_mode=history_mode,
|
||||
ref_audio_path=ref_audio_path, language=language, instruct=instruct,
|
||||
resolved_profile_id=resolved_profile_id, used_seed=used_seed,
|
||||
start_time=start_time, already_marked=_already_marked,
|
||||
start_time=start_time, already_marked=_already_marked, wav_bits=int(wav_bits),
|
||||
)
|
||||
audio_id = _meta["id"]
|
||||
audio_filename = _meta["filename"]
|
||||
audio_dur = _meta["duration"]
|
||||
gen_time = _meta["gen_time"]
|
||||
|
||||
buffer = io.BytesIO()
|
||||
_safe_torchaudio_save(buffer, audio_tensor, sample_rate, format="wav")
|
||||
buffer.seek(0)
|
||||
wav_bytes = buffer.read()
|
||||
# Playback, history, and downloads share the exact same encoded take.
|
||||
wav_bytes = await asyncio.to_thread(
|
||||
Path(OUTPUTS_DIR, audio_filename).read_bytes,
|
||||
)
|
||||
|
||||
async def _stream_wav():
|
||||
chunk_size = 16384
|
||||
|
||||
@@ -108,7 +108,7 @@ from services.performance_profiles import (
|
||||
|
||||
|
||||
class _PerformanceProfileBody(BaseModel):
|
||||
tier: str = Field(..., description="fast | balanced | quality | max")
|
||||
tier: str = Field(..., description="fast | balanced | quality | max | auto")
|
||||
family: str | None = Field(None, description="Engine family, or null to set the global tier")
|
||||
|
||||
|
||||
@@ -122,18 +122,23 @@ def get_performance_profile():
|
||||
|
||||
@router.put("/performance-profile")
|
||||
def set_performance_profile(body: _PerformanceProfileBody):
|
||||
"""Persist a performance preference and apply installed Max-capacity picks."""
|
||||
"""Persist a performance preference and apply installed compatible picks."""
|
||||
from core import prefs
|
||||
|
||||
tier = body.tier.strip().lower()
|
||||
if tier not in _PERFORMANCE_TIERS:
|
||||
if tier not in (*_PERFORMANCE_TIERS, "auto"):
|
||||
raise HTTPException(status_code=400, detail="Unknown performance tier")
|
||||
family = body.family.strip().lower() if body.family else None
|
||||
if family is not None and family not in _PERFORMANCE_FAMILIES:
|
||||
raise HTTPException(status_code=400, detail="Unknown engine family")
|
||||
if family is not None and tier == "auto":
|
||||
raise HTTPException(status_code=400, detail="Auto manages the whole device; use the global control")
|
||||
state = _performance_profile_state()
|
||||
applicable = state["applicable_families"]
|
||||
if (family is not None and family not in applicable) or (family is None and not applicable):
|
||||
# A global pack policy must be saved before its first models are installed.
|
||||
# Activation is installed-only; the installer reconciles the saved policy
|
||||
# as models become available. Family controls still need a usable engine.
|
||||
if family is not None and family not in applicable:
|
||||
raise HTTPException(status_code=409, detail="The selected engines do not support this performance preset")
|
||||
from core import job_store
|
||||
from api.routers.batch import list_batch_jobs
|
||||
@@ -145,7 +150,10 @@ def set_performance_profile(body: _PerformanceProfileBody):
|
||||
# choice, so a crash cannot leave half of a global change persisted.
|
||||
prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {"global": tier}, replace=True)
|
||||
else:
|
||||
prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {family: tier})
|
||||
stored = prefs.get(_PERFORMANCE_PROFILE_KEY, {})
|
||||
resolved = dict(stored.get("resolved", {})) if isinstance(stored, dict) else {}
|
||||
resolved.pop(family, None)
|
||||
prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {family: tier, "resolved": resolved})
|
||||
except Exception:
|
||||
logger.exception("set_performance_profile failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist performance profile")
|
||||
@@ -501,9 +509,10 @@ def set_llm_endpoint(body: _LLMEndpointBody):
|
||||
|
||||
Reuses the env-var persistence path (prefs.json, restored at startup):
|
||||
base_url -> TRANSLATE_BASE_URL, model -> TRANSLATE_MODEL,
|
||||
api_key -> TRANSLATE_API_KEY. A None field is left unchanged; an empty
|
||||
api_key -> encrypted TRANSLATE_API_KEY storage. A None field is left unchanged; an empty
|
||||
string clears it. Ollama ignores the key; vLLM / LM Studio require it.
|
||||
"""
|
||||
from services import settings_store
|
||||
from core.prefs import set_ as prefs_set, delete as prefs_delete
|
||||
|
||||
mapping = {
|
||||
@@ -517,10 +526,14 @@ def set_llm_endpoint(body: _LLMEndpointBody):
|
||||
val = val.strip()
|
||||
if val:
|
||||
os.environ[env_key] = val
|
||||
prefs_set(f"env.{env_key}", val)
|
||||
if env_key != "TRANSLATE_API_KEY":
|
||||
prefs_set(f"env.{env_key}", val)
|
||||
else:
|
||||
os.environ.pop(env_key, None)
|
||||
prefs_delete(f"env.{env_key}")
|
||||
if env_key == "TRANSLATE_API_KEY":
|
||||
settings_store.set_secret("translation_env.TRANSLATE_API_KEY", val or None)
|
||||
prefs_delete(f"env.{env_key}")
|
||||
# get_active_llm_backend() builds a fresh backend (and its OpenAI client
|
||||
# reads env at construction) on every call, so there's no singleton to
|
||||
# invalidate — the next translate/refine picks up the new values.
|
||||
@@ -538,6 +551,7 @@ class _LLMProviderBody(BaseModel):
|
||||
model: str | None = None
|
||||
account_id: str | None = Field(None, description="Cloudflare account id")
|
||||
make_active: bool = False
|
||||
activate_if_unset: bool = True # Legacy clients; editors opt out for save/test.
|
||||
|
||||
|
||||
class _LLMActiveBody(BaseModel):
|
||||
@@ -550,9 +564,11 @@ def list_llm_providers():
|
||||
|
||||
Never returns key material — only `has_key`/`key_from_env` booleans.
|
||||
"""
|
||||
from services import llm_providers
|
||||
from services import llm_providers, llm_backend
|
||||
return {
|
||||
"active": llm_providers.active_provider_id(),
|
||||
"engine_active": llm_backend.active_backend_id(),
|
||||
"engine_from_env": bool(os.environ.get("OMNIVOICE_LLM_BACKEND")),
|
||||
"providers": [llm_providers.describe(p) for p in llm_providers.all_providers()],
|
||||
}
|
||||
|
||||
@@ -567,6 +583,13 @@ def save_llm_provider(provider_id: str, body: _LLMProviderBody):
|
||||
p = llm_providers.get_provider(provider_id)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown provider {provider_id!r}")
|
||||
if not body.make_active and not body.activate_if_unset:
|
||||
from core import prefs
|
||||
from services import llm_backend
|
||||
# Persist the pre-save mode before adding a first cloud key. Otherwise
|
||||
# legacy key auto-detection would enable LLMs before Connect verifies it.
|
||||
if prefs.get("llm_backend") is None and not os.environ.get("OMNIVOICE_LLM_BACKEND"):
|
||||
prefs.set_("llm_backend", llm_backend.active_backend_id())
|
||||
if body.api_key is not None:
|
||||
llm_providers.save_key(provider_id, body.api_key.strip())
|
||||
llm_providers.save_overrides(
|
||||
@@ -580,8 +603,11 @@ def save_llm_provider(provider_id: str, body: _LLMProviderBody):
|
||||
# "Save" left nothing persisted to resolve. Gated on the STORED selection
|
||||
# only — an explicit prior choice is never stolen by a plain save, and an
|
||||
# unconfigured provider can't claim the slot.
|
||||
if body.make_active or (
|
||||
llm_providers.stored_active_provider_id() is None
|
||||
if body.make_active:
|
||||
_activate_llm_provider(provider_id)
|
||||
elif (
|
||||
body.activate_if_unset
|
||||
and llm_providers.stored_active_provider_id() is None
|
||||
and llm_providers.is_configured(p)
|
||||
):
|
||||
llm_providers.set_active_provider(provider_id)
|
||||
@@ -593,10 +619,50 @@ def set_active_llm_provider(body: _LLMActiveBody):
|
||||
from services import llm_providers
|
||||
if llm_providers.get_provider(body.provider) is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown provider {body.provider!r}")
|
||||
llm_providers.set_active_provider(body.provider)
|
||||
_activate_llm_provider(body.provider)
|
||||
return list_llm_providers()
|
||||
|
||||
|
||||
def _validate_llm_activation(provider_id: str) -> None:
|
||||
from services import llm_providers
|
||||
pin = llm_providers._active_env_pin()
|
||||
if (pin and pin != provider_id) or os.environ.get("OMNIVOICE_LLM_BACKEND") not in (None, "", "openai-compat"):
|
||||
raise HTTPException(status_code=409, detail="LLM selection is pinned by the environment.")
|
||||
p = llm_providers.get_provider(provider_id)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown LLM provider.")
|
||||
error = llm_providers.configuration_error(p)
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
|
||||
def _activate_llm_provider(provider_id: str) -> None:
|
||||
"""Explicit activation enables both provider and engine; pins still win."""
|
||||
from core import prefs
|
||||
from services import llm_providers
|
||||
_validate_llm_activation(provider_id)
|
||||
llm_providers.set_active_provider(provider_id)
|
||||
prefs.set_("llm_backend", "openai-compat")
|
||||
|
||||
|
||||
@router.post("/llm-providers/{provider_id}/connect")
|
||||
def connect_llm_provider(provider_id: str):
|
||||
"""Enable a provider only after it returns a usable completion."""
|
||||
from services import llm_providers
|
||||
p = llm_providers.get_provider(provider_id)
|
||||
_validate_llm_activation(provider_id)
|
||||
def configuration():
|
||||
return (llm_providers.resolve_base_url(p), llm_providers.configured_model(p),
|
||||
llm_providers.resolve_api_key(p))
|
||||
verified = configuration()
|
||||
result = test_llm_provider(provider_id)
|
||||
if result["ok"]:
|
||||
if configuration() != verified:
|
||||
return {"ok": False, "kind": "config"}
|
||||
_activate_llm_provider(provider_id)
|
||||
return result
|
||||
|
||||
|
||||
def _scrub_llm_detail(e: Exception, api_key: str | None) -> str:
|
||||
"""Scrubbed, UI-safe failure text. scrub_text() covers env secrets and
|
||||
home paths — but a STORE-persisted key isn't in the env, and some
|
||||
@@ -617,7 +683,8 @@ def _classify_llm_error(e: Exception) -> str:
|
||||
Status codes win when the OpenAI SDK provides one; exception-family
|
||||
names catch the non-HTTP failures (DNS, refused, TLS, timeout).
|
||||
"""
|
||||
status = getattr(e, "status_code", None)
|
||||
from urllib.error import HTTPError
|
||||
status = e.code if isinstance(e, HTTPError) else getattr(e, "status_code", None)
|
||||
if status in (401, 403):
|
||||
return "auth"
|
||||
if status == 404:
|
||||
@@ -625,7 +692,7 @@ def _classify_llm_error(e: Exception) -> str:
|
||||
if status == 429:
|
||||
return "rate_limit"
|
||||
name = type(e).__name__
|
||||
if name in ("APIConnectionError", "APITimeoutError", "ConnectError",
|
||||
if name in ("APIConnectionError", "APITimeoutError", "ConnectError", "URLError",
|
||||
"ConnectTimeout", "TimeoutError"):
|
||||
return "network"
|
||||
if name == "AuthenticationError":
|
||||
@@ -655,26 +722,32 @@ def test_llm_provider(provider_id: str):
|
||||
raise HTTPException(status_code=404, detail=f"unknown provider {provider_id!r}")
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
api_key = llm_providers.resolve_api_key(p)
|
||||
if not base_url:
|
||||
return {"ok": False, "kind": "config", "detail": "No Base URL set for this provider."}
|
||||
if not api_key:
|
||||
return {"ok": False, "kind": "config", "detail": "No API key configured for this provider."}
|
||||
error = llm_providers.configuration_error(p)
|
||||
if error:
|
||||
return {"ok": False, "kind": "config", "detail": error}
|
||||
t0 = _time.monotonic()
|
||||
try:
|
||||
from openai import OpenAI
|
||||
from httpx import Timeout
|
||||
from services.llm_transport import create_client
|
||||
# max_retries=0: this is an interactive probe with a live spinner — the
|
||||
# SDK's default 2 automatic retries turn a 429/timeout into a ~34s hang.
|
||||
# Surface the first failure immediately instead.
|
||||
client = OpenAI(api_key=api_key, base_url=base_url, max_retries=0)
|
||||
client = create_client(p)
|
||||
model = llm_providers.resolve_model(p)
|
||||
res = client.chat.completions.create(
|
||||
model=llm_providers.resolve_model(p),
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "Reply with the single word: ok"}],
|
||||
timeout=20,
|
||||
# Local runtimes may spend over 20s loading weights on first use.
|
||||
# Keep connection failures fast while allowing that cold start.
|
||||
timeout=Timeout(120 if p.local or p.transport == "cli" else 20, connect=5),
|
||||
)
|
||||
reply = (res.choices[0].message.content or "").strip()
|
||||
from services.llm_backend import _strip_reasoning
|
||||
reply = _strip_reasoning(res.choices[0].message.content or "")
|
||||
if not reply:
|
||||
raise ValueError("Provider returned no usable answer.")
|
||||
return {
|
||||
"ok": True,
|
||||
"model": llm_providers.resolve_model(p),
|
||||
"model": model,
|
||||
"reply": reply[:80],
|
||||
"latency_ms": int((_time.monotonic() - t0) * 1000),
|
||||
}
|
||||
@@ -729,9 +802,11 @@ def list_llm_provider_models(provider_id: str):
|
||||
p = llm_providers.get_provider(provider_id)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown provider {provider_id!r}")
|
||||
if p.transport != "openai":
|
||||
return {"ok": True, "models": [], "truncated": False}
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
api_key = llm_providers.resolve_api_key(p)
|
||||
if not base_url or not api_key:
|
||||
if llm_providers.configuration_error(p, require_model=False):
|
||||
return {"ok": False, "kind": "config", "models": []}
|
||||
try:
|
||||
from openai import OpenAI
|
||||
|
||||
@@ -1170,6 +1170,7 @@ PERSISTENT_KEYS = {
|
||||
"TRANSLATE_BASE_URL", "TRANSLATE_API_KEY", "TRANSLATE_MODEL",
|
||||
"DEEPL_API_KEY", "DEEPL_BASE_URL",
|
||||
"MICROSOFT_API_KEY", "MICROSOFT_BASE_URL",
|
||||
"GOOGLE_TRANSLATE_API_KEY", "MICROSOFT_REGION", "AWS_PROFILE", "AWS_REGION",
|
||||
# User-configurable network ports. Persisted so they survive restarts;
|
||||
# the Rust sidecar reads OMNIVOICE_PORT at startup and the backend derives
|
||||
# the LAN-share/UI ports from the others.
|
||||
@@ -1305,7 +1306,12 @@ async def set_env_var(body: dict):
|
||||
# startup in main.py). Non-persistent keys stay process-local.
|
||||
if key != "HF_TOKEN" and key in PERSISTENT_KEYS:
|
||||
prefs_key = f"env.{key}"
|
||||
if value:
|
||||
from services.translation_apis import SECRET_ENV_KEYS
|
||||
if key in SECRET_ENV_KEYS:
|
||||
from services import settings_store
|
||||
settings_store.set_secret("translation_env." + key, value or None)
|
||||
prefs_delete(prefs_key)
|
||||
elif value:
|
||||
prefs_set(prefs_key, value)
|
||||
else:
|
||||
prefs_delete(prefs_key)
|
||||
|
||||
@@ -139,6 +139,18 @@ def restore_env(data: dict) -> None:
|
||||
"""
|
||||
global _EXTERNALLY_PROVIDED
|
||||
_EXTERNALLY_PROVIDED = frozenset(os.environ.keys())
|
||||
from services.translation_apis import SECRET_ENV_KEYS
|
||||
from services import settings_store
|
||||
for key in SECRET_ENV_KEYS:
|
||||
legacy = data.get("env." + key)
|
||||
saved = settings_store.get_secret("translation_env." + key)
|
||||
if legacy and not saved:
|
||||
settings_store.set_secret("translation_env." + key, str(legacy))
|
||||
saved = str(legacy)
|
||||
if legacy:
|
||||
delete("env." + key)
|
||||
if saved:
|
||||
os.environ.setdefault(key, saved)
|
||||
for k, v in data.items():
|
||||
if not k.startswith("env.") or not v:
|
||||
continue
|
||||
|
||||
@@ -44,6 +44,8 @@ logger = logging.getLogger("omnivoice.omnivoice_subprocess")
|
||||
|
||||
class OmniVoiceSubprocessBackend(SubprocessBackend):
|
||||
"""The resident OmniVoice model in a killable sidecar process."""
|
||||
supports_float_transport = True
|
||||
supports_native_omnivoice_controls = True
|
||||
|
||||
id = "omnivoice-subprocess"
|
||||
display_name = "OmniVoice (subprocess-isolated, killable on timeout)"
|
||||
|
||||
@@ -187,7 +187,7 @@ def _load_model(stdout):
|
||||
return _model
|
||||
|
||||
|
||||
def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
|
||||
def _tensor_to_pcm_b64(audio, sample_rate: int, audio_format="s16le") -> tuple[str, int, int]:
|
||||
"""Convert a torch waveform tensor (1, N) in [-1, 1] to base64 int16 PCM."""
|
||||
import numpy as np
|
||||
|
||||
@@ -200,7 +200,10 @@ def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
|
||||
# not a downmix but a destroyed waveform. (#1328)
|
||||
arr = arr.mean(axis=int(np.argmin(arr.shape)))
|
||||
arr = np.clip(arr, -1.0, 1.0)
|
||||
pcm = (arr * 32767.0).astype(np.int16).tobytes()
|
||||
if audio_format not in ("s16le", "f32le"):
|
||||
raise ValueError("Unsupported audio transport format")
|
||||
pcm = (arr.astype("<f4").tobytes() if audio_format == "f32le"
|
||||
else (arr * 32767.0).astype("<i2").tobytes())
|
||||
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0])
|
||||
|
||||
|
||||
@@ -244,10 +247,12 @@ def _handle_synthesize(msg: dict, stdout) -> None:
|
||||
audio = audios[0] if isinstance(audios, (list, tuple)) else audios
|
||||
sample_rate = int(getattr(model, "sampling_rate", OMNIVOICE_SAMPLE_RATE))
|
||||
|
||||
pcm_b64, sr, n_samples = _tensor_to_pcm_b64(audio, sample_rate)
|
||||
audio_format = msg.get("audio_format", "s16le")
|
||||
pcm_b64, sr, n_samples = _tensor_to_pcm_b64(audio, sample_rate, audio_format)
|
||||
_send(stdout, {
|
||||
"op": "audio",
|
||||
"audio_pcm_b64": pcm_b64,
|
||||
"audio_format": audio_format,
|
||||
"sample_rate": sr,
|
||||
"n_samples": n_samples,
|
||||
})
|
||||
|
||||
@@ -38,6 +38,8 @@ def own_venv_python() -> "Path | None":
|
||||
|
||||
class VoxCPM2SubprocessBackend(SubprocessBackend):
|
||||
"""VoxCPM2 in a killable sidecar running the engine's own venv."""
|
||||
supports_float_transport = True
|
||||
supports_generation_seed = True
|
||||
|
||||
id = "voxcpm2"
|
||||
display_name = "VoxCPM2 (30 langs, studio 48 kHz, voice design)"
|
||||
|
||||
@@ -156,7 +156,7 @@ def _at_engine_rate(wav, sample_rate: int):
|
||||
return torchaudio.functional.resample(tensor, sample_rate, VOXCPM2_SAMPLE_RATE)
|
||||
|
||||
|
||||
def _to_pcm_b64(wav) -> tuple[str, int]:
|
||||
def _to_pcm_b64(wav, audio_format="s16le") -> tuple[str, int]:
|
||||
"""A float waveform in [-1, 1] (numpy or torch) as base64 int16 PCM."""
|
||||
import numpy as np # noqa: PLC0415
|
||||
|
||||
@@ -166,7 +166,10 @@ def _to_pcm_b64(wav) -> tuple[str, int]:
|
||||
if arr.ndim > 1:
|
||||
raise ValueError(f"expected mono audio (1-D after squeeze), got shape {arr.shape}")
|
||||
arr = np.clip(arr, -1.0, 1.0)
|
||||
pcm = (arr * 32767.0).astype(np.int16).tobytes()
|
||||
if audio_format not in ("s16le", "f32le"):
|
||||
raise ValueError("Unsupported audio transport format")
|
||||
pcm = (arr.astype("<f4").tobytes() if audio_format == "f32le"
|
||||
else (arr * 32767.0).astype("<i2").tobytes())
|
||||
return base64.b64encode(pcm).decode("ascii"), int(arr.shape[-1])
|
||||
|
||||
|
||||
@@ -215,12 +218,18 @@ def _handle_synthesize(msg: dict, stdout) -> None:
|
||||
"ref_audio must be a local file path; URLs are not accepted (local-first)."
|
||||
)
|
||||
model = _load_model(stdout)
|
||||
if msg.get("seed") is not None:
|
||||
import torch # noqa: PLC0415
|
||||
|
||||
torch.manual_seed(int(msg["seed"]))
|
||||
options = {key: value for key, value in msg.items() if key != "text"}
|
||||
wav = model.generate(**generation_kwargs(text, **options))
|
||||
pcm_b64, n_samples = _to_pcm_b64(_at_engine_rate(wav, _sample_rate(model)))
|
||||
audio_format = msg.get("audio_format", "s16le")
|
||||
pcm_b64, n_samples = _to_pcm_b64(_at_engine_rate(wav, _sample_rate(model)), audio_format)
|
||||
_send(stdout, {
|
||||
"op": "audio",
|
||||
"audio_pcm_b64": pcm_b64,
|
||||
"audio_format": audio_format,
|
||||
"sample_rate": VOXCPM2_SAMPLE_RATE,
|
||||
"n_samples": n_samples,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Read-only, model-free advisory checks; never a perceptual quality score."""
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AudioWarning(BaseModel):
|
||||
kind: Literal['empty', 'silence', 'quiet', 'loud', 'clipping', 'invalid']
|
||||
start: float
|
||||
end: float
|
||||
|
||||
|
||||
class AudioQuality(BaseModel):
|
||||
duration: float
|
||||
analyzed_seconds: float
|
||||
truncated: bool
|
||||
warnings: list[AudioWarning]
|
||||
|
||||
|
||||
def analyze_audio(path: str | Path, *, max_seconds: float = 7200) -> AudioQuality:
|
||||
"""Scan 100ms windows, bounded to two hours and 100 warnings.
|
||||
|
||||
RMS uses the loudest channel, not a phase-cancelling downmix. Relative
|
||||
volume checks exclude silence and use the median active-window level.
|
||||
One-second silence and 12dB sustained deviations are advisory only.
|
||||
"""
|
||||
levels, clips, invalid = [], [], []
|
||||
with sf.SoundFile(path) as source:
|
||||
duration = len(source) / source.samplerate
|
||||
if source.channels > 64 or source.samplerate > 768000:
|
||||
raise ValueError('Unsupported audio dimensions')
|
||||
window = max(1, round(source.samplerate * .1))
|
||||
limit = min(len(source), int(max(0, min(max_seconds, 7200)) * source.samplerate))
|
||||
read = 0
|
||||
while read < limit:
|
||||
data = source.read(min(window, limit - read), dtype='float32', always_2d=True)
|
||||
if not len(data):
|
||||
break
|
||||
read += len(data)
|
||||
finite = np.isfinite(data)
|
||||
invalid.append(not bool(finite.all()))
|
||||
data = np.where(finite, data, 0).astype(np.float64)
|
||||
clips.append(bool(np.count_nonzero(np.abs(data) >= .999) >= 3))
|
||||
rms = float(np.sqrt(np.max(np.mean(data * data, axis=0))))
|
||||
levels.append(20 * np.log10(max(rms, 1e-12)))
|
||||
analyzed = read / source.samplerate
|
||||
step = window / source.samplerate
|
||||
|
||||
warnings = []
|
||||
truncated = analyzed < duration
|
||||
if not levels:
|
||||
return AudioQuality(duration=duration, analyzed_seconds=analyzed,
|
||||
truncated=truncated, warnings=[AudioWarning(kind='empty', start=0, end=0)])
|
||||
values = np.asarray(levels)
|
||||
active = values[values > -50]
|
||||
baseline = float(np.median(active)) if len(active) else -50
|
||||
checks = [
|
||||
('invalid', np.asarray(invalid), 0),
|
||||
('clipping', np.asarray(clips), 0),
|
||||
('silence', values <= -50, 0 if not len(active) else 1),
|
||||
('quiet', (values > -50) & (values < baseline - 12), .5),
|
||||
('loud', values > baseline + 12, .5),
|
||||
]
|
||||
for kind, mask, minimum in checks:
|
||||
edges = np.diff(np.concatenate(([False], mask, [False])).astype(int))
|
||||
for start, end in zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1)):
|
||||
begin, finish = start * step, min(end * step, analyzed)
|
||||
if finish - begin + 1e-8 < minimum:
|
||||
continue
|
||||
if len(warnings) >= 100:
|
||||
truncated = True
|
||||
break
|
||||
warnings.append(AudioWarning(kind=kind, start=round(begin, 3), end=round(finish, 3)))
|
||||
warnings.sort(key=lambda item: (item.start, item.kind))
|
||||
return AudioQuality(duration=duration, analyzed_seconds=analyzed,
|
||||
truncated=truncated, warnings=warnings)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Deterministic WAV encoding of the final, already provenance-marked take."""
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from services.audio_io import _describe_write_failure, _safe_soundfile_write
|
||||
|
||||
WAV_SUBTYPES = {16: "PCM_16", 24: "PCM_24", 32: "FLOAT"}
|
||||
|
||||
|
||||
def save_generation_wav(path, audio: torch.Tensor, sample_rate: int, bits: int = 16) -> None:
|
||||
"""Preserve the selected precision independently of the torchaudio backend.
|
||||
|
||||
TorchCodec-backed torchaudio can ignore encoding/bit-depth arguments. Write
|
||||
the selected subtype explicitly with our audited SoundFile writer instead.
|
||||
This is encoding only: callers must pass through mark_synthetic first.
|
||||
"""
|
||||
if bits not in WAV_SUBTYPES:
|
||||
raise ValueError("WAV precision must be 16, 24, or 32 bits")
|
||||
audio = audio.detach().cpu()
|
||||
if audio.ndim == 1:
|
||||
audio = audio.unsqueeze(0)
|
||||
if audio.ndim != 2 or not audio.numel():
|
||||
raise ValueError("Expected non-empty channel-first audio")
|
||||
if audio.dtype in (torch.int16, torch.int32):
|
||||
scale = 32768.0 if audio.dtype == torch.int16 else 2147483648.0
|
||||
audio = audio.to(torch.float32) / scale
|
||||
samples = audio.to(torch.float32).transpose(0, 1).contiguous().numpy().copy()
|
||||
if not np.isfinite(samples).all():
|
||||
raise ValueError("Audio contains non-finite samples")
|
||||
try:
|
||||
_safe_soundfile_write(path, samples, sample_rate, subtype=WAV_SUBTYPES[bits], format="WAV")
|
||||
except Exception as exc:
|
||||
raise _describe_write_failure(exc, path) from exc
|
||||
@@ -194,10 +194,9 @@ class OpenAICompatBackend(LLMBackend):
|
||||
"(OpenAI/OpenRouter/OrcaRouter/Groq/… or a local Ollama), or set "
|
||||
"TRANSLATE_BASE_URL (+ TRANSLATE_API_KEY)."
|
||||
)
|
||||
if not llm_providers.resolve_base_url(p):
|
||||
return False, f"{p.display_name}: set a Base URL in Settings → LLM Providers."
|
||||
if not llm_providers.has_key(p):
|
||||
return False, f"{p.display_name}: add an API key in Settings → LLM Providers."
|
||||
error = llm_providers.configuration_error(p)
|
||||
if error:
|
||||
return False, error
|
||||
return True, f"ready ({p.display_name})"
|
||||
|
||||
@property
|
||||
@@ -211,21 +210,12 @@ class OpenAICompatBackend(LLMBackend):
|
||||
def _get_client(self):
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
from openai import OpenAI
|
||||
from services import llm_providers
|
||||
p = self._resolve_provider()
|
||||
if p is None:
|
||||
raise RuntimeError("LLM not configured. See `is_available()` for the hint.")
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
api_key = llm_providers.resolve_api_key(p)
|
||||
if not api_key:
|
||||
raise RuntimeError("LLM not configured. See `is_available()` for the hint.")
|
||||
kw = {"api_key": api_key}
|
||||
if base_url:
|
||||
kw["base_url"] = base_url
|
||||
# max_retries=0 so a 429 + Retry-After can't make one chat() sleep
|
||||
# through the Autofit fit-pass wall-clock budget (speech_rate).
|
||||
self._client = OpenAI(max_retries=0, **kw)
|
||||
from services.llm_transport import create_client
|
||||
self._client = create_client(p)
|
||||
return self._client
|
||||
|
||||
def chat(self, *, system: str, user: str, timeout: Optional[float] = None,
|
||||
@@ -414,7 +404,7 @@ def _provider_hint(bid: str) -> str | None:
|
||||
p = llm_providers.active_provider()
|
||||
if p is None:
|
||||
return None
|
||||
model = llm_providers.resolve_model(p)
|
||||
model = llm_providers.configured_model(p)
|
||||
return f"{p.display_name} · {model}" if model else p.display_name
|
||||
except Exception:
|
||||
# The hint is decoration; a provider-registry hiccup must not take
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Text completion via Electron's existing repair/translation agent runner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def executable(agent: str) -> bool:
|
||||
# Electron owns discovery (including Windows npm shims) and process lifetime.
|
||||
try:
|
||||
return agent in json.loads(os.environ.get("VOICESTUDIO_LLM_AGENTS", "[]"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def completion(provider, *, model, messages, timeout=120, stream=False, **_):
|
||||
base = os.environ.get("VOICESTUDIO_LLM_AGENT_URL", "")
|
||||
token = os.environ.get("VOICESTUDIO_LLM_AGENT_TOKEN", "")
|
||||
url = urlsplit(base)
|
||||
if url.scheme != "http" or url.hostname != "127.0.0.1" or not token:
|
||||
raise RuntimeError("CLI providers require the running Electron desktop.")
|
||||
seconds = float(getattr(timeout, "read", timeout) or 120)
|
||||
payload = {"agent": provider.sdk_provider, "model": model, "messages": messages,
|
||||
"timeoutMs": min(600_000, max(1_000, int(seconds * 1000)))}
|
||||
request = urllib.request.Request(base + "/complete", data=json.dumps(payload).encode(),
|
||||
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
|
||||
# Never send the local capability to an ambient proxy or follow redirects.
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
with opener.open(request, timeout=seconds + 2) as response:
|
||||
body = json.loads(response.read(1_000_001))
|
||||
text = body.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("The CLI returned no usable answer.")
|
||||
if stream:
|
||||
return iter([SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=text))])])
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=text))])
|
||||
@@ -1,11 +1,8 @@
|
||||
"""LLM provider registry — the OpenAI-compatible providers VoiceStudio can use
|
||||
for Cinematic / Autofit translation (and any future LLM feature).
|
||||
"""LLM provider registry for every VoiceStudio LLM skill.
|
||||
|
||||
Every provider here speaks the OpenAI chat-completions shape, so a single
|
||||
client (`llm_backend.OpenAICompatBackend`) drives all of them — the only
|
||||
per-provider differences are ``base_url``, ``model``, and the API key. This
|
||||
module is the one place that knows those defaults and resolves the live value
|
||||
for the *active* provider.
|
||||
OpenAI-compatible, native SDK and desktop CLI transports share the same
|
||||
completion interface. This module resolves configuration without contacting
|
||||
providers or starting agents; llm_transport handles explicit inference calls.
|
||||
|
||||
Resolution precedence for every field (key / base_url / model), highest first:
|
||||
1. Environment variable — power-user / `.env` override, wins always.
|
||||
@@ -64,6 +61,8 @@ class Provider:
|
||||
account_env: Optional[str] = None
|
||||
signup_url: str = ""
|
||||
notes: str = ""
|
||||
transport: str = "openai"
|
||||
sdk_provider: str = ""
|
||||
|
||||
|
||||
# Order here is the display order in the settings page. OpenAI first (the
|
||||
@@ -98,7 +97,7 @@ _PROVIDERS: tuple[Provider, ...] = (
|
||||
notes="Fastest Llama inference. Free tier."),
|
||||
Provider("google-ai", "Google AI (Gemini)",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-3.8-flash",
|
||||
key_envs=("GOOGLE_AI_API_KEY",), base_url_env="GOOGLE_AI_BASE_URL",
|
||||
model_env="GOOGLE_AI_MODEL",
|
||||
signup_url="https://aistudio.google.com/app/apikey",
|
||||
@@ -149,7 +148,7 @@ _PROVIDERS: tuple[Provider, ...] = (
|
||||
model_env="SILICONFLOW_MODEL", signup_url="https://siliconflow.com",
|
||||
notes="Qwen/DeepSeek and more. Strong for CJK."),
|
||||
Provider("ollama", "Ollama (local)", "http://localhost:11434/v1",
|
||||
"llama3.1", local=True,
|
||||
"llama3.1", local=True, key_envs=("OLLAMA_API_KEY",),
|
||||
base_url_env="OLLAMA_BASE_URL", model_env="OLLAMA_MODEL",
|
||||
signup_url="https://ollama.com",
|
||||
notes="Fully offline. Run `ollama pull llama3.1` first."),
|
||||
@@ -159,10 +158,49 @@ _PROVIDERS: tuple[Provider, ...] = (
|
||||
# a real name people actually pull) worked on the same machine (#1332).
|
||||
# resolve_model asks the server instead of shipping a guess.
|
||||
Provider("lmstudio", "LM Studio (local)", "http://localhost:1234/v1",
|
||||
"local-model", local=True, model_is_placeholder=True,
|
||||
"local-model", local=True, model_is_placeholder=True, key_envs=("LMSTUDIO_API_KEY",),
|
||||
base_url_env="LMSTUDIO_BASE_URL", model_env="LMSTUDIO_MODEL",
|
||||
signup_url="https://lmstudio.ai",
|
||||
notes="Fully offline. Start the LM Studio local server and load a model."),
|
||||
Provider("anthropic", "Anthropic (Claude)", "", "claude-sonnet-4-6",
|
||||
key_envs=("ANTHROPIC_API_KEY",), model_env="ANTHROPIC_MODEL",
|
||||
transport="sdk", sdk_provider="anthropic", signup_url="https://console.anthropic.com"),
|
||||
Provider("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat",
|
||||
key_envs=("DEEPSEEK_API_KEY",), model_env="DEEPSEEK_MODEL"),
|
||||
Provider("xai", "xAI (Grok)", "https://api.x.ai/v1", "grok-4",
|
||||
key_envs=("XAI_API_KEY",), model_env="XAI_MODEL"),
|
||||
Provider("together", "Together AI", "https://api.together.xyz/v1", "",
|
||||
key_envs=("TOGETHER_API_KEY",), model_env="TOGETHER_MODEL"),
|
||||
Provider("fireworks", "Fireworks AI", "https://api.fireworks.ai/inference/v1", "",
|
||||
key_envs=("FIREWORKS_API_KEY",), model_env="FIREWORKS_MODEL"),
|
||||
Provider("perplexity", "Perplexity", "https://api.perplexity.ai", "sonar",
|
||||
key_envs=("PERPLEXITY_API_KEY",), model_env="PERPLEXITY_MODEL"),
|
||||
Provider("qwen", "Alibaba Cloud (Qwen)", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus",
|
||||
key_envs=("DASHSCOPE_API_KEY",), model_env="QWEN_MODEL"),
|
||||
Provider("moonshot", "Moonshot (Kimi)", "https://api.moonshot.ai/v1", "",
|
||||
key_envs=("MOONSHOT_API_KEY",), model_env="MOONSHOT_MODEL"),
|
||||
Provider("minimax", "MiniMax", "https://api.minimax.io/v1", "",
|
||||
key_envs=("MINIMAX_API_KEY",), model_env="MINIMAX_MODEL"),
|
||||
Provider("zai", "Z.AI (GLM)", "https://api.z.ai/api/paas/v4", "",
|
||||
key_envs=("ZAI_API_KEY",), model_env="ZAI_MODEL"),
|
||||
Provider("azure", "Azure OpenAI", "", "",
|
||||
key_envs=("AZURE_API_KEY",), base_url_env="AZURE_OPENAI_BASE_URL", model_env="AZURE_DEPLOYMENT"),
|
||||
Provider("bedrock", "Amazon Bedrock", "", "",
|
||||
key_envs=("AWS_BEARER_TOKEN_BEDROCK",), model_env="BEDROCK_MODEL",
|
||||
key_optional=True, transport="sdk", sdk_provider="bedrock"),
|
||||
Provider("vertex", "Google Vertex AI", "", "",
|
||||
model_env="VERTEX_MODEL", key_optional=True, transport="sdk", sdk_provider="vertex_ai",
|
||||
needs_account=True, account_env="VERTEXAI_PROJECT"),
|
||||
Provider("sdk", "LiteLLM (other providers)", "", "",
|
||||
key_envs=("LITELLM_API_KEY",), model_env="LITELLM_MODEL", transport="sdk"),
|
||||
Provider("claude-code", "Claude Code (CLI)", "", "",
|
||||
transport="cli", sdk_provider="claude", model_env="VOICESTUDIO_CLAUDE_MODEL"),
|
||||
Provider("codex-cli", "Codex (CLI)", "", "",
|
||||
transport="cli", sdk_provider="codex", model_env="VOICESTUDIO_CODEX_MODEL"),
|
||||
Provider("pi-cli", "Pi (CLI)", "", "",
|
||||
transport="cli", sdk_provider="pi", model_env="VOICESTUDIO_PI_MODEL"),
|
||||
Provider("opencode-cli", "OpenCode (CLI)", "", "",
|
||||
transport="cli", sdk_provider="opencode", model_env="VOICESTUDIO_OPENCODE_MODEL"),
|
||||
Provider("custom", "Custom (OpenAI-compatible)", "", "",
|
||||
key_envs=("TRANSLATE_API_KEY",), base_url_env="TRANSLATE_BASE_URL",
|
||||
model_env="TRANSLATE_MODEL", key_optional=True,
|
||||
@@ -398,6 +436,21 @@ def discover_model(p: Provider) -> Optional[str]:
|
||||
return chosen
|
||||
|
||||
|
||||
def configured_model(p: Provider) -> str:
|
||||
"""Offline model setting for forms and inventory; blank means discovery.
|
||||
|
||||
Never put discovered IDs or the old LM Studio placeholder into an editable
|
||||
form: saving that form would freeze automatic discovery to a stale model.
|
||||
"""
|
||||
from services import settings_store
|
||||
value = (
|
||||
(p.model_env and os.environ.get(p.model_env))
|
||||
or settings_store.get_text(_MODEL_KEY + p.id)
|
||||
or p.default_model
|
||||
).strip()
|
||||
return "" if p.model_is_placeholder and value == p.default_model else value
|
||||
|
||||
|
||||
def resolve_model(p: Provider) -> str:
|
||||
"""Env override → stored override → discovered → default.
|
||||
|
||||
@@ -412,11 +465,7 @@ def resolve_model(p: Provider) -> str:
|
||||
from here would also re-open the per-segment cost the discovery cache
|
||||
exists to prevent (a 200-segment dub × one HTTP round trip each).
|
||||
"""
|
||||
from services import settings_store
|
||||
explicit = (
|
||||
(p.model_env and os.environ.get(p.model_env))
|
||||
or settings_store.get_text(_MODEL_KEY + p.id)
|
||||
)
|
||||
explicit = configured_model(p)
|
||||
if explicit:
|
||||
return explicit
|
||||
if p.model_is_placeholder:
|
||||
@@ -435,7 +484,7 @@ def resolve_api_key(p: Provider) -> Optional[str]:
|
||||
stored = settings_store.get_secret(SECRET_PREFIX + p.id)
|
||||
if stored:
|
||||
return stored
|
||||
if p.local or (p.key_optional and resolve_base_url(p)):
|
||||
if p.local or (p.transport == "openai" and p.key_optional and resolve_base_url(p)):
|
||||
return "local" # self-hosted OpenAI-compatible servers ignore the key
|
||||
return None
|
||||
|
||||
@@ -446,7 +495,7 @@ def has_key(p: Provider) -> bool:
|
||||
return True
|
||||
if _env_first(p.key_envs) or _key_in_store(p.id):
|
||||
return True
|
||||
return bool(p.key_optional and resolve_base_url(p))
|
||||
return bool(p.transport == "openai" and p.key_optional and resolve_base_url(p))
|
||||
|
||||
|
||||
def _key_in_store(pid: str) -> bool:
|
||||
@@ -454,11 +503,53 @@ def _key_in_store(pid: str) -> bool:
|
||||
return (SECRET_PREFIX + pid) in settings_store.list_secret_names()
|
||||
|
||||
|
||||
def configuration_error(p: Provider, *, require_model: bool = True) -> Optional[str]:
|
||||
"""Check local configuration only; never claim a successful network probe."""
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
if p.transport == "cli":
|
||||
from services.llm_cli import executable
|
||||
return None if executable(p.sdk_provider) else "Install and sign in to the selected CLI, then restart VoiceStudio."
|
||||
if p.transport == "sdk":
|
||||
import importlib.util
|
||||
if importlib.util.find_spec("litellm") is None:
|
||||
return "Install the LiteLLM SDK in the VoiceStudio backend."
|
||||
if require_model and not configured_model(p):
|
||||
return "Set the Model in Settings > Models > LLM."
|
||||
if p.id == "sdk" and require_model and "/" not in configured_model(p):
|
||||
return "Use a provider/model identifier for LiteLLM."
|
||||
if p.id == "vertex":
|
||||
if not resolve_account_id(p):
|
||||
return "Set the Vertex AI project as Account ID and configure Google application credentials."
|
||||
elif p.id not in {"sdk", "bedrock"} and not has_key(p):
|
||||
return "Add an API key in Settings > Models > LLM."
|
||||
# Generic SDK and Bedrock also support provider environment keys and
|
||||
# workload identities. The explicit Connect request validates them;
|
||||
# catalogue/config checks must not initiate identity-network probes.
|
||||
if not resolve_base_url(p):
|
||||
return None
|
||||
|
||||
raw_url = resolve_base_url(p, substitute=False)
|
||||
if "{account_id}" in raw_url and not resolve_account_id(p).strip():
|
||||
return "Set the Account ID in Settings > Models > LLM."
|
||||
try:
|
||||
url = urlsplit(resolve_base_url(p))
|
||||
valid_url = url.scheme in {"http", "https"} and bool(url.hostname)
|
||||
# Access validates malformed/out-of-range ports too.
|
||||
_ = url.port
|
||||
except ValueError:
|
||||
valid_url = False
|
||||
if not valid_url:
|
||||
return "Set a valid HTTP(S) Base URL in Settings > Models > LLM."
|
||||
if p.transport == "openai" and not has_key(p):
|
||||
return "Add an API key in Settings > Models > LLM."
|
||||
if require_model and not p.model_is_placeholder and not configured_model(p):
|
||||
return "Set the Model in Settings > Models > LLM."
|
||||
return None
|
||||
|
||||
|
||||
def is_configured(p: Provider) -> bool:
|
||||
"""Usable end-to-end: has a base_url (custom needs one set) and a key."""
|
||||
if not resolve_base_url(p):
|
||||
return False
|
||||
return has_key(p)
|
||||
return configuration_error(p) is None
|
||||
|
||||
|
||||
# ── Active provider selection ─────────────────────────────────────────────
|
||||
@@ -496,7 +587,7 @@ def active_provider_id() -> Optional[str]:
|
||||
# LM Studio) are *always* "configured" (no key needed) but we must NOT
|
||||
# assume their server is running — they require an explicit selection.
|
||||
for p in _PROVIDERS:
|
||||
if not p.local and is_configured(p):
|
||||
if not p.local and p.transport != "cli" and is_configured(p):
|
||||
return p.id
|
||||
return None
|
||||
|
||||
@@ -569,16 +660,23 @@ def describe(p: Provider) -> dict:
|
||||
"id": p.id,
|
||||
"display_name": p.display_name,
|
||||
"local": p.local,
|
||||
"transport": p.transport,
|
||||
"supports_model_listing": p.transport == "openai",
|
||||
"needs_account": p.needs_account,
|
||||
"signup_url": p.signup_url,
|
||||
"notes": p.notes,
|
||||
"base_url": resolve_base_url(p, substitute=False),
|
||||
"model": resolve_model(p),
|
||||
"model": configured_model(p),
|
||||
"has_key": has_key(p),
|
||||
"has_api_key": bool(_env_first(p.key_envs) or _key_in_store(p.id)),
|
||||
"key_from_env": bool(_env_first(p.key_envs)),
|
||||
"base_url_from_env": bool(p.base_url_env and os.environ.get(p.base_url_env)),
|
||||
"model_from_env": bool(p.model_env and os.environ.get(p.model_env)),
|
||||
"active_from_env": _active_env_pin() is not None,
|
||||
"activation_blocked": bool(
|
||||
(_active_env_pin() and _active_env_pin() != p.id)
|
||||
or os.environ.get("OMNIVOICE_LLM_BACKEND") not in (None, "", "openai-compat")
|
||||
),
|
||||
"configured": is_configured(p),
|
||||
}
|
||||
if p.needs_account:
|
||||
|
||||
@@ -174,7 +174,12 @@ def resolve_skill(skill_id: str) -> SkillResolution:
|
||||
provider = llm_providers.active_provider()
|
||||
source = "active" if provider is not None else "none"
|
||||
|
||||
if not enabled:
|
||||
from core import prefs
|
||||
backend_pin = os.environ.get("OMNIVOICE_LLM_BACKEND")
|
||||
backend_off = backend_pin == "off" or (
|
||||
not backend_pin and not override and prefs.get("llm_backend") == "off"
|
||||
)
|
||||
if not enabled or backend_off:
|
||||
ready, reason = False, "disabled"
|
||||
elif provider is None:
|
||||
ready, reason = False, "no_provider"
|
||||
@@ -230,13 +235,6 @@ def resolve_skill_client(skill_id: str) -> Optional[SkillClient]:
|
||||
return None
|
||||
from services import llm_providers
|
||||
|
||||
api_key = llm_providers.resolve_api_key(res.provider)
|
||||
if not api_key:
|
||||
return None
|
||||
kw: dict[str, Any] = {"api_key": api_key}
|
||||
base_url = llm_providers.resolve_base_url(res.provider)
|
||||
if base_url:
|
||||
kw["base_url"] = base_url
|
||||
# max_retries=0: a rate-limited provider returning 429 + a long Retry-After
|
||||
# would otherwise let the SDK sleep+retry inside a single call, blowing the
|
||||
# skill's wall-clock budget (the cinematic pass budget, the glossary call
|
||||
@@ -250,7 +248,8 @@ def resolve_skill_client(skill_id: str) -> Optional[SkillClient]:
|
||||
# The contract here is already "None == LLM unavailable, degrade" — a bad
|
||||
# proxy env must degrade the skill, never 500 the calling feature.
|
||||
try:
|
||||
client = OpenAI(max_retries=0, **kw)
|
||||
from services.llm_transport import create_client
|
||||
client = create_client(res.provider)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"LLM client construction failed for skill %s (provider %s): %s — "
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Provider SDK adapters. Importing the catalogue never imports cloud SDKs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def create_client(provider):
|
||||
from services import llm_providers as registry
|
||||
if provider.transport == "openai":
|
||||
from openai import OpenAI
|
||||
return OpenAI(api_key=registry.resolve_api_key(provider),
|
||||
base_url=registry.resolve_base_url(provider), max_retries=0)
|
||||
if provider.transport == "cli":
|
||||
from services.llm_cli import completion
|
||||
create = lambda **kwargs: completion(provider, **kwargs)
|
||||
else:
|
||||
create = lambda **kwargs: sdk_completion(provider, **kwargs)
|
||||
return SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
|
||||
|
||||
|
||||
def sdk_completion(provider, **kwargs):
|
||||
# LiteLLM's remote pricing fetch and telemetry are unnecessary for inference.
|
||||
# Set before its lazy import; opening settings must never trigger either.
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
os.environ["LITELLM_TELEMETRY"] = "False"
|
||||
import litellm
|
||||
from services import llm_providers as registry
|
||||
litellm.telemetry = False
|
||||
model = kwargs.pop("model")
|
||||
prefix = provider.sdk_provider
|
||||
if prefix and not model.startswith(prefix + "/"):
|
||||
model = prefix + "/" + model
|
||||
key = registry.resolve_api_key(provider)
|
||||
base = registry.resolve_base_url(provider)
|
||||
if key and key != "local":
|
||||
kwargs["aws_bearer_token_bedrock" if provider.id == "bedrock" else "api_key"] = key
|
||||
if base:
|
||||
kwargs["api_base"] = base
|
||||
if provider.id == "vertex":
|
||||
kwargs["vertex_project"] = registry.resolve_account_id(provider)
|
||||
kwargs["vertex_location"] = os.environ.get("VERTEXAI_LOCATION", "global")
|
||||
kwargs.update(num_retries=0, drop_params=True)
|
||||
return litellm.completion(model=model, **kwargs)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Deterministic, advisory working-memory plans; never loads or downloads models.
|
||||
|
||||
Estimates include runtime headroom, not checkpoint download size. They are not
|
||||
minimum hardware requirements. Generation-time pressure checks remain authoritative.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
TIERS = ("fast", "balanced", "quality", "max")
|
||||
PRIORITY = ("tts", "asr", "translation", "dictation", "diarisation", "llm")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
engine: str
|
||||
model: str
|
||||
rank: int
|
||||
ram: float
|
||||
vram: float = 0
|
||||
label: str | None = None
|
||||
|
||||
def selection(self) -> dict:
|
||||
return {"engine": self.engine, "model": self.model, "label": self.label}
|
||||
|
||||
|
||||
def hardware_snapshot() -> dict:
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.memory_budget import available_memory
|
||||
|
||||
caps = detect_host_caps()
|
||||
memory = available_memory()
|
||||
return {
|
||||
"device": caps.family,
|
||||
"ram_gb": memory.get("ram_total_gb"),
|
||||
# MPS shares RAM; counting its reported VRAM again invents capacity.
|
||||
"vram_gb": caps.vram_gb if caps.family in {"cuda", "rocm", "xpu"} else None,
|
||||
"cpu_threads": os.cpu_count() or 1,
|
||||
"probe_ok": caps.probe_ok,
|
||||
}
|
||||
|
||||
|
||||
def recommended_tier(hardware: dict) -> str:
|
||||
# Usable totals are commonly 31.8/15.8/11.9 GiB on nominal 32/16/12 GB
|
||||
# devices. Round for tier classification, never for the allocation budget.
|
||||
ram = round(hardware.get("ram_gb") or 0)
|
||||
vram = round(hardware.get("vram_gb") or 0)
|
||||
threads = hardware.get("cpu_threads") or 1
|
||||
if not hardware.get("probe_ok", True) or not ram:
|
||||
return "fast"
|
||||
if hardware.get("device") == "mps":
|
||||
return "max" if ram >= 32 else "quality" if ram >= 16 else "balanced" if ram >= 8 else "fast"
|
||||
if vram >= 12 and ram >= 24:
|
||||
return "max"
|
||||
if vram >= 8 and ram >= 16:
|
||||
return "quality"
|
||||
if vram >= 4 and ram >= 12:
|
||||
return "balanced"
|
||||
# CPU-only machines retain every setting. Auto favors a practical latency.
|
||||
return "quality" if ram >= 32 and threads >= 16 else "balanced" if ram >= 16 and threads >= 8 else "fast"
|
||||
|
||||
|
||||
def make_plan(choice: str, hardware: dict, inventory: dict[str, list[Candidate]],
|
||||
fixed: dict[str, Candidate] | None = None,
|
||||
overrides: dict[str, str] | None = None,
|
||||
current: dict[str, Candidate] | None = None) -> dict:
|
||||
"""Reserve fixed/custom engines first, then spend the shared budget TTS-first.
|
||||
|
||||
Stable total-memory budgets avoid oscillating Auto as our own models load.
|
||||
The OS/app reserve is intentionally separate from per-engine estimates.
|
||||
If nothing fits we keep the current engine (no forced unload/disable).
|
||||
"""
|
||||
resolved = recommended_tier(hardware) if choice == "auto" else choice
|
||||
if resolved not in TIERS:
|
||||
resolved = "balanced"
|
||||
ram = float(hardware.get("ram_gb") or 0)
|
||||
gpu = float(hardware.get("vram_gb") or 0)
|
||||
known = ram > 0 and hardware.get("probe_ok", True)
|
||||
# Unknown dedicated VRAM must not disable CPU-only candidates.
|
||||
remaining_ram = max(0, ram - max(4, ram * .2))
|
||||
remaining_gpu = max(0, gpu - max(1, gpu * .15))
|
||||
budgets = {"ram_gb": round(remaining_ram, 1), "vram_gb": round(remaining_gpu, 1)}
|
||||
fixed = fixed or {}
|
||||
overrides = overrides or {}
|
||||
current = current or {}
|
||||
plans = {}
|
||||
for candidate in fixed.values():
|
||||
remaining_ram -= candidate.ram
|
||||
remaining_gpu -= candidate.vram
|
||||
for family in PRIORITY:
|
||||
tier = overrides.get(family, resolved)
|
||||
if tier not in TIERS:
|
||||
tier = resolved
|
||||
effort = TIERS.index(tier) + 1
|
||||
# Quality and Max use the strongest affordable model. Max additionally
|
||||
# increases decoding/sampling effort; do not strand Auto/Quality on
|
||||
# Tiny merely because the next installed model is the top-ranked one.
|
||||
desired = 4 if effort >= 3 else effort
|
||||
candidates = sorted(inventory.get(family, []), key=lambda c: (c.rank, c.model))
|
||||
entry = {"tier": tier, "selection": None, "reason": "installed"}
|
||||
if family in fixed:
|
||||
entry.update(selection=fixed[family].selection(), reason="kept")
|
||||
elif candidates and known:
|
||||
preferred = [c for c in candidates if c.rank <= desired] or candidates[:1]
|
||||
fits = [c for c in preferred if c.ram <= remaining_ram and c.vram <= remaining_gpu]
|
||||
if fits:
|
||||
candidate = fits[-1]
|
||||
remaining_ram -= candidate.ram
|
||||
remaining_gpu -= candidate.vram
|
||||
entry.update(selection=candidate.selection(),
|
||||
reason="memory" if candidate != preferred[-1] else
|
||||
"installed" if candidate.rank < desired else "fits")
|
||||
# Keep TTS sampling effort at the requested tier. Supporting
|
||||
# decoders follow the affordable model, leaving TTS headroom.
|
||||
if family != "tts":
|
||||
entry["tier"] = TIERS[min(effort, candidate.rank) - 1]
|
||||
entry["estimated_ram_gb"] = candidate.ram
|
||||
entry["estimated_vram_gb"] = candidate.vram
|
||||
else:
|
||||
entry["reason"] = "memory"
|
||||
elif not known:
|
||||
entry["reason"] = "unknown"
|
||||
if entry["selection"] is None and family in current:
|
||||
# A retained engine still occupies memory. In particular, a TTS
|
||||
# that cannot fit must not give its budget away to secondary tools.
|
||||
remaining_ram -= current[family].ram
|
||||
remaining_gpu -= current[family].vram
|
||||
plans[family] = entry
|
||||
return {"resolved": resolved, "hardware": hardware, "budget": budgets,
|
||||
"families": plans, "estimated": True,
|
||||
"status": "unknown" if not known else
|
||||
"limited" if any(p["reason"] == "memory" and p["selection"] is None
|
||||
for p in plans.values()) else
|
||||
"adjusted" if any(p["reason"] != "fits" for f, p in plans.items()
|
||||
if f != "llm") else "fits"}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Installed-only adapter for the performance planner. No model construction."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from services.performance_budget import Candidate, hardware_snapshot, make_plan
|
||||
|
||||
|
||||
def profile_plan(choice: str, overrides: dict, selections: dict) -> dict:
|
||||
from core import prefs
|
||||
from services import performance_profiles as profiles, sherpa_dictation, translation_engines
|
||||
from services import diarization_runtime, tts_backend
|
||||
|
||||
hardware = hardware_snapshot()
|
||||
dedicated = hardware["device"] in {"cuda", "rocm", "xpu"}
|
||||
inventory: dict[str, list[Candidate]] = {}
|
||||
fixed: dict[str, Candidate] = {}
|
||||
current: dict[str, Candidate] = {}
|
||||
|
||||
# These are advisory working-set estimates, deliberately independent of
|
||||
# disk size. Unknown/custom engines keep their user's explicit selection.
|
||||
for family, selected in selections.items():
|
||||
engine, model = selected["engine"], selected.get("model")
|
||||
if not model or engine == "inactive":
|
||||
continue
|
||||
ram, vram = {"tts": (8, 6), "asr": (4, 5), "translation": (3, 3),
|
||||
"dictation": (3, 0), "diarisation": (2, 2), "llm": (8, 6)}[family]
|
||||
if engine == "kittentts":
|
||||
ram, vram = .5, 0
|
||||
current[family] = Candidate(engine, model, 1, ram, vram if dedicated else 0)
|
||||
|
||||
current_tts = selections["tts"]["engine"]
|
||||
from services.model_manager import resolve_omnivoice_checkpoint
|
||||
custom_checkpoint = resolve_omnivoice_checkpoint() != "k2-fsa/OmniVoice"
|
||||
if not custom_checkpoint and not any(os.environ.get(key) for key in ("OMNIVOICE_TTS_BACKEND", "OMNIVOICE_KITTENTTS_MODEL")) and current_tts in {
|
||||
"kittentts", "omnivoice", "omnivoice-subprocess",
|
||||
}:
|
||||
from api.routers.setup.models import KNOWN_MODELS, cache_is_complete, is_cached, _model_supported
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import runtime_compute_profile
|
||||
|
||||
tts = []
|
||||
# Never replace a cloning engine with a preset-voice-only engine.
|
||||
omni_runtime = current_tts if current_tts.startswith("omnivoice") else "omnivoice"
|
||||
choices = [(omni_runtime, "k2-fsa/OmniVoice", 4, 8, 6, "OmniVoice")]
|
||||
if current_tts == "kittentts":
|
||||
choices.append(("kittentts", "KittenML/kitten-tts-mini-0.8", 1, .5, 0, "KittenTTS"))
|
||||
for engine, repo, rank, ram, vram, label in choices:
|
||||
model = next((m for m in KNOWN_MODELS if m["repo_id"] == repo), None)
|
||||
if not model or not _model_supported(model) or not is_cached(repo) or not cache_is_complete(model):
|
||||
continue
|
||||
# Respect custom checkpoint overrides; this catalogue entry would
|
||||
# otherwise claim to select weights the runtime won't actually use.
|
||||
if engine == "kittentts" and os.environ.get("OMNIVOICE_KITTENTTS_MODEL"):
|
||||
continue
|
||||
cls = tts_backend.get_backend_class(engine)
|
||||
if not cls.is_available()[0]:
|
||||
continue
|
||||
routing = runtime_compute_profile(cls, detect_host_caps())
|
||||
if routing["routing_status"] == "unavailable":
|
||||
continue
|
||||
tts.append(Candidate(engine, repo, rank, ram,
|
||||
vram if routing["effective_device"] in {"cuda", "rocm", "xpu"} else 0, label))
|
||||
inventory["tts"] = tts
|
||||
elif "tts" in current:
|
||||
fixed["tts"] = current["tts"]
|
||||
|
||||
if os.environ.get("OMNIVOICE_ASR_BACKEND") or prefs.is_env_shadowed("ASR_MODEL_FASTER"):
|
||||
if "asr" in current:
|
||||
fixed["asr"] = current["asr"]
|
||||
else:
|
||||
backend = profiles._faster_whisper_backend()
|
||||
if backend:
|
||||
models = []
|
||||
for model in profiles._installed_ct2_models():
|
||||
repo = model["repo_id"]
|
||||
# Explicit Whisper quality order, not download-size sorting.
|
||||
rank, ram, vram = (4, 4, 5)
|
||||
if "turbo" in repo:
|
||||
rank, ram, vram = 3, 3, 3
|
||||
elif "medium" in repo:
|
||||
rank, ram, vram = 3, 3, 3
|
||||
elif "small" in repo:
|
||||
rank, ram, vram = 2, 1.5, 1.5
|
||||
elif "tiny" in repo or "base" in repo:
|
||||
rank, ram, vram = 1, .6, .6
|
||||
# CTranslate2 uses CUDA or CPU, not MPS/XPU/ROCm.
|
||||
models.append(Candidate(backend, repo, rank, ram,
|
||||
vram if hardware["device"] == "cuda" else 0))
|
||||
inventory["asr"] = models
|
||||
|
||||
if os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL") or not prefs.get("dictation.enabled", True):
|
||||
if "dictation" in current:
|
||||
fixed["dictation"] = current["dictation"]
|
||||
elif sherpa_dictation.sherpa_available()[0]:
|
||||
inventory["dictation"] = [
|
||||
Candidate(m.kind, m.id, 4 if "parakeet-tdt-v3" in m.id else
|
||||
3 if "parakeet" in m.id else 2 if "bilingual" in m.id else 1,
|
||||
3 if "parakeet" in m.id else .7,
|
||||
label=m.label)
|
||||
for m in profiles._installed_dictation_models()
|
||||
]
|
||||
|
||||
translator = selections["translation"]["engine"]
|
||||
if translator == "nllb" and translation_engines.is_ready(translator):
|
||||
# A device-wide preset has no source/target language pair to validate.
|
||||
# Argos' installed status cannot prove it replaces NLLB's coverage.
|
||||
if "translation" in current:
|
||||
fixed["translation"] = current["translation"]
|
||||
elif translator not in {"argos", "nllb"} and translation_engines.is_ready(translator):
|
||||
if "translation" in current:
|
||||
fixed["translation"] = Candidate(translator, current["translation"].model, 1, 0)
|
||||
else:
|
||||
inventory["translation"] = [
|
||||
Candidate(engine, repo, rank, ram, vram if dedicated else 0)
|
||||
for engine, repo, rank, ram, vram in [
|
||||
("argos", "argos", 1, 1, 0),
|
||||
("nllb", "facebook/nllb-200-distilled-600M", 4, 3, 3),
|
||||
] if translation_engines.is_ready(engine)
|
||||
]
|
||||
if os.environ.get("OMNIVOICE_DIARIZATION_BACKEND"):
|
||||
if "diarisation" in current:
|
||||
fixed["diarisation"] = current["diarisation"]
|
||||
else:
|
||||
installed = diarization_runtime.installed_backends()
|
||||
inventory["diarisation"] = [
|
||||
Candidate(engine, repo, rank, ram, vram if dedicated else 0)
|
||||
for engine, repo, rank, ram, vram in [
|
||||
(diarization_runtime.SORTFORMER, diarization_runtime.SORTFORMER_REPO, 1, 1.5, 1.5),
|
||||
(diarization_runtime.PYANNOTE, "pyannote/speaker-diarization-3.1", 4, 2, 2),
|
||||
] if engine in installed
|
||||
]
|
||||
# Retained unmanaged engines must consume budget too.
|
||||
for family, candidates in inventory.items():
|
||||
selected = selections.get(family, {})
|
||||
match = next((c for c in candidates if c.model == selected.get("model")), None)
|
||||
if match is not None:
|
||||
current[family] = match
|
||||
for family in current:
|
||||
if family not in inventory and family not in fixed:
|
||||
fixed[family] = current[family]
|
||||
result = make_plan(choice, hardware, inventory, fixed, overrides, current)
|
||||
result["max_status"] = make_plan("max", hardware, inventory, fixed, current=current)["status"]
|
||||
return result
|
||||
@@ -4,8 +4,9 @@ from __future__ import annotations
|
||||
import math
|
||||
import os
|
||||
|
||||
from services.performance_budget import TIERS as _PERFORMANCE_TIERS
|
||||
|
||||
_PERFORMANCE_PROFILE_KEY = "performance_profile"
|
||||
_PERFORMANCE_TIERS = ("fast", "balanced", "quality", "max")
|
||||
_PERFORMANCE_FAMILIES = (
|
||||
"tts",
|
||||
"asr",
|
||||
@@ -21,7 +22,7 @@ _PERFORMANCE_TARGETS = {
|
||||
"fast": {"steps": 8, "postprocess": False},
|
||||
"balanced": {"steps": 16, "postprocess": True},
|
||||
"quality": {"steps": 32, "postprocess": True},
|
||||
"max": {"steps": 64, "postprocess": True, "model_policy": "largest-installed-compatible"},
|
||||
"max": {"steps": 64, "postprocess": True, "model_policy": "tts-first-installed"},
|
||||
},
|
||||
"asr": {
|
||||
tier: {"beam_size": width, "best_of": width, "engine": "faster-whisper"}
|
||||
@@ -137,9 +138,8 @@ def _installed_dictation_models() -> list:
|
||||
compatible = [
|
||||
spec for spec in installed if _dictation_supports_locale(spec, language)
|
||||
]
|
||||
# A machine without a usable locale should still recover to an explicitly
|
||||
# installed model instead of claiming no speech model exists.
|
||||
return compatible or installed
|
||||
# Never "upgrade" a model to one that cannot understand the user's language.
|
||||
return compatible
|
||||
|
||||
|
||||
def _activate_asr_model(tier: str) -> dict | None:
|
||||
@@ -246,7 +246,7 @@ def _activate_installed_models(tier: str, family: str | None) -> dict[str, dict]
|
||||
|
||||
|
||||
|
||||
def profile_state() -> dict:
|
||||
def profile_state(choice: str | None = None) -> dict:
|
||||
from core import prefs
|
||||
from services import asr_backend, diarization_runtime
|
||||
from services.sherpa_dictation import get_spec as dictation_spec
|
||||
@@ -277,17 +277,14 @@ def profile_state() -> dict:
|
||||
|
||||
stored = prefs.get(_PERFORMANCE_PROFILE_KEY, {})
|
||||
raw = stored if isinstance(stored, dict) else {}
|
||||
global_tier = str(raw.get("global", "balanced")).lower()
|
||||
if global_tier not in _PERFORMANCE_TIERS:
|
||||
global_tier = str(choice or raw.get("global", "balanced")).lower()
|
||||
if global_tier not in (*_PERFORMANCE_TIERS, "auto"):
|
||||
global_tier = "balanced"
|
||||
overrides = {
|
||||
str(family): str(tier)
|
||||
for family, tier in (raw.items() if isinstance(raw, dict) else [])
|
||||
if family in _PERFORMANCE_FAMILIES and tier in _PERFORMANCE_TIERS
|
||||
}
|
||||
effective = {
|
||||
family: overrides.get(family, global_tier) for family in _PERFORMANCE_FAMILIES
|
||||
}
|
||||
applicable_families = [
|
||||
family
|
||||
for family, engines in supported_engines.items()
|
||||
@@ -338,11 +335,19 @@ def profile_state() -> dict:
|
||||
},
|
||||
"llm": {"engine": "inactive", "model": None},
|
||||
}
|
||||
from services.performance_inventory import profile_plan
|
||||
plan = profile_plan(global_tier, overrides, selections)
|
||||
effective = {family: overrides.get(family, plan["families"][family]["tier"])
|
||||
for family in _PERFORMANCE_FAMILIES}
|
||||
for family, entry in plan["families"].items():
|
||||
if family == "tts" and entry["selection"] and entry["reason"] != "kept" and family not in applicable_families:
|
||||
applicable_families.append(family)
|
||||
return {
|
||||
"global": global_tier,
|
||||
"overrides": overrides,
|
||||
"effective": effective,
|
||||
"tiers": list(_PERFORMANCE_TIERS),
|
||||
"global_choices": [*_PERFORMANCE_TIERS, "auto"],
|
||||
"families": list(_PERFORMANCE_FAMILIES),
|
||||
"implemented_families": list(_PERFORMANCE_TARGETS),
|
||||
"applicable_families": applicable_families,
|
||||
@@ -351,6 +356,7 @@ def profile_state() -> dict:
|
||||
for family in _PERFORMANCE_TARGETS
|
||||
},
|
||||
"selections": selections,
|
||||
"plan": plan,
|
||||
"downloads_started": False,
|
||||
}
|
||||
|
||||
@@ -363,6 +369,12 @@ def requested_tier(family: str) -> str | None:
|
||||
if not isinstance(stored, dict):
|
||||
return None
|
||||
tier = stored.get(family, stored.get("global"))
|
||||
resolved = stored.get("resolved", {})
|
||||
if isinstance(resolved, dict) and resolved.get(family) in _PERFORMANCE_TIERS:
|
||||
return resolved[family]
|
||||
if tier == "auto":
|
||||
from services.performance_budget import hardware_snapshot, recommended_tier
|
||||
return recommended_tier(hardware_snapshot())
|
||||
return tier if tier in _PERFORMANCE_TIERS else None
|
||||
|
||||
|
||||
@@ -378,6 +390,8 @@ def activate_maximum_capacity_models(family: str | None = None) -> dict:
|
||||
|
||||
def activate_performance_tier(tier: str, family: str | None = None) -> dict:
|
||||
"""Apply installed-only model/runtime selections implied by a preset."""
|
||||
if family is None:
|
||||
return _activate_budgeted_profile(tier)
|
||||
requested = set(_PERFORMANCE_FAMILIES if family is None else (family,))
|
||||
activated = _activate_installed_models(tier, family)
|
||||
|
||||
@@ -399,6 +413,45 @@ def activate_performance_tier(tier: str, family: str | None = None) -> dict:
|
||||
return activated
|
||||
|
||||
|
||||
def _activate_budgeted_profile(tier: str) -> dict:
|
||||
"""Apply one shared plan; never independently spend the full RAM six times."""
|
||||
from core import prefs
|
||||
from services import asr_backend, diarization_runtime, tts_backend
|
||||
|
||||
state = profile_state(tier)
|
||||
activated = {}
|
||||
for family, entry in state["plan"]["families"].items():
|
||||
selected = entry["selection"]
|
||||
if selected is None or entry["reason"] == "kept":
|
||||
continue
|
||||
engine, model = selected["engine"], selected["model"]
|
||||
if family == "tts":
|
||||
if tts_backend.active_backend_id() != engine:
|
||||
# Normal next-use switching releases the outgoing engine.
|
||||
prefs.set_("tts_backend", engine)
|
||||
elif family == "asr":
|
||||
if asr_backend.faster_whisper_model_id() != model:
|
||||
asr_backend.select_faster_whisper_model(model)
|
||||
prefs.set_("asr_backend", engine)
|
||||
elif family == "dictation":
|
||||
if prefs.get("dictation.model_id") != model:
|
||||
prefs.set_("dictation.model_id", model)
|
||||
asr_backend._capture_backend = None
|
||||
asr_backend._capture_backend_key = None
|
||||
elif family == "translation":
|
||||
prefs.set_("translation_backend", engine)
|
||||
elif family == "diarisation":
|
||||
if diarization_runtime.selected_backend() != engine:
|
||||
diarization_runtime.select_backend(engine)
|
||||
if engine == diarization_runtime.SORTFORMER:
|
||||
from services import model_manager
|
||||
model_manager.unload_diarization_pipeline()
|
||||
activated[family] = selected
|
||||
# Audio hot paths read the applied result, without probing model inventory.
|
||||
prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {"resolved": state["effective"]})
|
||||
return activated
|
||||
|
||||
|
||||
def reconcile_active_profile() -> dict[str, dict]:
|
||||
"""Reapply a persisted profile after installs or an app restart.
|
||||
|
||||
@@ -414,15 +467,9 @@ def reconcile_active_profile() -> dict[str, dict]:
|
||||
if not isinstance(stored, dict):
|
||||
return {}
|
||||
global_tier = str(stored.get("global", "balanced")).lower()
|
||||
if global_tier not in _PERFORMANCE_TIERS:
|
||||
if global_tier not in (*_PERFORMANCE_TIERS, "auto"):
|
||||
global_tier = "balanced"
|
||||
activated: dict[str, dict] = {}
|
||||
for family in _PERFORMANCE_TARGETS:
|
||||
tier = str(stored.get(family, global_tier)).lower()
|
||||
if tier not in _PERFORMANCE_TIERS:
|
||||
tier = global_tier
|
||||
activated.update(activate_performance_tier(tier, family))
|
||||
return activated
|
||||
return activate_performance_tier(global_tier)
|
||||
|
||||
|
||||
def tts_defaults(engine: str = "omnivoice") -> dict:
|
||||
|
||||
@@ -788,8 +788,8 @@ class SubprocessBackend(TTSBackend):
|
||||
"""Synthesize one utterance through the sidecar.
|
||||
|
||||
Returns a tensor of shape (1, n_samples) at the sidecar-reported
|
||||
sample rate. Decodes the int16 PCM the sidecar returns into float32
|
||||
in [-1, 1].
|
||||
sample rate. Negotiates float32 with capable sidecars and accepts
|
||||
legacy int16 frames from existing installations.
|
||||
"""
|
||||
self._check_language(kw.get("language"))
|
||||
# On-pool callers (every HTTP/dub/batch generate, dispatched via
|
||||
@@ -838,6 +838,8 @@ class SubprocessBackend(TTSBackend):
|
||||
for k, v in kw.items():
|
||||
if _is_jsonable(v):
|
||||
msg[k] = v
|
||||
if getattr(self, "supports_float_transport", False):
|
||||
msg["audio_format"] = "f32le"
|
||||
deadline_s = self._effective_recv_timeout_s(text)
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
@@ -905,7 +907,15 @@ class SubprocessBackend(TTSBackend):
|
||||
)
|
||||
pcm_b64 = reply.get("audio_pcm_b64", "")
|
||||
pcm = base64.b64decode(pcm_b64)
|
||||
arr = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
audio_format = reply.get("audio_format", "s16le")
|
||||
if audio_format == "f32le":
|
||||
arr = np.frombuffer(pcm, dtype="<f4")
|
||||
elif audio_format == "s16le":
|
||||
arr = np.frombuffer(pcm, dtype="<i2").astype(np.float32) / 32768.0
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported sidecar audio format: {audio_format!r}")
|
||||
if not np.isfinite(arr).all():
|
||||
raise RuntimeError("Sidecar returned non-finite audio samples")
|
||||
tensor = torch.from_numpy(arr.copy()).unsqueeze(0)
|
||||
return tensor
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Paid translation APIs with explicit credentials and bounded requests."""
|
||||
import html
|
||||
import os
|
||||
|
||||
PAID_PROVIDERS = {"deepl", "microsoft", "google-cloud", "amazon"}
|
||||
SECRET_ENV_KEYS = {"DEEPL_API_KEY", "MICROSOFT_API_KEY", "GOOGLE_TRANSLATE_API_KEY", "TRANSLATE_API_KEY"}
|
||||
|
||||
|
||||
class Translator:
|
||||
def __init__(self, provider, source, target, api_key=""):
|
||||
self.provider, self.source, self.target = provider, source, target
|
||||
self.api_key = api_key
|
||||
|
||||
def translate(self, text):
|
||||
import httpx
|
||||
source, target = self.source, self.target
|
||||
provider = self.provider
|
||||
if provider == "amazon":
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
aliases = {"zh-CN": "zh", "zh-TW": "zh-TW"}
|
||||
client = boto3.session.Session().client("translate", region_name=os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION"),
|
||||
config=Config(connect_timeout=5, read_timeout=30, retries={"total_max_attempts": 1}))
|
||||
try:
|
||||
return client.translate_text(Text=text, SourceLanguageCode=aliases.get(source, source),
|
||||
TargetLanguageCode=aliases.get(target, target))["TranslatedText"]
|
||||
finally:
|
||||
client.close()
|
||||
headers = {}
|
||||
params = {}
|
||||
if provider == "deepl":
|
||||
key = os.environ.get("DEEPL_API_KEY") or self.api_key
|
||||
base = os.environ.get("DEEPL_BASE_URL") or ("https://api-free.deepl.com/v2" if key.endswith(":fx") else "https://api.deepl.com/v2")
|
||||
url = base.rstrip("/") + "/translate"
|
||||
headers["Authorization"] = "DeepL-Auth-Key " + key
|
||||
target = {"zh-CN": "ZH-HANS", "zh-TW": "ZH-HANT"}.get(target, target.upper())
|
||||
payload = {"text": [text], "target_lang": target}
|
||||
if source != "auto": payload["source_lang"] = source.split("-")[0].upper()
|
||||
elif provider == "microsoft":
|
||||
key = os.environ.get("MICROSOFT_API_KEY") or self.api_key
|
||||
url = (os.environ.get("MICROSOFT_BASE_URL") or "https://api.cognitive.microsofttranslator.com").rstrip("/") + "/translate"
|
||||
headers["Ocp-Apim-Subscription-Key"] = key
|
||||
if os.environ.get("MICROSOFT_REGION"):
|
||||
headers["Ocp-Apim-Subscription-Region"] = os.environ["MICROSOFT_REGION"]
|
||||
aliases = {"zh-CN": "zh-Hans", "zh-TW": "zh-Hant"}
|
||||
params = {"api-version": "3.0", "to": aliases.get(target, target)}
|
||||
if source != "auto": params["from"] = aliases.get(source, source)
|
||||
payload = [{"Text": text}]
|
||||
elif provider == "google-cloud":
|
||||
key = os.environ.get("GOOGLE_TRANSLATE_API_KEY") or self.api_key
|
||||
url = "https://translation.googleapis.com/language/translate/v2"
|
||||
headers["X-Goog-Api-Key"] = key
|
||||
payload = {"q": text, "target": target, "format": "text"}
|
||||
if source != "auto": payload["source"] = source
|
||||
else:
|
||||
raise ValueError("Unknown translation API")
|
||||
if not key:
|
||||
raise ValueError("Translation API key is missing")
|
||||
# No redirects: provider keys must remain at the configured endpoint.
|
||||
with httpx.Client(timeout=httpx.Timeout(30, connect=5), follow_redirects=False) as client:
|
||||
response = client.post(url, headers=headers, params=params, json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if provider == "deepl": return body["translations"][0]["text"]
|
||||
if provider == "microsoft": return body[0]["translations"][0]["text"]
|
||||
return html.unescape(body["data"]["translations"][0]["translatedText"])
|
||||
@@ -84,8 +84,9 @@ REGISTRY: dict[str, dict] = {
|
||||
"deepl": {
|
||||
"id": "deepl",
|
||||
"display_name": "DeepL (Online, Key)",
|
||||
"pip_package": "deep_translator",
|
||||
"probe_module": "deep_translator",
|
||||
"pip_package": "httpx",
|
||||
"probe_module": "httpx",
|
||||
"builtin": True,
|
||||
"category": "online",
|
||||
"needs_key": True,
|
||||
"notes": "High-quality EU MT. Free tier: 500K chars/month. Set DEEPL_API_KEY.",
|
||||
@@ -93,12 +94,21 @@ REGISTRY: dict[str, dict] = {
|
||||
"microsoft": {
|
||||
"id": "microsoft",
|
||||
"display_name": "Microsoft Translator (Online, Key)",
|
||||
"pip_package": "deep_translator",
|
||||
"probe_module": "deep_translator",
|
||||
"pip_package": "httpx",
|
||||
"probe_module": "httpx",
|
||||
"builtin": True,
|
||||
"category": "online",
|
||||
"needs_key": True,
|
||||
"notes": "Azure Cognitive Services. Free tier: 2M chars/month. Set MICROSOFT_API_KEY.",
|
||||
},
|
||||
"google-cloud": {
|
||||
"id": "google-cloud", "display_name": "Google Cloud Translation", "pip_package": "httpx",
|
||||
"probe_module": "httpx", "category": "online", "needs_key": True, "builtin": True,
|
||||
},
|
||||
"amazon": {
|
||||
"id": "amazon", "display_name": "Amazon Translate", "pip_package": "boto3",
|
||||
"probe_module": "boto3", "category": "online", "needs_key": True, "builtin": True,
|
||||
},
|
||||
"mymemory": {
|
||||
"id": "mymemory",
|
||||
"display_name": "MyMemory (Online, No Key)",
|
||||
@@ -224,6 +234,11 @@ def _configured(entry: dict) -> tuple[bool, str | None]:
|
||||
return bool(os.environ.get("DEEPL_API_KEY") or os.environ.get("TRANSLATE_API_KEY")), None
|
||||
if engine_id == "microsoft":
|
||||
return bool(os.environ.get("MICROSOFT_API_KEY") or os.environ.get("TRANSLATE_API_KEY")), None
|
||||
if engine_id == "google-cloud":
|
||||
return bool(os.environ.get("GOOGLE_TRANSLATE_API_KEY")), None
|
||||
if engine_id == "amazon":
|
||||
# Resolve shared-file, SSO and role credentials only on an explicit request.
|
||||
return True, None
|
||||
return True, None
|
||||
|
||||
|
||||
@@ -290,7 +305,7 @@ def uninstall_blocker(engine_id: str) -> "tuple[int, str] | None":
|
||||
pkg = entry.get("pip_package") if entry else None
|
||||
if not pkg:
|
||||
return None
|
||||
if _normalize(pkg) in _app_dependency_names():
|
||||
if entry.get("builtin") or _normalize(pkg) in _app_dependency_names():
|
||||
return 400, (
|
||||
f"{entry['display_name']} uses {pkg}, which VoiceStudio itself "
|
||||
"depends on. Uninstalling it would break the app."
|
||||
|
||||
@@ -136,7 +136,7 @@ def test_reference_asr_hang_is_killed_and_next_request_recovers(monkeypatch, tmp
|
||||
"sys.modules['services.tts_backend'] = types.SimpleNamespace(reference_duration_s=lambda _: 1)\n"
|
||||
"sys.modules['services.asr_backend'] = types.SimpleNamespace(transcribe_reference=transcribe)\n"
|
||||
"child._load_model = lambda _: types.SimpleNamespace(generate=lambda **kw: [None], sampling_rate=24000)\n"
|
||||
"child._tensor_to_pcm_b64 = lambda *_: ('AAA=', 24000, 1)\n"
|
||||
"child._tensor_to_pcm_b64 = lambda *args: ('AAAAAA==' if args[-1] == 'f32le' else 'AAA=', 24000, 1)\n"
|
||||
"sys.exit(child.main())\n"
|
||||
)
|
||||
_use_stub(monkeypatch, script)
|
||||
|
||||
@@ -818,7 +818,8 @@ class TaskExecutor:
|
||||
"""
|
||||
import io # noqa: PLC0415
|
||||
|
||||
import soundfile as sf # noqa: PLC0415
|
||||
from services.audio_io import _safe_soundfile_write # noqa: PLC0415
|
||||
from services.generation_audio import WAV_SUBTYPES # noqa: PLC0415
|
||||
|
||||
sample_rate = int(
|
||||
params.get("sample_rate") or getattr(backend, "sample_rate", 0) or 24_000
|
||||
@@ -833,7 +834,12 @@ class TaskExecutor:
|
||||
array = array.squeeze()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, array, sample_rate, format="WAV")
|
||||
# Preserve the requested precision before the control plane receives
|
||||
# the waveform; upcasting a PCM16 transport cannot restore lost detail.
|
||||
bits = int(params.get("wav_bits", 16))
|
||||
if bits not in WAV_SUBTYPES:
|
||||
raise ValueError("WAV precision must be 16, 24, or 32 bits")
|
||||
_safe_soundfile_write(buffer, array, sample_rate, format="WAV", subtype=WAV_SUBTYPES[bits])
|
||||
payload = buffer.getvalue()
|
||||
duration = float(len(array)) / sample_rate if sample_rate else 0.0
|
||||
return payload, {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Electron audio-quality investigation handoff
|
||||
|
||||
Updated 2026-09-28. **The original perceptual regression is not established.**
|
||||
The owner redirected the work toward normal-to-highest-precision generation
|
||||
and cloning across supported TTS engines. PR #2406 now includes quality
|
||||
controls, deterministic export precision, and lossless float transport for
|
||||
OmniVoice/VoxCPM2 sidecars. See [Audio quality](audio-quality.md) for controls,
|
||||
engine coverage, and validation. Private recordings remain outside Git.
|
||||
|
||||
## Report and sample mapping
|
||||
|
||||
WittingMouse reports that the same saved voice sounds more padded/reverberant
|
||||
after moving from Tauri to Electron. Similarity of saved voice IDs alone does
|
||||
not establish identical generation requests or processing.
|
||||
|
||||
| Supplied file | Reported version | WAV format | Duration | Bytes | Loudness |
|
||||
| --- | --- | --- | ---: | ---: | ---: |
|
||||
| `4619c980.wav` | approximately OmniVoice 0.2.7, reporter uncertain | mono 24 kHz, 32-bit float | 7.64 s | 733,520 | -18.5 LUFS |
|
||||
| `3ff95c1d.wav` | VoiceStudio 0.5.3 | mono 24 kHz, 16-bit PCM | 7.57 s | 363,404 | -19.2 LUFS |
|
||||
| `voicestudio-43577f3a.wav` | VoiceStudio 0.5.6 | mono 24 kHz, 16-bit PCM | 7.73 s | 371,084 | -18.5 LUFS |
|
||||
|
||||
All are uncompressed WAVs. At 24 kHz mono, 16-bit samples carry 384 kbps and
|
||||
32-bit samples carry 768 kbps, excluding headers. This explains the approximate
|
||||
file-size ratio, not the reported perceptual difference. In particular, both
|
||||
0.5.3 and 0.5.6 supplied files are 16-bit. A conversion bug or earlier processing
|
||||
remains possible; do not dismiss the report or assume that increasing bit depth
|
||||
is the fix. No controlled listening verdict has been recorded.
|
||||
|
||||
## Completed investigations and limits
|
||||
|
||||
- Isolated source trees at v0.5.3 (`135ccd09`) and v0.5.6 (`3915a62c`), using
|
||||
the same interpreter, installed checkpoint, MPS/float16, reference/transcript,
|
||||
seed 42, 16 steps, guidance 2, denoise and model postprocessing enabled,
|
||||
produced byte-identical raw waveforms within each language condition.
|
||||
This tests source behavior, **not both packaged installers or the reporter's
|
||||
original environment**. The reference was the repository UK narrator sample.
|
||||
- English raw SHA-256: `43dbb2f0513c63514257b3614ed70cfdf2c9c108176316f7897ab6e67e6e299f`.
|
||||
Auto/None raw SHA-256: `70c5edf9ff88907aa3cbfe38a8f3c66a9629898cd62196237918dfa2f6743f37`.
|
||||
Each contained 98,880 samples. Language conditions differed from each other.
|
||||
- Legacy Auto omitted `language`, allowing the saved profile language to be
|
||||
inherited. Electron sends explicit `Auto`. This is a request-level candidate,
|
||||
not an established cause. Do not silently reverse documented Auto semantics.
|
||||
- The DSP source was unchanged between these tags. The pinned pedalboard
|
||||
versions differed (0.9.24 vs 0.9.20); applying the mastering/broadcast chain
|
||||
under both versions to the three supplied WAVs yielded identical hashes per
|
||||
input on this Mac. This does not exclude other environment differences.
|
||||
- A separate sidecar PCM round-trip measured 75.92 dB signal-to-error ratio
|
||||
and maximum sample error 0.00004977; this was not an end-to-end reproduction.
|
||||
|
||||
Checkpoint used throughout:
|
||||
`k2-fsa/OmniVoice`, revision `c5fdb5ccb189668d56333f77ba2629f4cd7535f4`.
|
||||
|
||||
## Supplied isolated bit-depth comparison
|
||||
|
||||
One new waveform was generated on MPS, seed 42, 16 steps, explicit English,
|
||||
guidance 2, denoise and model postprocessing enabled, without a voice reference.
|
||||
Mastering, broadcast effects, peak normalization to -2 dBFS, and
|
||||
`mark_synthetic` were applied **once before splitting the exports**.
|
||||
The same float samples were written using SoundFile `PCM_16` and `FLOAT`.
|
||||
There was no resampling, independent normalization, or added dither.
|
||||
|
||||
Text:
|
||||
|
||||
> The morning sunlight filled the quiet room. A gentle breeze moved through
|
||||
> the trees, while distant voices echoed across the garden. Listen closely
|
||||
> to the warmth of the voice and the quiet spaces between each word.
|
||||
|
||||
| Measurement | Result |
|
||||
| --- | ---: |
|
||||
| Channels / sample rate | mono / 24,000 Hz |
|
||||
| Samples / duration | 315,840 / 13.16 s |
|
||||
| 16-bit WAV bytes | 631,724 |
|
||||
| 32-bit float WAV bytes | 1,263,440 |
|
||||
| Float peak | -1.9838 dBFS |
|
||||
| Conversion-error RMS | -95.078 dBFS |
|
||||
| Signal-to-quantization-error ratio | 75.882 dB |
|
||||
| Maximum absolute sample difference | 0.0000305171 |
|
||||
|
||||
The shared pre-export float waveform SHA-256 was
|
||||
`5b63f4057f1fa0d21bf4db338fa618ef412ed22646b73515f002bed93c3c311e`.
|
||||
Neither export clipped. These measurements describe numerical differences,
|
||||
not a listening result or proof that bit depth is irrelevant in every pipeline.
|
||||
|
||||
Reproduce export isolation with an existing **provenance-marked float master**
|
||||
that has not already been quantized to 16-bit. Run in a new output directory:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
samples, sr = sf.read('marked-float-master.wav', dtype='float32')
|
||||
assert sf.info('marked-float-master.wav').subtype == 'FLOAT'
|
||||
assert sr == 24000 and samples.ndim == 1
|
||||
assert np.isfinite(samples).all() and np.max(np.abs(samples)) < 1
|
||||
sf.write('sample-16bit.wav', samples, sr, subtype='PCM_16')
|
||||
sf.write('sample-32bit-float.wav', samples, sr, subtype='FLOAT')
|
||||
a, _ = sf.read('sample-16bit.wav', dtype='float64')
|
||||
b, _ = sf.read('sample-32bit-float.wav', dtype='float64')
|
||||
error_rms = np.sqrt(np.mean((b - a) ** 2))
|
||||
print('error RMS dBFS:', 20 * np.log10(error_rms))
|
||||
print('signal/error dB:', 20 * np.log10(np.sqrt(np.mean(b ** 2)) / error_rms))
|
||||
```
|
||||
|
||||
Converting the old 16-bit sample to float cannot restore lost information.
|
||||
Use identical player settings and playback volume; disable automatic loudness
|
||||
adjustment, EQ, enhancement, and spatial audio for the comparison.
|
||||
|
||||
## Supplied higher-quality candidate, not a controlled A/B
|
||||
|
||||
A second generation used the same text, seed 42, explicit English, guidance 2,
|
||||
**32 steps**, denoise and model postprocessing enabled. The preceding float
|
||||
sample and its text were supplied as the cloning reference to retain a similar
|
||||
voice. No mastering compressor or broadcast effects were added; processing was
|
||||
peak normalization and `mark_synthetic` only. Export: mono 24 kHz, 32-bit float,
|
||||
12.93 seconds, peak -2.0343 dBFS, no clipping.
|
||||
|
||||
This changes steps, reference conditioning, and effects together. It cannot
|
||||
identify which change helps, and no perceptual improvement has been confirmed.
|
||||
The reference already contains earlier processing; it is not a dry studio source.
|
||||
Do not ship these settings as a proven fix based on this sample alone.
|
||||
|
||||
## Files and code available to the next machine
|
||||
|
||||
The supplied archive was checked locally: its six WAVs and two JSON files
|
||||
match the export measurements above. The archive did not contain the original
|
||||
voice reference or transcript for the three historical outputs. Its directory
|
||||
labels include `20260929`; those labels do not establish the experiment date.
|
||||
The WAVs are **local only**, not attached to this PR. Transfer privately if
|
||||
needed; obtain permission before publishing any reporter recordings.
|
||||
|
||||
- Source Mac Downloads: `VoiceStudio-bit-depth-comparison-20260929/` contains
|
||||
`sample-16bit.wav`, `sample-32bit-float.wav`, and `comparison.json`.
|
||||
- Source Mac Downloads: `VoiceStudio-clean-quality-20260929/` contains
|
||||
`clean-32step-float.wav` and `settings.json`.
|
||||
- Original three supplied WAVs are in that Mac's Downloads folder.
|
||||
- Temporary one-off generation harnesses on that Mac:
|
||||
`/tmp/voicestudio-bit-depth-ab.py` and `/tmp/voicestudio-clean-quality.py`.
|
||||
They contain machine-specific paths and are not repository dependencies.
|
||||
- Separately pushed branch `fix/desktop-packs-audio-quality-polish`, commit
|
||||
`30c814a0`, contains `scripts/compare_generation_quality.py`,
|
||||
`scripts/compare_release_clone.py`, and the earlier
|
||||
`docs/audio-feedback-tasklist.md`. The release-comparison script remains on that branch; the generation-quality
|
||||
script is now maintained in this PR.
|
||||
Inspect them with `git show 30c814a0:scripts/compare_release_clone.py` after
|
||||
fetching that branch. Use an installed checkpoint and offline mode; do not
|
||||
assume `/tmp` artifacts or the original developer's cache exist elsewhere.
|
||||
- Advisory audio-quality warnings from that branch (#2375) are now included.
|
||||
They describe signal issues, not voice similarity or perceived quality.
|
||||
|
||||
## Next-machine task list and acceptance criteria
|
||||
|
||||
1. Obtain original reference WAV/transcript, generated text, model revision,
|
||||
seed, exact request payload, effects/export settings, and each installation's
|
||||
OS, device and dependency versions. Keep private recordings out of Git.
|
||||
2. Record matched-level blind listening judgments for the export-only pair.
|
||||
If there is a repeatable difference, test the same float master through the
|
||||
**actual old and current export paths**, checking clipping, scale, rounding,
|
||||
sample-rate/channel metadata, player decoding and intermediary formats.
|
||||
3. Reproduce with matched inputs in both application environments. Capture raw
|
||||
model output, output after each DSP stage, stored WAV, and downloaded/exported
|
||||
WAV. Compare explicit English first, then investigate Auto/profile inheritance.
|
||||
4. For quality tuning, hold reference and seed fixed; change one variable at a
|
||||
time: 16 vs 32 steps, effects off/on, denoise off/on, export subtype. Use several
|
||||
seeds and voices. Measure intelligibility and ask listeners about similarity,
|
||||
naturalness and reverberation; DSP warning counts are not quality scores.
|
||||
5. Add a fail-before/pass-after regression test at the demonstrated failing
|
||||
processing/export seam, then implement the smallest supported fix. Preserve
|
||||
`mark_synthetic`, existing voices, local-first behavior and explicit downloads.
|
||||
6. Validate user-visible behavior on macOS, Windows and Linux. Run model-free
|
||||
tests with `HF_HUB_OFFLINE=1` and an empty `HF_HUB_CACHE`; run generation
|
||||
experiments separately against explicitly installed checkpoints.
|
||||
|
||||
Do not restore removed Tauri build/runtime paths to the maintained repository.
|
||||
Use isolated historical installations/source trees only for comparison. Do not
|
||||
close the regression based solely on larger files, fewer warnings, or a single
|
||||
new generation sounding different.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Audio quality in Clone and Design
|
||||
|
||||
The **Audio quality** controls apply to the next generated take. Existing files
|
||||
stay unchanged. Normal defaults remain 16-bit WAV, 16 sampling steps and
|
||||
broadcast mastering; model-specific limits still apply.
|
||||
|
||||
The compact slider sits below the script in Clone and Design. **More options**
|
||||
reveals sampling steps, mastering and format guidance. **Voice controls** exposes
|
||||
speed, duration and cleanup; technical model controls are collapsed under
|
||||
**Advanced model tuning** with plain-language names and guidance.
|
||||
|
||||
## Normal to maximum export precision
|
||||
|
||||
| WAV slider | Use | Approximate size per minute, 24 kHz mono |
|
||||
| --- | --- | ---: |
|
||||
| 16-bit PCM | Normal playback, smallest WAV, broad player support | 2.75 MiB |
|
||||
| 24-bit PCM | More precision for editing | 4.12 MiB |
|
||||
| 32-bit float | Highest supported export precision | 5.49 MiB |
|
||||
|
||||
These are uncompressed WAVs. Size scales with duration, sample rate and channel
|
||||
count; 48 kHz doubles these estimates. The finished take shows its actual size,
|
||||
sample rate, channels and sample format. Export precision cannot add information
|
||||
that an engine has already discarded or improve voice identity by itself.
|
||||
The WAV slider does not change model weights, quantization or sample rate.
|
||||
|
||||
**Sampling steps** controls model computation separately. More steps take longer,
|
||||
without increasing WAV size or guaranteeing better speech. The slider appears
|
||||
only for adapters that forward this setting:
|
||||
|
||||
| Engine | Sampling slider | Export controls |
|
||||
| --- | --- | --- |
|
||||
| OmniVoice, including subprocess mode | 8–64 steps | 16/24/32-bit WAV |
|
||||
| VoxCPM2 | 8–64 steps | 16/24/32-bit WAV, native 48 kHz |
|
||||
| dots.tts | 8–64 steps | 16/24/32-bit WAV |
|
||||
| Supertonic-3 | 5–12 steps, matching its adapter limits | 16/24/32-bit WAV |
|
||||
| Other TTS engines | Engine manages sampling; no ineffective slider | 16/24/32-bit WAV |
|
||||
|
||||
**Broadcast mastering** adds the existing processing chain. Turning it off skips
|
||||
the app's added EQ, compression and normalization; engine postprocessing and
|
||||
the existing provenance watermark setting remain in effect. An engine that
|
||||
performs its own mastering continues to skip the app's mastering pre-stage.
|
||||
|
||||
The final playback response uses the saved WAV itself. Streaming previews still
|
||||
use PCM16; their completed take uses the selected precision. Updated remote
|
||||
workers honor the requested precision before returning audio. Older workers or
|
||||
engines that only deliver PCM16 cannot recover extra detail through a larger export.
|
||||
OmniVoice and VoxCPM2 subprocesses negotiate float32 transport; legacy PCM16
|
||||
responses remain supported without reinstalling engines.
|
||||
|
||||
## Optional signal checks
|
||||
|
||||
Use **Check audio** below a finished take to scan locally for long silence,
|
||||
relative volume changes, clipping, empty audio or invalid samples. Click a warning
|
||||
to seek to its timestamp; close the report to dismiss it. Scanning is bounded to
|
||||
two hours and 100 warnings, and reports when limited. It never modifies audio.
|
||||
Warnings are advisory signal checks, not a voice-similarity or naturalness score.
|
||||
|
||||
## Validation and repeatable comparisons
|
||||
|
||||
PR #2406 exercises real installed engines on Windows with an RTX 4090:
|
||||
|
||||
| Engine | Real generation coverage | Format |
|
||||
| --- | --- | --- |
|
||||
| OmniVoice | TTS and cloning, 16/32/64 steps | 24 kHz mono |
|
||||
| OmniVoice subprocess | TTS and cloning, 16/32/64 steps | 24 kHz mono |
|
||||
| VoxCPM2 subprocess | TTS and cloning, 16/32/64 steps | 48 kHz mono |
|
||||
| KittenTTS | Fixed-voice TTS; cloning/step tuning unsupported | 24 kHz mono |
|
||||
|
||||
Each condition is exported at all three precisions, with broadcast and raw
|
||||
processing. The first sweep produced 114 valid WAVs with finite samples and no
|
||||
signal warnings. Offline Whisper large-v3 recovered all 13 words in each of the
|
||||
19 raw float conditions. That short single-text check establishes intelligibility
|
||||
for these trials, not a perceptual ranking across voices or languages.
|
||||
|
||||
The sweep exposed seed propagation gaps in the explicit OmniVoice and VoxCPM2
|
||||
sidecars. Those now receive per-chunk seeds, with a fresh sweep after the fix.
|
||||
On the seeded VoxCPM2 clone trial, 16/32/64 steps took approximately 9.7/14.1/27.2
|
||||
seconds; increasing sampling effort has a measurable time cost.
|
||||
|
||||
Model-free regression tests cover export precision, identical final playback/save
|
||||
bytes, streamed final samples, stereo/rate preservation, remote-worker encoding,
|
||||
float sidecar round trips, legacy frames, seed forwarding, warning bounds, path
|
||||
confinement, keyboard sliders and locale parity. Precision tests fail against the
|
||||
original PR implementation, which writes PCM16 regardless of the requested bits.
|
||||
The same implementation is used on macOS, Windows and Linux; real synthesis on
|
||||
macOS/Linux and engines absent from this Windows installation is not claimed here.
|
||||
|
||||
For another installed engine or reference, run the repository Python interpreter:
|
||||
|
||||
```sh
|
||||
python scripts/compare_generation_quality.py \
|
||||
--engines omnivoice omnivoice-subprocess voxcpm2 kittentts \
|
||||
--output /path/to/new-comparison-directory \
|
||||
--ref-audio /path/to/reference.wav --ref-text "Reference transcript"
|
||||
```
|
||||
|
||||
The script runs offline, uses installed models, writes provenance-marked WAVs and
|
||||
`results.json`, and refuses to overwrite an output directory. Omit the reference
|
||||
arguments for TTS only; use `--text`, `--steps` and `--seed` to vary the experiment.
|
||||
Keep private recordings and generated voice comparisons outside Git. The original
|
||||
[audio investigation](audio-quality-handoff.md) remains available as historical
|
||||
evidence; this work does not establish a cause for the historical reverb report.
|
||||
@@ -1,22 +1,127 @@
|
||||
# Electron LLM provider settings
|
||||
|
||||
The local setup on Windows can use Ollama at `http://127.0.0.1:11434/v1` with
|
||||
`qwen3:4b-instruct-2507-q4_K_M` (approximately 2.5 GB). Install Ollama, pull the
|
||||
model, then choose Ollama in this page and use **Connect & enable**. No key is
|
||||
needed. This setup was verified with real translation and dictation cleanup.
|
||||
|
||||
## Provider options
|
||||
|
||||
The same provider and per-skill selectors support three transports:
|
||||
|
||||
| Option | Configuration |
|
||||
| --- | --- |
|
||||
| OpenAI-compatible SDK | OpenAI, Gemini, Mistral, Cohere, DeepSeek, xAI, Together, Fireworks, Perplexity, Qwen, Kimi, MiniMax, Z.AI, OpenRouter and other listed hosts; key, model, optional endpoint override |
|
||||
| Native LiteLLM SDK | Anthropic directly; Amazon Bedrock; Google Vertex AI; other LiteLLM providers using `provider/model` |
|
||||
| Installed CLI | Claude Code, Codex, Pi and OpenCode using their existing CLI login; model is optional |
|
||||
|
||||
Azure OpenAI uses the resource's `/openai/v1` base URL and the deployment name
|
||||
as Model. Bedrock accepts a Bedrock API key or an existing `AWS_PROFILE` /
|
||||
AWS credentials, with region set in the AWS environment. Vertex uses Account ID
|
||||
as the Google project, application default credentials, and `VERTEXAI_LOCATION`
|
||||
(default `global`). Provider access and billing remain tied to your accounts.
|
||||
Model IDs vary by account; native SDK and CLI entries use manual model entry,
|
||||
while compatible endpoints offer model discovery. The generic LiteLLM entry
|
||||
requires a provider-prefixed model. Credentials can use a saved key, provider
|
||||
environment variables or the provider's identity chain; Connect validates access.
|
||||
The Google AI default is `gemini-3.8-flash`; the former `gemini-2.0-flash` default
|
||||
has been retired ([Google's deprecation schedule](https://ai.google.dev/gemini-api/docs/deprecations)).
|
||||
Explicitly saved model choices remain unchanged.
|
||||
|
||||
CLI completions reuse Electron's **Ask VoiceStudio Agent** discovery, process
|
||||
runner, output parser, timeout and shutdown cleanup. They run in a temporary
|
||||
directory with restricted tools, without the repair API capability. A private
|
||||
loopback bridge lets backend LLM skills call that runner; its credential never
|
||||
reaches renderer JavaScript or the CLI child. Standalone web/backend deployments
|
||||
do not advertise desktop CLI providers as configured. Install/sign in to a CLI
|
||||
outside the app, then restart VoiceStudio to refresh discovery. CLI startup can
|
||||
exceed dictation's short latency budget; Ollama or a direct API is preferable for
|
||||
live cleanup. Existing batch agent dubbing remains available in the Dubbing tab.
|
||||
|
||||
SDK imports are lazy and LiteLLM telemetry and remote pricing-map downloads are
|
||||
disabled. Reading the provider catalogue starts no CLI and performs no cloud
|
||||
request. Paid-provider transport tests use loopback fixtures, not paid accounts.
|
||||
|
||||
## Translation services
|
||||
|
||||
The Dubbing engine selector also offers Google Cloud Translation and Amazon
|
||||
Translate alongside DeepL, Microsoft, Google web translation, MyMemory, and
|
||||
offline engines. Configure Google Cloud's API key, Azure region, AWS profile and
|
||||
AWS region in **Settings > Credentials**. Amazon uses the standard AWS credential
|
||||
chain. DeepL Free keys ending in `:fx` automatically use the Free API; paid keys
|
||||
use the Pro endpoint. DeepL/Microsoft/Google Cloud keys are encrypted, including
|
||||
migration of older plaintext translation-key preferences. The legacy compatible
|
||||
endpoint key uses the same encrypted storage. These paid requests
|
||||
have bounded connection/read timeouts and do not follow credential-bearing redirects.
|
||||
|
||||
Provider protocol references: [LiteLLM](https://docs.litellm.ai/docs/providers),
|
||||
[Codex](https://developers.openai.com/codex/noninteractive/),
|
||||
[Claude Code](https://code.claude.com/docs/en/cli-reference),
|
||||
[Pi](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/cli.md),
|
||||
[OpenCode](https://opencode.ai/docs/cli/),
|
||||
[Google Cloud Translation](https://cloud.google.com/translate/docs/reference/rest/v2/translate),
|
||||
[Amazon Translate](https://docs.aws.amazon.com/translate/latest/APIReference/API_TranslateText.html),
|
||||
[DeepL](https://developers.deepl.com/api-reference/translate),
|
||||
[Microsoft](https://learn.microsoft.com/en-us/azure/ai-services/translator/text-translation/reference/v3/translate).
|
||||
|
||||
## Connection behavior
|
||||
|
||||
Settings > Models > LLM includes the existing provider catalogue. Translation
|
||||
settings links to it. Configure an endpoint/model and, where needed, an API key
|
||||
or account ID. Save preserves a stored key when the key input is blank. Keys
|
||||
are sent only to the existing backend credential storage, never localStorage.
|
||||
Environment-pinned fields and activation remain read-only with an explanation.
|
||||
Environment-pinned fields remain read-only. Activation cannot override a pinned
|
||||
provider or an `OMNIVOICE_LLM_BACKEND=off` setting; the currently pinned provider
|
||||
can still save its editable fields and enable the engine when permitted.
|
||||
|
||||
Save & use for translation explicitly activates the provider. Saving or testing
|
||||
alone does not imply activation. Test and Fetch models first save the current
|
||||
LLM settings uses one provider status and setup form. It does not show the generic
|
||||
hardware-engine inventory with a misleading "Off: Available" row or an unavailable
|
||||
backend count. Without an active LLM, the status explains how to connect a server
|
||||
or configure a provider key. The provider picker is searchable; local API keys are
|
||||
optional and collapsed by default.
|
||||
|
||||
Connect & enable saves the form, then calls `/llm-providers/{id}/connect`. The
|
||||
backend verifies a usable model response before activating the provider and LLM
|
||||
engine. A failed connection leaves the engine mode unchanged and shows the
|
||||
classified failure. Turn off LLM disables the engine without deleting credentials.
|
||||
Incomplete settings cannot replace the active selection. The editor passes `activate_if_unset=false`
|
||||
for ordinary saves and probes; older API clients retain the first-save activation
|
||||
behavior. A first editor save preserves the prior engine mode so adding a cloud
|
||||
key cannot auto-enable features before connection verification. Existing
|
||||
environment/key auto-selection outside this editor remains supported.
|
||||
Test and Fetch models first save the current
|
||||
form, stop if saving fails, and then call the backend probe. They never run
|
||||
on page load. Provider calls may use the network only when explicitly requested;
|
||||
local endpoints remain supported. Failed probes use classified localized messages.
|
||||
Local connection tests allow up to 120 seconds for a cold model load, while
|
||||
connection establishment remains bounded to five seconds (cloud responses: 20 seconds).
|
||||
|
||||
Local servers can use optional API keys, stored encrypted like cloud keys, or
|
||||
`OLLAMA_API_KEY` / `LMSTUDIO_API_KEY` environment overrides. Blank key inputs keep
|
||||
the stored key. Fetch models works before a custom model is selected and reports
|
||||
empty and truncated listings. Test requires a nonempty answer, not merely an HTTP
|
||||
success. Configuration readiness checks the HTTP(S) endpoint, required credentials,
|
||||
model and account ID; a stopped server is detected by the explicit Test action.
|
||||
|
||||
LM Studio's blank model field preserves automatic loaded-model discovery.
|
||||
Opening settings or reading engine inventory never probes that server, and saving
|
||||
the form never freezes a discovered model. Existing `local-model` placeholder
|
||||
settings recover automatic discovery; a real explicit model still takes precedence.
|
||||
Discovery runs only on an explicit probe or feature request.
|
||||
|
||||
LLM skills following the active provider respect the selected Off engine. Explicit
|
||||
per-skill provider overrides remain usable independently, but the environment's
|
||||
global Off switch disables all skills. Changing skill routing refreshes translation
|
||||
and dictation readiness in the UI.
|
||||
|
||||
The browser smoke `node electron/tests/llm-providers-smoke.mjs` mocks credentials
|
||||
and provider responses. It verifies blank-key preservation, environment pinning,
|
||||
save-before-test, no probe after failed save, model choice and activation. A live
|
||||
catalogue read returned 17 provider descriptors without key material. Actual
|
||||
external credentials and remote-provider calls are not verified by those mocks.
|
||||
Backend regression tests also exercise the actual OpenAI SDK against a loopback
|
||||
HTTP fixture through model listing, activation, probing and skill completion.
|
||||
That verifies transport and routing, not a real model's translation quality.
|
||||
Per-skill routing is available beneath providers. Each backend capability can be
|
||||
disabled or assigned a configured provider, with an option to follow the active
|
||||
provider. Existing unavailable overrides stay visible. Readiness comes from the
|
||||
|
||||
@@ -10,7 +10,7 @@ until the reader returns to the bottom. Clear requires an explicit confirmation.
|
||||
The backend log file can be revealed with the native file manager when available.
|
||||
Synthesis error details link directly to this settings view. Error and failure lines
|
||||
are red, warnings amber, successful readiness events green, and debug output muted.
|
||||
**Repair with an agent** opens the footer dock without covering the log and preloads
|
||||
**Ask VoiceStudio Agent** opens the footer dock without covering the log and preloads
|
||||
the visible failure and warning lines as repair context.
|
||||
|
||||
Run `node electron/tests/logs-smoke.mjs` against the development renderer. It mocks
|
||||
|
||||
@@ -19,13 +19,25 @@ The backend publishes explicit model lifecycle transitions to the renderer event
|
||||
|
||||
## Speed and quality presets
|
||||
|
||||
The engine sidebar and Performance settings expose Fast, Balanced, Quality and Max. A global choice resets family overrides; a family choice overrides the global preference. Changes are blocked while foreground or batch work is active. Choosing a preset never downloads weights or activates cloud providers. A ready network translator remains authoritative when explicitly selected; if that provider becomes unavailable, profile reconciliation recovers to the tier-appropriate installed local translator instead of leaving translation unusable.
|
||||
The sidebar groups the original speed/quality slider with its engine list. Models use compact two-line rows with plain-language tool names, short model names and written status. Available means the model loads on use; Check setup opens model settings to inspect availability. Offline overrides cached readiness. Loading and saving use explicit text and a spinner that respects reduced motion.
|
||||
|
||||
Currently connected controls are OmniVoice sampling (8/16/32/64 steps), Faster-Whisper decoding search (1/3/5/8), Sherpa transducer dictation search (greedy through 8-path modified beam search), local NLLB beam search (1/3/5/8), and the installed diarisation runtimes. Clone, Dubbing, Batch, Voice Conversion, Stories and Audiobook all resolve untouched TTS controls through this shared contract; an explicit Production override still wins. Dictation keeps the selected language model and rebuilds its warm recognizer after a tier change. When both diarisation choices are installed, Fast/Balanced select native audio.cpp Sortformer and Quality/Max select pyannote; with only one runtime, its family control stays unavailable rather than accepting a no-op preference. Max selects the strongest already-installed compatible Faster-Whisper, dictation and NLLB choices without downloading anything. LLM remains unavailable until the selected runtime exposes a meaningful comparable effort control. The API reports only implemented targets.
|
||||
One view icon beside the device status opens the remembered Simple, Models and Details choices, each with its own icon. Simple shows voice readiness; Models shows all six selected engines; Details lets users expand one engine at a time for complete identifiers, observed execution devices, reported problems and a Change engine link. Diagnostics are opened explicitly, so hovering never stacks cards over the workspace. View changes only affect presentation. Engine lists scroll within the sidebar so Settings stays reachable on short windows.
|
||||
|
||||
The slider sits directly above the engines and displays the selected tier. A successful preset change reveals Models when starting in Simple, shows the backend-confirmed model selections while dependent status queries refresh, then returns to observed runtime status. Stale readiness, device and error information are hidden during this transition. All affected status sources, including diarisation, are refreshed. The slider retains keyboard and right-to-left support, with explanatory text linked for screen readers. Preset changes never download models.
|
||||
|
||||
The engine sidebar retains the glowing Fast–Balanced–Quality–Max slider and adds Auto at the end. Auto chooses a tier from local CPU threads, RAM and GPU memory, then uses the same shared model budget as manual presets. The short status beneath the slider opens hardware figures, Max availability and each tool's reason for a smaller or unchanged model. A global choice resets family overrides; a family choice overrides the global preference. Changes are blocked while foreground or batch work is active. Choosing a preset never downloads weights or activates cloud providers. A ready network translator remains authoritative when explicitly selected; if that provider becomes unavailable, profile reconciliation recovers to an installed local translator.
|
||||
|
||||
Global plans reserve system headroom, retain pinned/custom choices, then allocate to TTS, transcription, translation, dictation and speaker identification in that order. Native OmniVoice is preferred at Max when its checkpoint and runtime are installed and the estimate fits; otherwise the plan keeps an installed compatible choice. A cloning engine is never replaced with Kitten's preset-only voices. Supporting tools use smaller installed models when necessary. Dictation upgrades to Parakeet v3 when installed, language-compatible and affordable; Whisper Tiny can remain selected because of language, installation or memory limits. Presets do not change OmniVoice's runtime precision (currently FP16); Max is not a promise of FP32 inference.
|
||||
|
||||
The estimates describe working memory, not download size. CPU and dedicated-GPU budgets are separate; Apple unified memory is counted only once. Auto uses stable total-capacity budgets with system reserves so loading the app's own models does not cause repeated switching. It is reconsidered when selected, at startup and after installation, not continuously during a job. Runtime memory-pressure checks still apply when loading; fit estimates cannot guarantee peak use or account precisely for other applications. Missing hardware readings preserve model selections and show an unavailable check. No model is preloaded by moving the slider.
|
||||
|
||||
The hardware explanation shows live local CPU/GPU utilization and RAM/VRAM used versus capacity in a compact grid, followed by each tool's memory fit. Expand Details for hardware specifications and how models share memory. It shares the device-status telemetry query and refreshes every two seconds while open, stopping on close. Unsupported readings and failed or offline samples show Unavailable instead of a false zero or stale live value. When device-wide GPU telemetry is unavailable, reported process allocation is labeled App VRAM. The panel scrolls on short windows. Planning estimates remain separate from these live measurements.
|
||||
|
||||
Connected controls include OmniVoice sampling (8/16/32/64 steps), Faster-Whisper decoding search (1/3/5/8), Sherpa transducer dictation search (greedy through 8-path modified beam search), local NLLB beam search (1/3/5/8), and installed diarisation runtimes. Clone, Dubbing, Batch, Voice Conversion, Stories and Audiobook resolve untouched TTS controls through this shared contract; an explicit Production override still wins. Global plans cache the effective decoder tiers after applying their model choices. Dictation rebuilds its warm recognizer when the selected model changes. With only one diarisation runtime its individual family control stays unavailable. LLM selection remains explicit because it has no comparable local effort control.
|
||||
|
||||
Performance Settings presents each family as a discrete Fast/Balanced/Quality/Max control and identifies the effective local model, runtime, and decoding effort beneath it. A choice is persisted before any optional renderer-side synchronization and receives explicit applied or failed feedback, so a cold engine catalogue cannot make the control appear inert. Families without an installed compatible target stay disabled, name the required engine, and link to its Models view; an engine with no comparable effort contract explains that limitation. The compact sidebar keeps the slider form of the same setting.
|
||||
|
||||
Settings > Models turns those tiers into one-click, target-aware model packs. Each pack previews its exact compatible models, installed size, remaining download and aggregate progress before starting the existing resumable installer. Fast installs the smallest local ASR and dictation set; Balanced selects the faster Whisper Turbo and Parakeet set; Quality and Max add Whisper large-v3 and local NLLB. The saved tier is reconciled after every successful model download, so newly available engines become active without another selection or restart. Diarisation stays explicit because native audio.cpp setup and gated pyannote access require separate consent; LLM stays explicit because it has no common local performance target.
|
||||
Settings > Models turns those tiers into one-click, target-aware model packs. Each pack previews its exact compatible models, installed size, remaining download and aggregate progress before starting the existing resumable installer. Pack tiers can be browsed before any engine is active; browsing does not change the saved performance profile or download models. The install/use button applies the selected tier. Fast installs the smallest local ASR and dictation set; Balanced selects the faster Whisper Turbo and Parakeet set; Quality and Max add Whisper large-v3 and local NLLB. The saved tier is reconciled after every successful model download, so newly available engines become active without another selection or restart. Diarisation stays explicit because native audio.cpp setup and gated pyannote access require separate consent; LLM stays explicit because it has no common local performance target.
|
||||
|
||||
Backend startup was checked live after the lifecycle changes: OmniVoice loaded successfully and the performance-profile endpoint responded. This does not establish the cause of historical native crashes or verify recovery from every stall.
|
||||
|
||||
@@ -36,3 +48,6 @@ Crash-isolated Faster-Whisper receives the same ASR decoding preset with each tr
|
||||
Uploaded voice references use the selected ASR engine through the shared transcribe endpoint's reference mode. This skips word alignment, checks locally installed models before loading, and never enables LLM refinement. Dictation selection remains independent. Missing models leave the optional transcript editable and retryable; asynchronous results do not overwrite manual edits or a subsequently selected saved voice.
|
||||
|
||||
Reference mode fails closed if local installation cannot be verified, including unknown model selections and preflight errors. The loader rechecks the actual selected engine and every fallback immediately before loading, bypassing stale positive cache entries.
|
||||
|
||||
When dedicated GPU capacity is unavailable, the planner still considers installed
|
||||
CPU-only candidates within the RAM budget. It does not assume GPU models fit.
|
||||
|
||||
+31
-26
@@ -1,24 +1,23 @@
|
||||
# Repair with an agent
|
||||
# Ask VoiceStudio Agent
|
||||
|
||||
The Electron app exposes a repair launcher centered on the right edge of every workspace and
|
||||
Settings view. Opening it reserves footer space and resizes the active view instead of covering
|
||||
content. Opening it on Logs automatically collects the visible backend and frontend failures,
|
||||
deduplicates them, fills the report, and identifies common Hugging Face access, memory, port and
|
||||
broken-runtime causes before an agent runs. The evidence remains expandable in the dock. It detects
|
||||
supported command-line agents already installed on the machine:
|
||||
The Electron launcher opens a resizable chat workspace inside the app. Select an installed
|
||||
Codex, Claude Code, OpenCode or Pi harness, then describe a task. Feature chips supply focused
|
||||
instructions for setup, repairs, voice cloning, voice design, dubbing, transcription, stories,
|
||||
audiobooks, workflows and audio tools. Starter cards prefill a request without executing it.
|
||||
Use **Send** (Enter) to run it; Shift+Enter adds a line.
|
||||
|
||||
- Codex
|
||||
- Claude Code
|
||||
- OpenCode
|
||||
- Pi
|
||||
**Autopilot** performs the requested supported app actions and verifies their results.
|
||||
**Plan** inspects app state through a read-only API capability. Ordinary chat uses a temporary
|
||||
app workspace even in a source build: no source checkout is required for app setup, generation,
|
||||
or diagnosis. **Features > Allow source-code repair** explicitly opts into the attached
|
||||
writable checkout and the selected harness's source-editing permissions.
|
||||
|
||||
**Diagnose** gives the selected agent read-only access where the CLI supports it. **Fix** allows
|
||||
workspace edits under that agent's normal permission model. Each run receives the user's report,
|
||||
the current route, recent renderer and backend logs, the local system diagnostic report, and any
|
||||
durable Electron main-process fatal-error record. Manual
|
||||
runs start only after the user clicks an action. A repeated renderer crash may start the available
|
||||
default agent only after the user has explicitly selected that default on the first failure; the
|
||||
diagnostic context stays local to the selected command-line agent.
|
||||
Replies stream as chat messages. Follow-up turns receive the last twelve nonempty messages,
|
||||
limited to 4,000 characters each. Conversation and draft state survive workspace navigation
|
||||
and dock collapse for this app session; **New chat** clears them. They are not persisted to disk.
|
||||
The selected harness receives the current request, recent conversation, current route, recent
|
||||
renderer/backend logs and diagnostics. The harness may use its configured provider account.
|
||||
No harness starts merely by opening the panel or selecting a preset.
|
||||
|
||||
Each run also receives a temporary connection file for an app-owned loopback API bridge. The bridge
|
||||
targets the backend currently attached to VoiceStudio, injects remote authentication inside the
|
||||
@@ -49,12 +48,11 @@ checkout-free action while preserving the selected media, editable transcript an
|
||||
|
||||
For checkout-free app operations, the agent discovers supported actions from the bridge OpenAPI
|
||||
document, chooses ordinary model and engine settings from current hardware and recommendations, and
|
||||
verifies the final state. The triggering **Fix** click authorizes required model downloads and engine
|
||||
selection. Licenses, credentials, privacy or telemetry consent, data deletion and remote-device
|
||||
verifies the final state. A setup request sent in Autopilot authorizes its required model downloads and engine selection. Licenses, credentials, privacy or telemetry consent, data deletion and remote-device
|
||||
connections remain explicit user decisions; the agent stops at that one decision instead of filling
|
||||
it in silently.
|
||||
|
||||
Explicit `ACTION_REQUEST` recovery runs can operate a packaged app without a source checkout. The
|
||||
Chat requests with `workspace: app` and legacy `ACTION_REQUEST` recovery runs operate a packaged app without a source checkout. The
|
||||
agent starts in an isolated temporary directory, can reach only the session bridge, cannot use
|
||||
authentication routes, and is instructed to inspect, perform and verify the requested app operation
|
||||
without editing user files. Codex keeps its workspace sandbox for these sessions and enables network
|
||||
@@ -65,7 +63,7 @@ their checkout-free sessions can call only VoiceStudio's scoped API tool and can
|
||||
filesystem tools. The MCP process reads the capability from the protected connection file, so the
|
||||
token never appears in agent arguments, configuration or output.
|
||||
|
||||
Diagnosis and code repair still require a writable VoiceStudio source checkout and must follow its
|
||||
Explicit source diagnosis and code repair require a writable VoiceStudio source checkout and must follow its
|
||||
`AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, and installed skills. Source builds select the current
|
||||
checkout automatically. Packaged builds ask the user to choose a checkout only when source work is
|
||||
actually required and remember that location. The picker rejects read-only folders and unrelated
|
||||
@@ -76,8 +74,7 @@ request. A successful fix ends with a review prompt so the user can inspect the
|
||||
tests before deciding whether to propose a PR. An app-only recovery instead reports the verified
|
||||
application state and any remaining user input; it never presents branch or pull-request guidance.
|
||||
|
||||
A renderer crash opens the dock with its sanitized error and stack. The first crash asks which
|
||||
installed agent should become the default. Starting that fix stores the local choice; later renderer
|
||||
A renderer crash opens the dock with its sanitized error and stack. If no previously chosen harness is available, the prefilled chat waits for **Send**. Starting a run stores the local harness choice; later renderer
|
||||
crashes start the available default agent automatically. If a code repair lacks a source checkout or
|
||||
the selected agent is missing, the dock remains open and asks for that prerequisite instead of
|
||||
discarding the failure. App-only recovery does not show or require the source picker.
|
||||
@@ -85,7 +82,7 @@ Transient dynamic-module fetch failures perform one controlled renderer reload s
|
||||
actually refetches the chunk. A session marker survives that reload, so only the same repeated
|
||||
failure invokes agent recovery and no reload loop is possible.
|
||||
|
||||
Only one repair process runs at a time. **Stop** terminates its process tree. Output is retained for
|
||||
Only one agent process runs at a time, including preparation and translation sessions. **Stop** cancels preparation or terminates a running process tree. Output is retained for
|
||||
the current app session and streamed into the dock; JSON event formats from supported CLIs are
|
||||
reduced to readable agent and tool output.
|
||||
|
||||
@@ -102,7 +99,7 @@ smoke launches an agent or modifies the checkout.
|
||||
`electron/src/shared/repair-request.test.ts` pins the boundary between checkout-free app operations
|
||||
and source-gated diagnosis or code repair.
|
||||
`node electron/tests/packaged-repair-agent-smoke.mjs` launches the packaged application with a clean
|
||||
profile, confirms no source checkout is present, keeps explicit app-operation Diagnose/Fix enabled,
|
||||
profile, confirms no source checkout is present, keeps app-operation Send/Plan enabled,
|
||||
and verifies Electron main still rejects an ordinary source-repair request.
|
||||
`node electron/tests/packaged-repair-operation-acceptance.mjs <agent>` is the opt-in live acceptance
|
||||
for an installed CLI and an already-running packaged backend. It performs a read-only TTS readiness
|
||||
@@ -115,3 +112,11 @@ agent; acceptance with a native Linux CLI remains required.
|
||||
Bug reports include the scrubbed backend exception class when available, from
|
||||
streaming error frames or parsed API error responses. This distinguishes otherwise
|
||||
identical generic engine failures; absent or non-text class values are omitted.
|
||||
|
||||
|
||||
## Short audiobook test
|
||||
|
||||
Use `samples/audiobook-agent-smoke.txt`, an original four-paragraph narration fixture with clean
|
||||
paragraph breaks. Choose **Audiobook**, paste the text, and request a short preview with an
|
||||
existing saved voice. Verify the returned project and audio instead of starting a full book.
|
||||
This sample does not contain text from the copyrighted book supplied as a URL.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Electron runtime setup
|
||||
|
||||
The top bar places a GitHub Star button immediately right of Get Pro, loading GitHub's small public star-count response when shown and every 20 minutes while visible. Browser caching keeps repeat requests small; a bundled count remains visible if GitHub is unavailable. The request sends no credentials or referrer. The project website icon sits immediately left of X in the footer. The footer includes one X button with a hover popup of follow cards for @idebpalash and @voicestudiosh, plus a heart shortcut to donation options, immediately left of the footer collapse control. The profile popup also opens by click or keyboard; follow links open in the external browser. Profile cards span the popup and bundle their images locally for offline display. The heart opens compact cards with locally bundled brand icons for Ko-fi, PayPal, GitHub stars, and Discord, plus the full Support page. Both popups support click and keyboard access. Icons have localized accessible labels. The footer chevron collapses the bar; a slim expand control restores it. Get Pro remains in the top bar.
|
||||
|
||||
The development launcher watches main-process and preload source changes as well as renderer changes, rebuilding and restarting or reloading Electron to keep IPC contracts synchronized. After changing the launcher itself, stop and restart `bun run dev` once; a renderer refresh alone cannot update the running main process.
|
||||
|
||||
The development shell keeps DevTools closed by default. Open it with the normal
|
||||
Electron shortcut, or set `VOICESTUDIO_OPEN_DEVTOOLS=1` before `bun run dev`.
|
||||
This prevents Chromium's detached performance monitor from injecting failing
|
||||
@@ -21,7 +25,7 @@ Cancel stops the active process tree or download and returns to setup. Failure p
|
||||
|
||||
Verification: runtime regression tests cover consent gating, dependency changes, incomplete environments, failed repair, cancellation before download, and source replacement. `node electron/tests/packaged-smoke.mjs --setup` verifies the first-run view without initiating an installation. `--install` performs the explicit isolated runtime installation and verifies the packaged renderer's same-origin connection to its managed backend.
|
||||
|
||||
First run has four steps: System check, Model packs, Privacy, and Enter studio. Model packs reuse the existing performance tiers and install only missing supported models after an explicit click. Installed state, remaining download size, disk checks and installation progress appear in one place. Advanced mode reveals individual models, engine tuning, privacy settings and recovery tools; failed system checks still expose recovery without enabling Advanced. Dictation and its permissions are optional on the final step and remain available in Settings. Navigation requires passing preflight, required models and the privacy choice; entering the studio opens the first-voice demo. On narrow windows the step navigation moves above the content and the footer stays visible.
|
||||
First run has four steps: System check, Model packs, Privacy, and Enter studio. Model packs reuse the existing performance tiers and install only missing supported models after an explicit click. Installed state, remaining download size, disk checks and installation progress appear in one place. Advanced mode reveals individual models, engine tuning, privacy settings and recovery tools; failed system checks still expose recovery without enabling Advanced. The final Enter Studio step opens Advanced expanded, showing optional dictation permissions and shortcuts; either toggle can collapse them. Dictation and its permissions remain optional and available in Settings. Navigation requires passing preflight, required models and the privacy choice; entering the studio opens the first-voice demo. On narrow windows the step navigation moves above the content and the footer stays visible.
|
||||
|
||||
A subsequent packaged launch also reused that installed runtime and started a managed backend. Electron discovers compatible Tauri default, custom and portable runtime locations and can reuse them without a download when the interpreter and both frozen dependency manifests match. Its location record distinguishes reused environments from custom runtimes Electron creates. Uninstall includes only an Electron-owned custom runtime; it never claims or removes a reused Tauri runtime.
|
||||
|
||||
@@ -101,3 +105,13 @@ guidance before uv runs; dropping them would silently change routing semantics.
|
||||
Fully quit Electron, set an explicit proxy URL and the intended `NO_PROXY` exclusions in a terminal, then launch Electron from that same terminal. Retrying or relaunching from the existing process cannot pick up new environment variables. PAC remains handled by the existing system networking behavior; use the actual proxy protocol,
|
||||
not the `socks=` registry syntax. This repair applies to packaged runtime setup;
|
||||
it does not change browser networking or global Windows proxy settings.
|
||||
|
||||
## Home and sidebar
|
||||
|
||||
Home opens with the “VoiceStudio.sh Open Source” title and a right-aligned cluster of round avatars in up to three right-aligned, tapered rows filled from right to left (currently 5, 3, 2 icons) for human GitHub contributors with more than 10 repository commits. All qualifying avatars are shown; the final “All” icon links to the repository contributor page. The cluster scales down and the heading wraps as the window narrows. Contributor avatars link to their GitHub profiles; hovering or activating Palash's avatar opens both GitHub and X links. Keyboard and touch users can open that card with the avatar button. The contributor list and images are bundled offline in `features/home/home-contributors-data.ts` and `features/home/contributors/`, using the GitHub contributors API snapshot dated 2026-09-28; refresh both from that API when updating credits, excluding bots and counts of 10 or fewer. Home emphasizes Voice cloning, Design, and Dubbing, with compact horizontal cards for Stories, Audiobook, Gallery, Transcribe, Workflows, and Tools. Primary cards use restrained color accents and local vector illustrations; all cards have visible navigation arrows, keyboard focus states, and reduced-motion support. Each of the nine Home icons uses a local SVG illustrating its feature. CSS animates only transforms and opacity on hover or keyboard focus, for two 1.6-second cycles; idle icons and reduced-motion mode remain static. No timers, animation library, or continuous background loop is used. Descriptions explain each workflow; Stories creates multi-voice scenes, while Audiobook organizes chapters. Saved voices and projects remain on Home; Recent takes appears in the sidebar. Sidebar filters wrap at narrow widths and Clear remains an accessible icon button with confirmation. Engine controls show family labels, and the speed/quality slider displays its current tier.
|
||||
|
||||
The VoiceStudio title links to `https://voicestudio.sh`. Hover or keyboard focus shows a preview hint; clicking opens a modal below the clicked item, constrained to the right content area of the main window with an address bar, back/forward, reload/stop, and an **Open in browser** list. The list includes the system default and common browsers detected in standard installation locations (Windows and macOS) or executable PATH entries (Linux). Browser/web deployments open the title link in a new browser tab instead.
|
||||
|
||||
Website requests begin only after clicking. Remote pages use a sandboxed Electron WebContentsView with no preload, Node access, app bridge, or device permissions. Links and redirects stay restricted to HTTP(S); popup links reuse the same view. The browsing session is temporary and separate from app storage. The sidebar and workspace remain visible behind the modal. Its native page follows the modal bounds on resize and zoom, leaving the toolbar and surrounding workspace uncovered. **Back to Studio**, Escape, or clicking outside the modal disposes the embedded web contents, clears its session data, and restores the existing workspace and keyboard focus without navigating or remounting it. The browser can be reused for future cloud signup and other web flows; authentication callbacks are not implemented yet. Sites requiring device permissions or external authentication may need **Open in browser**. The browser list passes URLs as arguments to detected executables, never to a command shell.
|
||||
|
||||
The Dubbing workspace keeps its existing `/dub` route and saved project format; the name change affects visible feature labels only.
|
||||
|
||||
@@ -4,7 +4,7 @@ Engine availability depends on installed models, hardware, and configured provid
|
||||
|
||||
## Features
|
||||
|
||||
- **Voice Cloning**
|
||||
- **Voice Cloning** - [Audio quality, export precision, and local checks](audio-quality.md)
|
||||
- **Voice Design**
|
||||
- **Video Dubbing**
|
||||
- **Dictation Widget**
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Footer assets
|
||||
|
||||
Bundled locally so profile and support popups make no network requests.
|
||||
|
||||
- `palash.png`: public profile image from `https://github.com/debpalash.png?size=160`.
|
||||
- `voicestudio.jpg`: @voicestudiosh profile image retrieved through `https://unavatar.io/twitter/voicestudiosh`.
|
||||
- `kofi.svg`, `paypal.svg`, `github.svg`, `discord.svg`: Simple Icons brand marks, retrieved from `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/` on 2026-09-28. Simple Icons artwork is distributed under CC0; brand trademarks belong to their respective owners.
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="#5865f2" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#181717" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
|
After Width: | Height: | Size: 837 B |
@@ -0,0 +1 @@
|
||||
<svg fill="#ff6433" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ko-fi</title><path d="M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#0070ba" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PayPal</title><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"/></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -146,7 +146,7 @@ export function prepareMacDevElectron({
|
||||
return plan.destinationExecutable;
|
||||
}
|
||||
|
||||
function launchElectronVite() {
|
||||
export function launchElectronVite(args = process.argv.slice(2), spawnProcess = spawn) {
|
||||
const electronVitePackage = require.resolve('electron-vite/package.json');
|
||||
const { bin } = JSON.parse(readFileSync(electronVitePackage, 'utf8'));
|
||||
const electronViteBin = resolve(dirname(electronVitePackage), bin['electron-vite']);
|
||||
@@ -166,7 +166,7 @@ function launchElectronVite() {
|
||||
});
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, [electronViteBin, 'dev', ...process.argv.slice(2)], {
|
||||
const child = spawnProcess(process.execPath, [electronViteBin, 'dev', '--watch', ...args], {
|
||||
cwd: electronRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment node
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { BackendSupervisor } from './backend';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { registerRepairAgents, REPAIR_CHANNELS } from './repair-agents';
|
||||
const mock = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (...args: any[]) => any>(),
|
||||
spawn: vi.fn(),
|
||||
bridge: vi.fn(),
|
||||
close: vi.fn(async () => {}),
|
||||
output: vi.fn(),
|
||||
}));
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/temp', getAppPath: () => '/app' },
|
||||
BrowserWindow: {},
|
||||
dialog: {},
|
||||
ipcMain: {
|
||||
handle: (name: string, callback: (...args: any[]) => any) => mock.handlers.set(name, callback),
|
||||
removeHandler: (name: string) => mock.handlers.delete(name),
|
||||
},
|
||||
}));
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: () => true,
|
||||
accessSync: () => {},
|
||||
constants: { W_OK: 2 },
|
||||
readFileSync: vi.fn(),
|
||||
mkdtempSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: mock.spawn,
|
||||
spawnSync: () => ({
|
||||
status: 0,
|
||||
stdout: process.platform === 'win32' ? 'C:\\agents\\codex.exe' : '/usr/bin/codex',
|
||||
}),
|
||||
}));
|
||||
vi.mock('./repair-api-bridge', () => ({ startRepairApiBridge: mock.bridge }));
|
||||
vi.mock('./llm-agent-bridge', () => ({
|
||||
startLlmAgentBridge: async () => ({ url: '', token: '', close() {} }),
|
||||
}));
|
||||
vi.mock('./window-safety', () => ({ sendToLiveWindow: mock.output }));
|
||||
const frame = { url: 'app://voicestudio/index.html' };
|
||||
const contents = { mainFrame: frame };
|
||||
const owner = { webContents: contents };
|
||||
const event = { sender: contents, senderFrame: frame };
|
||||
const request = {
|
||||
agent: 'codex',
|
||||
mode: 'fix',
|
||||
workspace: 'app',
|
||||
report: 'Create a preview',
|
||||
context: '{}',
|
||||
};
|
||||
let dispose: (() => void) | undefined;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mock.bridge.mockResolvedValue({ contextFile: '/temp/isolated/context.json', close: mock.close });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, text: async () => '{}' })),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
const start = () => mock.handlers.get(REPAIR_CHANNELS.start)!(event, request);
|
||||
async function setup() {
|
||||
dispose = await registerRepairAgents(
|
||||
{
|
||||
baseUrl: 'http://localhost',
|
||||
requestHeaders: () => ({}),
|
||||
status: { stage: 'ready' },
|
||||
} as unknown as BackendSupervisor,
|
||||
'/source',
|
||||
() => owner as any,
|
||||
);
|
||||
}
|
||||
|
||||
it('cancels during setup and rejects a concurrent request before launching a process', async () => {
|
||||
let release!: (value: unknown) => void;
|
||||
mock.bridge.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
await setup();
|
||||
const first = start();
|
||||
await expect(start()).rejects.toThrow('already running');
|
||||
const stopped = mock.handlers.get(REPAIR_CHANNELS.stop)!(event);
|
||||
expect(stopped.status).toBe('stopped');
|
||||
release({ contextFile: '/temp/isolated/context.json', close: mock.close });
|
||||
await first;
|
||||
expect(mock.spawn).not.toHaveBeenCalled();
|
||||
expect(mock.close).toHaveBeenCalled();
|
||||
expect(mock.handlers.get(REPAIR_CHANNELS.state)!(event).status).toBe('stopped');
|
||||
});
|
||||
it('launches app chat outside the checkout and closes its capability after streaming completion', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: Object.assign(new EventEmitter(), { end: vi.fn() }),
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
kill: vi.fn(),
|
||||
});
|
||||
mock.spawn.mockReturnValue(child);
|
||||
await setup();
|
||||
const result = await start();
|
||||
expect(mock.spawn.mock.calls[0][2].cwd.replaceAll('\\', '/')).toBe('/temp/isolated');
|
||||
expect(child.stdin.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No source checkout is attached'),
|
||||
);
|
||||
child.stdout.emit('data', Buffer.from('Preview ready'));
|
||||
child.emit('close', 0);
|
||||
expect(mock.output).toHaveBeenCalledWith(
|
||||
owner,
|
||||
REPAIR_CHANNELS.event,
|
||||
expect.objectContaining({ sessionId: result.sessionId, type: 'output', text: 'Preview ready' }),
|
||||
);
|
||||
expect(mock.handlers.get(REPAIR_CHANNELS.state)!(event).status).toBe('complete');
|
||||
expect(mock.close).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { agentUsesAppWorkspace, validateAgentWorkspace } from '../shared/agent-workspace';
|
||||
import { requestPrompt } from './repair-agents';
|
||||
|
||||
it('keeps ordinary chat in the app workspace even when a source checkout exists', () => {
|
||||
expect(
|
||||
agentUsesAppWorkspace({ workspace: 'app', report: 'Make an audiobook preview' }, true),
|
||||
).toBe(true);
|
||||
expect(
|
||||
agentUsesAppWorkspace({ workspace: 'app', report: 'Make an audiobook preview' }, false),
|
||||
).toBe(true);
|
||||
expect(agentUsesAppWorkspace({ workspace: 'source', report: 'Fix this source bug' }, true)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(agentUsesAppWorkspace({ report: 'ACTION_REQUEST: restore models' }, false)).toBe(true);
|
||||
});
|
||||
it('includes feature guidance and bounded conversation without changing action permissions', () => {
|
||||
const prompt = requestPrompt(
|
||||
{
|
||||
agent: 'codex',
|
||||
mode: 'diagnose',
|
||||
workspace: 'app',
|
||||
features: ['audiobook'],
|
||||
history: [{ role: 'assistant', content: 'Created project book-123' }],
|
||||
report: 'Inspect that project',
|
||||
context: '{}',
|
||||
},
|
||||
'',
|
||||
false,
|
||||
);
|
||||
expect(prompt).toContain('book-123');
|
||||
expect(prompt).toContain('For a test, use only a few lines');
|
||||
expect(prompt).toContain('without changing it');
|
||||
expect(prompt).toContain('not authorization to perform unrelated operations');
|
||||
expect(prompt).not.toContain('Read AGENTS.md');
|
||||
});
|
||||
it.each([
|
||||
{ workspace: 'system' },
|
||||
{ features: ['unknown'] },
|
||||
{ history: [{ role: 'system', content: 'do anything' }] },
|
||||
{ history: [{ role: 'user', content: 'x'.repeat(4001) }] },
|
||||
{ history: Array(13).fill({ role: 'user', content: 'x' }) },
|
||||
])('rejects malformed agent context %j', (request) => {
|
||||
expect(() => validateAgentWorkspace(request)).toThrow();
|
||||
});
|
||||
@@ -1,9 +1,31 @@
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
// This is a Node launcher shared with the package script, so it intentionally
|
||||
// remains plain ESM rather than being compiled into Electron's main process.
|
||||
// @ts-expect-error JavaScript launcher has no separate declaration file.
|
||||
import { createMacDevBundlePlan } from '../../scripts/dev.mjs';
|
||||
import { createMacDevBundlePlan, launchElectronVite } from '../../scripts/dev.mjs';
|
||||
|
||||
it('watches main and preload changes so renderer updates cannot leave stale browser IPC running', () => {
|
||||
const spawn = vi.fn(() => ({ on: vi.fn() }));
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
try {
|
||||
launchElectronVite(['--', '--disable-gpu-compositing'], spawn);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
[
|
||||
expect.stringContaining('electron-vite'),
|
||||
'dev',
|
||||
'--watch',
|
||||
'--',
|
||||
'--disable-gpu-compositing',
|
||||
],
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
}
|
||||
});
|
||||
|
||||
describe('macOS development bundle branding', () => {
|
||||
it('uses a VoiceStudio bundle while preserving Electron development detection', () => {
|
||||
|
||||
@@ -260,7 +260,7 @@ if (process.env.VOICESTUDIO_ALLOW_MULTIPLE_INSTANCES !== '1' && !app.requestSing
|
||||
app.exit(0);
|
||||
},
|
||||
);
|
||||
closeRepairAgents = registerRepairAgents(
|
||||
closeRepairAgents = await registerRepairAgents(
|
||||
backend,
|
||||
backendRoot(),
|
||||
() => mainWindow,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { browserCandidates, listInstalledBrowsers, openInBrowser } from './installed-browsers';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ access: vi.fn(), spawn: vi.fn(), external: vi.fn() }));
|
||||
vi.mock('node:fs/promises', () => ({ access: mocks.access }));
|
||||
vi.mock('node:child_process', () => ({ spawn: mocks.spawn }));
|
||||
vi.mock('electron', () => ({ shell: { openExternal: mocks.external } }));
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.access.mockRejectedValue(new Error('missing'));
|
||||
});
|
||||
|
||||
it('discovers native locations on all three platforms regardless of test host', () => {
|
||||
expect(
|
||||
browserCandidates(
|
||||
'win32',
|
||||
{ LOCALAPPDATA: 'C:\\Users\\User\\AppData\\Local', ProgramFiles: 'C:\\Program Files' },
|
||||
'C:\\Users\\User',
|
||||
),
|
||||
).toContainEqual({
|
||||
id: 'edge',
|
||||
name: 'Microsoft Edge',
|
||||
path: 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
});
|
||||
expect(browserCandidates('darwin', {}, '/Users/user')).toContainEqual({
|
||||
id: 'chrome',
|
||||
name: 'Google Chrome',
|
||||
path: '/Users/user/Applications/Google Chrome.app',
|
||||
});
|
||||
const linux = browserCandidates('linux', { PATH: '/usr/bin:.:relative:/snap/bin' }, '/home/user');
|
||||
expect(linux).toContainEqual({
|
||||
id: 'chromium',
|
||||
name: 'Chromium',
|
||||
path: '/snap/bin/chromium-browser',
|
||||
});
|
||||
expect(linux.every((candidate) => candidate.path.startsWith('/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('lists only installed browsers, deduplicates them, and hides executable paths', async () => {
|
||||
mocks.access.mockImplementation(async (path: string) => {
|
||||
if (!/google[\\/ -]chrome/i.test(path)) throw new Error('missing');
|
||||
});
|
||||
expect(await listInstalledBrowsers()).toEqual([{ id: 'chrome', name: 'Google Chrome' }]);
|
||||
});
|
||||
|
||||
it('always supports the default browser and rejects arbitrary executables and schemes', async () => {
|
||||
await openInBrowser('default', 'https://voicestudio.sh');
|
||||
expect(mocks.external).toHaveBeenCalledWith('https://voicestudio.sh/');
|
||||
await expect(openInBrowser('cmd.exe', 'https://example.com')).rejects.toThrow();
|
||||
await expect(openInBrowser('default', 'file:///private')).rejects.toThrow();
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes URLs as a single argument to a detected browser without a command shell', async () => {
|
||||
mocks.access.mockImplementation(async (path: string) => {
|
||||
if (!/google[\\/ -]chrome/i.test(path)) throw new Error('missing');
|
||||
});
|
||||
mocks.spawn.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), { unref: vi.fn() });
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
});
|
||||
await openInBrowser('chrome', 'https://example.com/?q=$(unsafe)&a=2');
|
||||
const [, args, options] = mocks.spawn.mock.calls[0];
|
||||
expect(args.at(-1)).toBe('https://example.com/?q=$(unsafe)&a=2');
|
||||
expect(options.shell).not.toBe(true);
|
||||
expect(options.windowsHide).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { access } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { win32, posix } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { shell } from 'electron';
|
||||
import { browserUrl, type InstalledBrowser } from '../shared/site-browser';
|
||||
|
||||
interface BrowserApp extends InstalledBrowser {
|
||||
path: string;
|
||||
}
|
||||
const brands = [
|
||||
[
|
||||
'chrome',
|
||||
'Google Chrome',
|
||||
'Google/Chrome/Application/chrome.exe',
|
||||
'Google Chrome.app',
|
||||
'google-chrome',
|
||||
],
|
||||
[
|
||||
'edge',
|
||||
'Microsoft Edge',
|
||||
'Microsoft/Edge/Application/msedge.exe',
|
||||
'Microsoft Edge.app',
|
||||
'microsoft-edge',
|
||||
],
|
||||
['firefox', 'Firefox', 'Mozilla Firefox/firefox.exe', 'Firefox.app', 'firefox'],
|
||||
[
|
||||
'brave',
|
||||
'Brave',
|
||||
'BraveSoftware/Brave-Browser/Application/brave.exe',
|
||||
'Brave Browser.app',
|
||||
'brave-browser',
|
||||
],
|
||||
['vivaldi', 'Vivaldi', 'Vivaldi/Application/vivaldi.exe', 'Vivaldi.app', 'vivaldi'],
|
||||
['opera', 'Opera', 'Programs/Opera/opera.exe', 'Opera.app', 'opera'],
|
||||
['chromium', 'Chromium', 'Chromium/Application/chrome.exe', 'Chromium.app', 'chromium'],
|
||||
['zen', 'Zen', 'Zen Browser/zen.exe', 'Zen.app', 'zen-browser'],
|
||||
] as const;
|
||||
|
||||
/** Known browser executables only; the renderer never supplies commands or paths. */
|
||||
export function browserCandidates(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
home: string,
|
||||
): BrowserApp[] {
|
||||
const candidates: BrowserApp[] = [];
|
||||
const { join, isAbsolute } = platform === 'win32' ? win32 : posix;
|
||||
for (const [id, name, windows, mac, linux] of brands) {
|
||||
const paths =
|
||||
platform === 'win32'
|
||||
? [env.LOCALAPPDATA, env.ProgramFiles, env['ProgramFiles(x86)']]
|
||||
.filter((root): root is string => Boolean(root && isAbsolute(root)))
|
||||
.map((root) => join(root, ...windows.split('/')))
|
||||
: platform === 'darwin'
|
||||
? ['/Applications', join(home, 'Applications')].map((root) => join(root, mac))
|
||||
: (env.PATH ?? '/usr/bin:/usr/local/bin:/snap/bin')
|
||||
.split(':')
|
||||
.filter(isAbsolute)
|
||||
.flatMap((root) => [
|
||||
join(root, linux),
|
||||
...(id === 'chromium'
|
||||
? [join(root, 'chromium-browser')]
|
||||
: id === 'chrome'
|
||||
? [join(root, 'google-chrome-stable')]
|
||||
: []),
|
||||
]);
|
||||
for (const path of paths) candidates.push({ id, name, path });
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
candidates.push({ id: 'safari', name: 'Safari', path: '/Applications/Safari.app' });
|
||||
candidates.push({ id: 'arc', name: 'Arc', path: '/Applications/Arc.app' });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function detectedBrowsers(): Promise<BrowserApp[]> {
|
||||
const candidates = browserCandidates(process.platform, process.env, homedir());
|
||||
const results = await Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
try {
|
||||
await access(
|
||||
candidate.path,
|
||||
process.platform === 'linux' ? constants.X_OK : constants.F_OK,
|
||||
);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const found = new Map<string, BrowserApp>();
|
||||
for (const candidate of results)
|
||||
if (candidate && !found.has(candidate.id)) found.set(candidate.id, candidate);
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
export async function listInstalledBrowsers(): Promise<InstalledBrowser[]> {
|
||||
return (await detectedBrowsers()).map(({ id, name }) => ({ id, name }));
|
||||
}
|
||||
|
||||
export async function openInBrowser(id: unknown, rawUrl: unknown): Promise<void> {
|
||||
const url = browserUrl(rawUrl);
|
||||
if (id === 'default') {
|
||||
await shell.openExternal(url);
|
||||
return;
|
||||
}
|
||||
const browser = (await detectedBrowsers()).find((item) => item.id === id);
|
||||
if (!browser) throw new Error('Browser is no longer installed');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child =
|
||||
process.platform === 'darwin'
|
||||
? spawn('/usr/bin/open', ['-a', browser.path, url], { stdio: 'ignore', windowsHide: true })
|
||||
: spawn(browser.path, [url], { stdio: 'ignore', windowsHide: true, shell: false });
|
||||
child.once('error', reject);
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { registerSiteBrowser } from './site-browser';
|
||||
import { saveFiltersFor } from './save-filters';
|
||||
import { resolveBackendDownloadUrl } from './backend-download';
|
||||
import {
|
||||
@@ -314,6 +315,7 @@ export function registerIpc(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
exitForUninstall: () => Promise<void> = async () => app.exit(0),
|
||||
): void {
|
||||
registerSiteBrowser(getMainWindow);
|
||||
let maintenanceBusy = false;
|
||||
let relocationSelection: {
|
||||
authorization: string;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
startLlmAgentBridge,
|
||||
validateAgentCompletion,
|
||||
type AgentCompletionRequest,
|
||||
} from './llm-agent-bridge';
|
||||
|
||||
const request = {
|
||||
agent: 'codex' as const,
|
||||
model: '',
|
||||
messages: [{ role: 'user', content: 'Translate hello' }],
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
describe('shared CLI completion bridge', () => {
|
||||
it('requires its private capability and refuses browser origins', async () => {
|
||||
const complete = vi.fn(async (_request: AgentCompletionRequest) => 'Bonjour');
|
||||
const bridge = await startLlmAgentBridge(complete);
|
||||
try {
|
||||
expect((await fetch(bridge.url + '/complete', { method: 'POST' })).status).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await fetch(bridge.url + '/complete', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + bridge.token, Origin: 'http://localhost' },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
const response = await fetch(bridge.url + '/complete', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + bridge.token },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
expect(await response.json()).toEqual({ text: 'Bonjour' });
|
||||
expect(complete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agent: request.agent, messages: request.messages }),
|
||||
);
|
||||
expect(complete.mock.calls[0][0].timeoutMs).toBeLessThanOrEqual(request.timeoutMs);
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
it('rejects arbitrary commands, flags, roles and unbounded execution', () => {
|
||||
for (const patch of [
|
||||
{ agent: 'powershell' },
|
||||
{ model: '--unsafe' },
|
||||
{ timeoutMs: 0 },
|
||||
{ timeoutMs: Infinity },
|
||||
{ messages: [{ role: 'tool', content: 'x' }] },
|
||||
]) {
|
||||
expect(() => validateAgentCompletion({ ...request, ...patch })).toThrow();
|
||||
}
|
||||
});
|
||||
it('serializes concurrent skill calls and preserves account error classification', async () => {
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const bridge = await startLlmAgentBridge(async (body) => {
|
||||
maximum = Math.max(maximum, ++active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
active -= 1;
|
||||
if (body.model === 'rejected')
|
||||
throw Object.assign(new Error('private provider output'), {
|
||||
name: 'AgentAuthenticationError',
|
||||
});
|
||||
return 'ok';
|
||||
});
|
||||
const call = (model = '') =>
|
||||
fetch(bridge.url + '/complete', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + bridge.token },
|
||||
body: JSON.stringify({ ...request, model }),
|
||||
});
|
||||
try {
|
||||
const results = await Promise.all([call(), call(), call('rejected')]);
|
||||
expect(results.map((response) => response.status)).toEqual([200, 200, 401]);
|
||||
expect(maximum).toBe(1);
|
||||
expect(await results[2].text()).not.toContain('private provider output');
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a shared agent runner busy state as retryable', async () => {
|
||||
const bridge = await startLlmAgentBridge(async () => {
|
||||
throw Object.assign(new Error('An agent is already running'), { name: 'AgentRateLimitError' });
|
||||
});
|
||||
try {
|
||||
const response = await fetch(bridge.url + '/complete', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + bridge.token },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
expect(response.status).toBe(429);
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import type { RepairAgentId } from '../preload/index.d';
|
||||
|
||||
export interface AgentCompletionRequest {
|
||||
agent: RepairAgentId;
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export function validateAgentCompletion(value: unknown): AgentCompletionRequest {
|
||||
const body = value as AgentCompletionRequest;
|
||||
if (
|
||||
!body ||
|
||||
!['codex', 'claude', 'pi', 'opencode'].includes(body.agent) ||
|
||||
typeof body.model !== 'string' ||
|
||||
body.model.length > 200 ||
|
||||
(body.model !== '' && !/^[a-zA-Z0-9][\w./:@+-]*$/.test(body.model)) ||
|
||||
!Array.isArray(body.messages) ||
|
||||
body.messages.length < 1 ||
|
||||
body.messages.length > 100 ||
|
||||
body.messages.some(
|
||||
(m) =>
|
||||
!m || !['system', 'user', 'assistant'].includes(m.role) || typeof m.content !== 'string',
|
||||
) ||
|
||||
!Number.isFinite(body.timeoutMs) ||
|
||||
body.timeoutMs < 1000 ||
|
||||
body.timeoutMs > 600_000
|
||||
) {
|
||||
throw new Error('Invalid agent completion request');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Backend-only capability; no browser CORS, no arbitrary commands or repair API access. */
|
||||
export async function startLlmAgentBridge(
|
||||
complete: (request: AgentCompletionRequest) => Promise<string>,
|
||||
) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expected = Buffer.from('Bearer ' + token);
|
||||
let tail = Promise.resolve();
|
||||
let queued = 0;
|
||||
let closed = false;
|
||||
const server = createServer(async (req, res) => {
|
||||
const supplied = Buffer.from(req.headers.authorization || '');
|
||||
const send = (status: number, data: unknown) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
if (
|
||||
supplied.length !== expected.length ||
|
||||
!timingSafeEqual(supplied, expected) ||
|
||||
req.headers.origin
|
||||
) {
|
||||
send(403, { error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST' || req.url !== '/complete') {
|
||||
send(404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 500_000) {
|
||||
send(413, { error: 'Request too large' });
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let body: AgentCompletionRequest;
|
||||
try {
|
||||
body = validateAgentCompletion(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
send(400, { error: 'Invalid request' });
|
||||
return;
|
||||
}
|
||||
if (queued >= 8 || closed) {
|
||||
send(429, { error: 'Agent is busy' });
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + body.timeoutMs;
|
||||
queued += 1;
|
||||
const task = tail.then(async () => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (closed || res.destroyed || remaining < 1000) throw new Error('Agent request expired');
|
||||
return complete({ ...body, timeoutMs: remaining });
|
||||
});
|
||||
tail = task
|
||||
.then(
|
||||
() => {},
|
||||
() => {},
|
||||
)
|
||||
.finally(() => {
|
||||
queued -= 1;
|
||||
});
|
||||
const text = await task;
|
||||
send(200, { text });
|
||||
} catch (error) {
|
||||
// Never expose subprocess output, login tokens, or source dialogue in errors.
|
||||
const status =
|
||||
error instanceof Error
|
||||
? (
|
||||
{
|
||||
AgentAuthenticationError: 401,
|
||||
AgentRateLimitError: 429,
|
||||
AgentModelError: 404,
|
||||
} as Record<string, number>
|
||||
)[error.name]
|
||||
: undefined;
|
||||
send(status ?? 502, { error: 'Agent completion failed. Check CLI installation and login.' });
|
||||
}
|
||||
});
|
||||
server.requestTimeout = 30_000;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Agent bridge did not bind');
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
token,
|
||||
close: () => {
|
||||
closed = true;
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
agentProviderFailure,
|
||||
agentCommandMatchesPlatform,
|
||||
dubTranslationPrompt,
|
||||
guardAgentProcessStreams,
|
||||
@@ -21,6 +22,22 @@ const request = {
|
||||
};
|
||||
|
||||
describe('packaged app repair sessions', () => {
|
||||
it('classifies provider account failures without forwarding their output', () => {
|
||||
expect(
|
||||
agentProviderFailure(
|
||||
'{"type":"assistant","is_api_error_message":true,"error":"account_on_hold"}',
|
||||
)?.name,
|
||||
).toBe('AgentAuthenticationError');
|
||||
expect(
|
||||
agentProviderFailure(
|
||||
'{"type":"error","error":{"data":{"statusCode":403,"message":"private"}}}',
|
||||
)?.name,
|
||||
).toBe('AgentAuthenticationError');
|
||||
expect(agentProviderFailure('{"type":"error","error":{"data":{"statusCode":429}}}')?.name).toBe(
|
||||
'AgentRateLimitError',
|
||||
);
|
||||
expect(agentProviderFailure('{"translations":[{"id":"reply","text":"ok"}]}')).toBeNull();
|
||||
});
|
||||
it('contains prompt pipe closures instead of raising uncaught process errors', () => {
|
||||
const stdin = new EventEmitter();
|
||||
const stdout = new EventEmitter();
|
||||
@@ -127,7 +144,9 @@ describe('packaged app repair sessions', () => {
|
||||
const prompt = requestPrompt(request, 'live diagnostics', false);
|
||||
|
||||
expect(prompt).toContain('No source checkout is attached');
|
||||
expect(prompt).toContain('Complete only the explicit ACTION_REQUEST');
|
||||
expect(prompt).toContain(
|
||||
"Complete the user's current request through VoiceStudio's app API bridge",
|
||||
);
|
||||
expect(prompt).toContain('VOICESTUDIO_REPAIR_CONTEXT_FILE');
|
||||
expect(prompt).toContain('Do not ask the user to repeat actions the API can perform');
|
||||
expect(prompt).toContain('required model downloads and engine selection');
|
||||
|
||||
+238
-130
@@ -15,8 +15,9 @@ import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'el
|
||||
import type { BackendSupervisor } from './backend';
|
||||
import { startRepairApiBridge, type RepairApiBridge } from './repair-api-bridge';
|
||||
import { isTrustedRenderer } from './trusted-renderer';
|
||||
import { isAppOperationRequest } from '../shared/repair-request';
|
||||
import { agentUsesAppWorkspace, featureGuidance, validateAgentWorkspace } from '../shared/agent-workspace';
|
||||
import { sendToLiveWindow } from './window-safety';
|
||||
import { startLlmAgentBridge } from './llm-agent-bridge';
|
||||
import type {
|
||||
RepairAgentEvent,
|
||||
RepairAgentId,
|
||||
@@ -357,13 +358,19 @@ function validateDubTranslationRequest(
|
||||
): asserts value is DubAgentTranslationRequest {
|
||||
if (!value || typeof value !== 'object') throw new Error('Invalid agent translation request');
|
||||
const request = value as DubAgentTranslationRequest;
|
||||
if (request.requestId !== undefined && (typeof request.requestId !== 'string' || request.requestId.length > 100))
|
||||
if (
|
||||
request.requestId !== undefined &&
|
||||
(typeof request.requestId !== 'string' || request.requestId.length > 100)
|
||||
)
|
||||
throw new Error('Invalid translation request id');
|
||||
if (!DEFINITIONS.some((item) => item.id === request.agent)) throw new Error('Unknown agent');
|
||||
if (request.purpose !== 'translate' && request.purpose !== 'fit')
|
||||
throw new Error('Invalid agent translation purpose');
|
||||
if (request.translationInstructions !== undefined &&
|
||||
(typeof request.translationInstructions !== 'string' || request.translationInstructions.length > 5000))
|
||||
if (
|
||||
request.translationInstructions !== undefined &&
|
||||
(typeof request.translationInstructions !== 'string' ||
|
||||
request.translationInstructions.length > 5000)
|
||||
)
|
||||
throw new Error('Invalid translation instructions');
|
||||
if (!request.targetLanguage?.trim() || request.targetLanguage.length > 100)
|
||||
throw new Error('Invalid target language');
|
||||
@@ -430,6 +437,9 @@ export function translationLaunchArgs(agent: RepairAgentId): string[] {
|
||||
'read-only',
|
||||
'--ephemeral',
|
||||
'--skip-git-repo-check',
|
||||
'--ignore-user-config',
|
||||
'-c',
|
||||
'features.shell_tool=false',
|
||||
'--color',
|
||||
'never',
|
||||
'-',
|
||||
@@ -446,9 +456,42 @@ export function translationLaunchArgs(agent: RepairAgentId): string[] {
|
||||
'--tools',
|
||||
'',
|
||||
'--no-session-persistence',
|
||||
'--strict-mcp-config',
|
||||
'--mcp-config',
|
||||
'{"mcpServers":{}}',
|
||||
];
|
||||
if (agent === 'opencode') return ['run', '--format', 'json', '--pure'];
|
||||
return ['--print', '--mode', 'json'];
|
||||
return [
|
||||
'--print',
|
||||
'--mode',
|
||||
'json',
|
||||
'--no-tools',
|
||||
'--no-extensions',
|
||||
'--no-skills',
|
||||
'--no-prompt-templates',
|
||||
'--no-context-files',
|
||||
'--no-session',
|
||||
'--offline',
|
||||
];
|
||||
}
|
||||
|
||||
export function agentProviderFailure(output: string): Error | null {
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type !== 'error' && !event.is_api_error_message) continue;
|
||||
const status = event.error?.data?.statusCode ?? event.api_error_status;
|
||||
let name = '';
|
||||
if (status === 401 || status === 403 || event.error === 'account_on_hold')
|
||||
name = 'AgentAuthenticationError';
|
||||
else if (status === 429) name = 'AgentRateLimitError';
|
||||
else if (status === 404) name = 'AgentModelError';
|
||||
if (name) return Object.assign(new Error('Agent provider rejected the request'), { name });
|
||||
} catch {
|
||||
/* Non-JSON progress is not a provider error. */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsonObjects(text: string): unknown[] {
|
||||
@@ -576,13 +619,14 @@ export function requestPrompt(
|
||||
context: string,
|
||||
sourceAttached = true,
|
||||
): string {
|
||||
validateAgentWorkspace(request);
|
||||
const task =
|
||||
request.report.trim().slice(0, MAX_REPORT) ||
|
||||
'Find the current VoiceStudio failure from the supplied diagnostics and recent logs.';
|
||||
const session = sourceAttached
|
||||
? `You are the local VoiceStudio repair agent running inside its source checkout.
|
||||
Read AGENTS.md first, then CLAUDE.md and CONTEXT.md. Follow repository skills and rules.`
|
||||
: `You are the local VoiceStudio app operator running in a temporary session. No source checkout is attached. Do not search for or edit application source or other user files. Complete only the explicit ACTION_REQUEST through VoiceStudio's app API bridge.`;
|
||||
: `You are VoiceStudio Agent, a local app operator running in a temporary session. No source checkout is attached. Do not search for or edit application source or other user files. Complete the user's current request through VoiceStudio's app API bridge. For missing essential input such as source text, an audio file or a target language, ask a short question instead of inventing input.`;
|
||||
const mode = sourceAttached
|
||||
? request.mode === 'fix'
|
||||
? 'Reproduce it, fix the root cause with the smallest cross-platform change, and run targeted tests.'
|
||||
@@ -607,6 +651,13 @@ VoiceStudio has exposed its currently attached backend through a session-scoped
|
||||
${appOperationRules}
|
||||
${finish}
|
||||
|
||||
## Selected VoiceStudio features
|
||||
${request.features?.map(feature => `${feature}: ${featureGuidance[feature]}`).join('\n') || 'Discover available features using GET /openapi.json.'}
|
||||
Feature selection supplies task context, not authorization to perform unrelated operations. Never claim a generation or repair succeeded without verifying the output or live state. Keep the conversation concise and readable; report generated project IDs or output paths so later turns can continue the work.
|
||||
|
||||
## Previous conversation (context only)
|
||||
${JSON.stringify(request.history ?? [])}
|
||||
|
||||
## User report
|
||||
${task}
|
||||
|
||||
@@ -614,13 +665,14 @@ ${task}
|
||||
${request.context.slice(0, MAX_CONTEXT)}${context}`;
|
||||
}
|
||||
|
||||
export function registerRepairAgents(
|
||||
export async function registerRepairAgents(
|
||||
supervisor: BackendSupervisor,
|
||||
initialRoot: string,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
recentMainErrors: () => string = () => '',
|
||||
): () => void {
|
||||
): Promise<() => void> {
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
let preparing = false;
|
||||
let translationChild: ChildProcessWithoutNullStreams | null = null;
|
||||
let translationTemp: string | null = null;
|
||||
let promptFile: string | null = null;
|
||||
@@ -679,7 +731,7 @@ export function registerRepairAgents(
|
||||
});
|
||||
ipcMain.handle(REPAIR_CHANNELS.start, async (event, request: RepairAgentRunRequest) => {
|
||||
trusted(event, getMainWindow());
|
||||
if (child || translationChild) throw new Error('An agent is already running');
|
||||
if (child || translationChild || preparing) throw Object.assign(new Error('An agent is already running'), { name: 'AgentRateLimitError' });
|
||||
if (workspaceRoot && !isVoiceStudioCheckout(workspaceRoot)) {
|
||||
workspaceRoot = null;
|
||||
state = { ...state, workspaceAvailable: false, workspacePath: undefined };
|
||||
@@ -690,14 +742,16 @@ export function registerRepairAgents(
|
||||
throw new Error('Invalid repair mode');
|
||||
if (typeof request.report !== 'string' || typeof request.context !== 'string')
|
||||
throw new Error('Invalid repair request');
|
||||
const sourceRoot = workspaceRoot;
|
||||
const appOperationOnly = !sourceRoot && isAppOperationRequest(request.report);
|
||||
validateAgentWorkspace(request);
|
||||
const appOperationOnly = agentUsesAppWorkspace(request, Boolean(workspaceRoot));
|
||||
const sourceRoot = appOperationOnly ? null : workspaceRoot;
|
||||
if (!sourceRoot && !appOperationOnly)
|
||||
throw new Error('A writable VoiceStudio source checkout is required');
|
||||
const command = commands.get(request.agent) ?? locate(request.agent);
|
||||
if (!command) throw new Error('That repair agent is not installed');
|
||||
|
||||
const sessionId = randomUUID();
|
||||
preparing = true;
|
||||
state = {
|
||||
...state,
|
||||
sessionId,
|
||||
@@ -730,6 +784,11 @@ export function registerRepairAgents(
|
||||
await diagnosticContext(supervisor, recentMainErrors),
|
||||
Boolean(sourceRoot),
|
||||
);
|
||||
if (state.status === 'stopped') {
|
||||
closeRepairBridge(apiBridge);
|
||||
apiBridge = null;
|
||||
return { sessionId };
|
||||
}
|
||||
const args = [
|
||||
...command.prefix,
|
||||
...launchArgs(request.agent, request.mode, appOperationOnly, apiBridge.mcpConfigFile),
|
||||
@@ -746,6 +805,9 @@ export function registerRepairAgents(
|
||||
NO_COLOR: '1',
|
||||
FORCE_COLOR: '0',
|
||||
VOICESTUDIO_REPAIR_CONTEXT_FILE: apiBridge.contextFile,
|
||||
VOICESTUDIO_LLM_AGENT_TOKEN: undefined,
|
||||
VOICESTUDIO_LLM_AGENT_URL: undefined,
|
||||
...(command.prefix.length ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
|
||||
...(request.agent === 'opencode' && apiBridge.openCodeConfigFile
|
||||
? { OPENCODE_CONFIG: apiBridge.openCodeConfigFile }
|
||||
: {}),
|
||||
@@ -785,16 +847,20 @@ export function registerRepairAgents(
|
||||
await apiBridge?.close();
|
||||
apiBridge = null;
|
||||
child = null;
|
||||
state = { ...state, status: 'failed' };
|
||||
emit({ sessionId, type: 'state', status: 'failed' });
|
||||
const status = state.status === 'stopped' ? 'stopped' : 'failed';
|
||||
state = { ...state, status };
|
||||
emit({ sessionId, type: 'state', status });
|
||||
if (status === 'stopped') return { sessionId };
|
||||
throw error;
|
||||
} finally {
|
||||
preparing = false;
|
||||
}
|
||||
});
|
||||
ipcMain.handle(REPAIR_CHANNELS.stop, (event) => {
|
||||
trusted(event, getMainWindow());
|
||||
if (!child || !state.sessionId) return state;
|
||||
if ((!child && !preparing) || !state.sessionId) return state;
|
||||
const sessionId = state.sessionId;
|
||||
terminateAgentProcess(child);
|
||||
if (child) terminateAgentProcess(child);
|
||||
closeRepairBridge(apiBridge);
|
||||
apiBridge = null;
|
||||
state = { ...state, status: 'stopped' };
|
||||
@@ -802,122 +868,156 @@ export function registerRepairAgents(
|
||||
return state;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
REPAIR_CHANNELS.translate,
|
||||
async (event, request: DubAgentTranslationRequest): Promise<DubAgentTranslationResult> => {
|
||||
trusted(event, getMainWindow());
|
||||
validateDubTranslationRequest(request);
|
||||
if (child || translationChild) throw new Error('An agent is already running');
|
||||
const definition = DEFINITIONS.find((item) => item.id === request.agent)!;
|
||||
const command = commands.get(request.agent) ?? locate(definition.command);
|
||||
if (!command) throw new Error('That agent is not installed');
|
||||
const translate = async (
|
||||
request: DubAgentTranslationRequest,
|
||||
promptOverride?: string,
|
||||
model = '',
|
||||
timeoutMs = 10 * 60 * 1_000,
|
||||
): Promise<DubAgentTranslationResult> => {
|
||||
validateDubTranslationRequest(request);
|
||||
if (child || translationChild || preparing) throw Object.assign(new Error('An agent is already running'), { name: 'AgentRateLimitError' });
|
||||
const definition = DEFINITIONS.find((item) => item.id === request.agent)!;
|
||||
const command = commands.get(request.agent) ?? locate(definition.command);
|
||||
if (!command) throw new Error('That agent is not installed');
|
||||
|
||||
const prompt = dubTranslationPrompt(request);
|
||||
const sessionId = randomUUID();
|
||||
translationTemp = mkdtempSync(join(app.getPath('temp'), 'voicestudio-dub-agent-'));
|
||||
const promptPath = join(translationTemp, `${sessionId}.md`);
|
||||
const openCodeConfigPath = join(translationTemp, 'opencode.json');
|
||||
const args = [...command.prefix, ...translationLaunchArgs(request.agent)];
|
||||
if (request.agent === 'opencode') {
|
||||
writeFileSync(promptPath, prompt, 'utf8');
|
||||
writeFileSync(
|
||||
openCodeConfigPath,
|
||||
JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
permission: { '*': 'deny' },
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
args.push(
|
||||
'Translate the attached dubbing segments and return only the requested JSON.',
|
||||
'--file',
|
||||
promptPath,
|
||||
);
|
||||
}
|
||||
let output = '';
|
||||
const stdoutDecoder = new StringDecoder('utf8');
|
||||
const stderrDecoder = new StringDecoder('utf8');
|
||||
let pendingLog = '';
|
||||
let logTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flushLog = () => {
|
||||
if (logTimer) clearTimeout(logTimer);
|
||||
logTimer = undefined;
|
||||
if (pendingLog && request.requestId)
|
||||
sendToLiveWindow(getMainWindow(), REPAIR_CHANNELS.translationEvent,
|
||||
{ requestId: request.requestId, text: pendingLog });
|
||||
pendingLog = '';
|
||||
};
|
||||
const append = (text: string, stdout = true) => {
|
||||
if (stdout) output = (output + text).slice(-MAX_TRANSLATION_OUTPUT);
|
||||
pendingLog = (pendingLog + text).slice(-250_000);
|
||||
if (!logTimer) logTimer = setTimeout(flushLog, 100);
|
||||
};
|
||||
try {
|
||||
return await new Promise<DubAgentTranslationResult>((resolvePromise, rejectPromise) => {
|
||||
let settled = false;
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
append(stdoutDecoder.end());
|
||||
append(stderrDecoder.end(), false);
|
||||
flushLog();
|
||||
translationChild = null;
|
||||
if (translationTemp) rmSync(translationTemp, { recursive: true, force: true });
|
||||
translationTemp = null;
|
||||
callback();
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
const running = translationChild;
|
||||
if (running) terminateAgentProcess(running);
|
||||
finish(() => rejectPromise(new Error('Agent translation timed out')));
|
||||
},
|
||||
10 * 60 * 1_000,
|
||||
);
|
||||
translationChild = spawn(command.executable, args, {
|
||||
cwd: translationTemp!,
|
||||
env: {
|
||||
...process.env,
|
||||
NO_COLOR: '1',
|
||||
FORCE_COLOR: '0',
|
||||
...(request.agent === 'opencode' ? { OPENCODE_CONFIG: openCodeConfigPath } : {}),
|
||||
},
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
guardAgentProcessStreams(translationChild, (error) => finish(() => rejectPromise(error)));
|
||||
translationChild.stdout.on('data', (value: Buffer) => append(stdoutDecoder.write(value)));
|
||||
translationChild.stderr.on('data', (value: Buffer) => append(stderrDecoder.write(value), false));
|
||||
translationChild.on('error', (error) =>
|
||||
finish(() => rejectPromise(new Error(`Agent could not start: ${error.message}`))),
|
||||
);
|
||||
translationChild.on('close', (code) =>
|
||||
finish(() => {
|
||||
if (code !== 0) {
|
||||
rejectPromise(
|
||||
new Error(`Agent translation failed (exit code ${code ?? 'unknown'})`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolvePromise(parseDubAgentTranslations(output, request));
|
||||
} catch (error) {
|
||||
rejectPromise(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (request.agent === 'opencode') translationChild.stdin.end();
|
||||
else translationChild.stdin.end(prompt);
|
||||
const prompt = promptOverride ?? dubTranslationPrompt(request);
|
||||
const sessionId = randomUUID();
|
||||
translationTemp = mkdtempSync(join(app.getPath('temp'), 'voicestudio-dub-agent-'));
|
||||
const promptPath = join(translationTemp, `${sessionId}.md`);
|
||||
const openCodeConfigPath = join(translationTemp, 'opencode.json');
|
||||
const args = [...command.prefix, ...translationLaunchArgs(request.agent)];
|
||||
if (model) args.push('--model', model);
|
||||
if (request.agent === 'opencode') {
|
||||
writeFileSync(promptPath, prompt, 'utf8');
|
||||
writeFileSync(
|
||||
openCodeConfigPath,
|
||||
JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
permission: { '*': 'deny' },
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
args.push(
|
||||
'Process the attached request and return only the requested JSON.',
|
||||
'--file',
|
||||
promptPath,
|
||||
);
|
||||
}
|
||||
let output = '';
|
||||
const stdoutDecoder = new StringDecoder('utf8');
|
||||
const stderrDecoder = new StringDecoder('utf8');
|
||||
let pendingLog = '';
|
||||
let logTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flushLog = () => {
|
||||
if (logTimer) clearTimeout(logTimer);
|
||||
logTimer = undefined;
|
||||
if (pendingLog && request.requestId)
|
||||
sendToLiveWindow(getMainWindow(), REPAIR_CHANNELS.translationEvent, {
|
||||
requestId: request.requestId,
|
||||
text: pendingLog,
|
||||
});
|
||||
} catch (error) {
|
||||
flushLog();
|
||||
if (translationTemp) rmSync(translationTemp, { recursive: true, force: true });
|
||||
translationTemp = null;
|
||||
translationChild = null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
pendingLog = '';
|
||||
};
|
||||
const append = (text: string, stdout = true) => {
|
||||
if (stdout) output = (output + text).slice(-MAX_TRANSLATION_OUTPUT);
|
||||
pendingLog = (pendingLog + text).slice(-250_000);
|
||||
if (!logTimer) logTimer = setTimeout(flushLog, 100);
|
||||
};
|
||||
try {
|
||||
return await new Promise<DubAgentTranslationResult>((resolvePromise, rejectPromise) => {
|
||||
let settled = false;
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
append(stdoutDecoder.end());
|
||||
append(stderrDecoder.end(), false);
|
||||
flushLog();
|
||||
translationChild = null;
|
||||
if (translationTemp) rmSync(translationTemp, { recursive: true, force: true });
|
||||
translationTemp = null;
|
||||
callback();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
const running = translationChild;
|
||||
if (running) terminateAgentProcess(running);
|
||||
finish(() => rejectPromise(new Error('Agent translation timed out')));
|
||||
}, timeoutMs);
|
||||
translationChild = spawn(command.executable, args, {
|
||||
cwd: translationTemp!,
|
||||
env: {
|
||||
...process.env,
|
||||
// Agent subprocesses must not inherit the backend-only capability.
|
||||
VOICESTUDIO_LLM_AGENT_TOKEN: undefined,
|
||||
VOICESTUDIO_LLM_AGENT_URL: undefined,
|
||||
...(command.prefix.length ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
|
||||
NO_COLOR: '1',
|
||||
FORCE_COLOR: '0',
|
||||
...(request.agent === 'opencode' ? { OPENCODE_CONFIG: openCodeConfigPath } : {}),
|
||||
},
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
guardAgentProcessStreams(translationChild, (error) => finish(() => rejectPromise(error)));
|
||||
translationChild.stdout.on('data', (value: Buffer) => append(stdoutDecoder.write(value)));
|
||||
translationChild.stderr.on('data', (value: Buffer) =>
|
||||
append(stderrDecoder.write(value), false),
|
||||
);
|
||||
translationChild.on('error', (error) =>
|
||||
finish(() => rejectPromise(new Error(`Agent could not start: ${error.message}`))),
|
||||
);
|
||||
translationChild.on('close', (code) =>
|
||||
finish(() => {
|
||||
const providerFailure = agentProviderFailure(output);
|
||||
if (providerFailure) {
|
||||
rejectPromise(providerFailure);
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
rejectPromise(new Error(`Agent translation failed (exit code ${code ?? 'unknown'})`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolvePromise(parseDubAgentTranslations(output, request));
|
||||
} catch (error) {
|
||||
rejectPromise(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (request.agent === 'opencode') translationChild.stdin.end();
|
||||
else translationChild.stdin.end(prompt);
|
||||
});
|
||||
} catch (error) {
|
||||
flushLog();
|
||||
if (translationTemp) rmSync(translationTemp, { recursive: true, force: true });
|
||||
translationTemp = null;
|
||||
translationChild = null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
ipcMain.handle(REPAIR_CHANNELS.translate, (event, request: DubAgentTranslationRequest) => {
|
||||
trusted(event, getMainWindow());
|
||||
return translate(request);
|
||||
});
|
||||
const llmBridge = await startLlmAgentBridge(async (request) => {
|
||||
const prompt = `Complete the following chat as a text-only assistant. Follow the system message and answer the final user message. Do not use tools or read files. Return exactly one JSON object: {"translations":[{"id":"reply","text":"your answer"}]}. Preserve any JSON requested by the chat inside the text string.\n${JSON.stringify(request.messages)}`;
|
||||
const result = await translate(
|
||||
{
|
||||
agent: request.agent,
|
||||
purpose: 'translate',
|
||||
targetLanguage: 'en',
|
||||
segments: [{ id: 'reply', sourceText: 'completion', start: 0, end: 1 }],
|
||||
},
|
||||
prompt,
|
||||
request.model,
|
||||
request.timeoutMs,
|
||||
);
|
||||
return result.translations[0].text;
|
||||
});
|
||||
process.env.VOICESTUDIO_LLM_AGENT_URL = llmBridge.url;
|
||||
process.env.VOICESTUDIO_LLM_AGENT_TOKEN = llmBridge.token;
|
||||
process.env.VOICESTUDIO_LLM_AGENTS = JSON.stringify(
|
||||
DEFINITIONS.filter((item) => locate(item.command)).map((item) => item.id),
|
||||
);
|
||||
ipcMain.handle(REPAIR_CHANNELS.stopTranslation, (event) => {
|
||||
trusted(event, getMainWindow());
|
||||
@@ -926,6 +1026,11 @@ export function registerRepairAgents(
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (preparing) state = { ...state, status: 'stopped' };
|
||||
llmBridge.close();
|
||||
delete process.env.VOICESTUDIO_LLM_AGENT_URL;
|
||||
delete process.env.VOICESTUDIO_LLM_AGENT_TOKEN;
|
||||
delete process.env.VOICESTUDIO_LLM_AGENTS;
|
||||
if (child) terminateAgentProcess(child);
|
||||
if (translationChild) terminateAgentProcess(translationChild);
|
||||
if (promptFile) rmSync(promptFile, { force: true });
|
||||
@@ -936,7 +1041,10 @@ export function registerRepairAgents(
|
||||
translationChild = null;
|
||||
translationTemp = null;
|
||||
Object.values(REPAIR_CHANNELS)
|
||||
.filter((channel) => channel !== REPAIR_CHANNELS.event && channel !== REPAIR_CHANNELS.translationEvent)
|
||||
.filter(
|
||||
(channel) =>
|
||||
channel !== REPAIR_CHANNELS.event && channel !== REPAIR_CHANNELS.translationEvent,
|
||||
)
|
||||
.forEach((channel) => ipcMain.removeHandler(channel));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BrowserWindow, WebContentsView } from 'electron';
|
||||
import { registerSiteBrowser, SITE_BROWSER_CHANNELS } from './site-browser';
|
||||
import { browserUrl } from '../shared/site-browser';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (...args: any[]) => any>(),
|
||||
windows: [] as any[],
|
||||
views: [] as any[],
|
||||
partition: vi.fn(),
|
||||
external: vi.fn(),
|
||||
list: vi.fn(),
|
||||
}));
|
||||
vi.mock('./installed-browsers', () => ({
|
||||
listInstalledBrowsers: mocks.list,
|
||||
openInBrowser: mocks.external,
|
||||
}));
|
||||
vi.mock('electron', async () => {
|
||||
const { EventEmitter } = await import('node:events');
|
||||
let id = 0;
|
||||
class Contents extends EventEmitter {
|
||||
id = ++id;
|
||||
mainFrame = { url: 'app://voicestudio/index.html' };
|
||||
destroyed = false;
|
||||
navigationHistory = {
|
||||
canGoBack: vi.fn(() => true),
|
||||
canGoForward: vi.fn(() => false),
|
||||
goBack: vi.fn(),
|
||||
goForward: vi.fn(),
|
||||
};
|
||||
loadURL = vi.fn(async () => {});
|
||||
getURL = () => this.mainFrame.url;
|
||||
getTitle = () => 'Page title';
|
||||
getZoomFactor = vi.fn(() => 1);
|
||||
isLoading = () => false;
|
||||
isDestroyed = () => this.destroyed;
|
||||
close = vi.fn(() => {
|
||||
this.destroyed = true;
|
||||
});
|
||||
send = vi.fn();
|
||||
setWindowOpenHandler = vi.fn();
|
||||
reload = vi.fn();
|
||||
stop = vi.fn();
|
||||
}
|
||||
class Window extends EventEmitter {
|
||||
id = ++id;
|
||||
webContents = new Contents();
|
||||
contentView = { addChildView: vi.fn(), removeChildView: vi.fn() };
|
||||
destroyed = false;
|
||||
constructor(public options?: unknown) {
|
||||
super();
|
||||
mocks.windows.push(this);
|
||||
}
|
||||
getContentSize = () => [1000, 800];
|
||||
isDestroyed = () => this.destroyed;
|
||||
show = vi.fn();
|
||||
focus = vi.fn();
|
||||
loadURL = vi.fn(async () => {});
|
||||
destroy = vi.fn(() => {
|
||||
this.destroyed = true;
|
||||
this.emit('closed');
|
||||
});
|
||||
}
|
||||
class View {
|
||||
webContents = new Contents();
|
||||
constructor(public options: unknown) {
|
||||
mocks.views.push(this);
|
||||
}
|
||||
setBounds = vi.fn();
|
||||
setVisible = vi.fn();
|
||||
}
|
||||
return {
|
||||
BrowserWindow: Window,
|
||||
WebContentsView: View,
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (...args: any[]) => any) =>
|
||||
mocks.handlers.set(channel, handler),
|
||||
},
|
||||
session: { fromPartition: mocks.partition },
|
||||
};
|
||||
});
|
||||
|
||||
let parent: BrowserWindow;
|
||||
const invoke = (name: string, owner: BrowserWindow | WebContentsView, ...args: unknown[]) =>
|
||||
mocks.handlers.get(`site-browser:${name}`)!(
|
||||
{ sender: owner.webContents, senderFrame: owner.webContents.mainFrame },
|
||||
...args,
|
||||
);
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.windows.length = 0;
|
||||
mocks.views.length = 0;
|
||||
mocks.handlers.clear();
|
||||
mocks.partition.mockReturnValue({
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
clearStorageData: vi.fn(async () => {}),
|
||||
clearCache: vi.fn(async () => {}),
|
||||
});
|
||||
parent = new BrowserWindow();
|
||||
registerSiteBrowser(() => parent);
|
||||
});
|
||||
|
||||
describe('website URL policy', () => {
|
||||
it('accepts web URLs and bare domains', () => {
|
||||
expect(browserUrl(' voicestudio.sh/docs ')).toBe('https://voicestudio.sh/docs');
|
||||
expect(browserUrl('http://localhost:8000/?x=1&y=2')).toBe('http://localhost:8000/?x=1&y=2');
|
||||
});
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,test',
|
||||
'file:///etc/passwd',
|
||||
'app://voicestudio',
|
||||
'https://user:pass@site.test',
|
||||
'',
|
||||
'https://',
|
||||
null,
|
||||
'a'.repeat(8193),
|
||||
])('rejects unsafe or invalid input %s', (input) => expect(() => browserUrl(input)).toThrow());
|
||||
});
|
||||
|
||||
it('opens an isolated sandboxed view only after the main window requests it', () => {
|
||||
expect(mocks.views).toHaveLength(0);
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
const child = parent,
|
||||
view = mocks.views[0];
|
||||
expect(mocks.windows).toHaveLength(1);
|
||||
expect(child.loadURL).not.toHaveBeenCalled();
|
||||
expect(parent.contentView.addChildView).toHaveBeenCalledWith(view);
|
||||
expect(view.options.webPreferences).toMatchObject({
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
});
|
||||
expect(view.options.webPreferences.preload).toBeUndefined();
|
||||
expect(mocks.partition.mock.calls[0][0]).not.toContain('persist:');
|
||||
expect(view.webContents.loadURL).toHaveBeenCalledWith('https://voicestudio.sh/');
|
||||
const callback = vi.fn();
|
||||
mocks.partition.mock.results[0].value.setPermissionRequestHandler.mock.calls[0][0](
|
||||
null,
|
||||
'media',
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
invoke('bounds', child, { x: 280, y: 240, width: 680, height: 500 });
|
||||
expect(view.setBounds).toHaveBeenLastCalledWith({ x: 280, y: 240, width: 680, height: 500 });
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('keeps the native view inside the measured modal at different zoom levels and rejects invalid bounds', () => {
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
const view = mocks.views[0];
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false);
|
||||
vi.mocked(parent.webContents.getZoomFactor).mockReturnValue(1.25);
|
||||
invoke('bounds', parent, { x: 220, y: 180, width: 400, height: 300 });
|
||||
expect(view.setBounds).toHaveBeenLastCalledWith({ x: 275, y: 225, width: 500, height: 375 });
|
||||
invoke('bounds', parent, { x: 700, y: 600, width: 900, height: 900 });
|
||||
expect(view.setBounds).toHaveBeenLastCalledWith({ x: 875, y: 750, width: 125, height: 50 });
|
||||
for (const rect of [
|
||||
null,
|
||||
{},
|
||||
{ x: NaN, y: 1, width: 2, height: 3 },
|
||||
{ x: 1, y: 2, width: Infinity, height: 3 },
|
||||
])
|
||||
expect(() => invoke('bounds', parent, rect)).toThrow('Invalid browser bounds');
|
||||
});
|
||||
|
||||
it('closes the modal with Escape while the remote page has keyboard focus', () => {
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
const event = { preventDefault: vi.fn() };
|
||||
mocks.views[0].webContents.emit('before-input-event', event, { type: 'keyDown', key: 'Escape' });
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(invoke('state', parent)).toBeNull();
|
||||
});
|
||||
|
||||
it('denies every browser IPC to remote content and subframes', () => {
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
const view = mocks.views[0],
|
||||
child = parent;
|
||||
for (const name of SITE_BROWSER_CHANNELS) {
|
||||
expect(() => invoke(name, view, 'https://example.com')).toThrow('Untrusted browser request');
|
||||
expect(() =>
|
||||
mocks.handlers.get(`site-browser:${name}`)!({
|
||||
sender: child.webContents,
|
||||
senderFrame: { url: 'app://voicestudio/index.html' },
|
||||
}),
|
||||
).toThrow('Untrusted browser request');
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the browser, supports history, reports failures and opens the current page externally', () => {
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
const child = parent,
|
||||
view = mocks.views[0];
|
||||
invoke('open', parent, 'https://example.com');
|
||||
expect(mocks.views).toHaveLength(1);
|
||||
expect(child.focus).toHaveBeenCalled();
|
||||
invoke('command', child, 'back');
|
||||
invoke('command', child, 'reload');
|
||||
expect(view.webContents.navigationHistory.goBack).toHaveBeenCalled();
|
||||
expect(view.webContents.reload).toHaveBeenCalled();
|
||||
invoke('navigate', child, 'https://offline.test');
|
||||
view.webContents.emit('did-start-navigation', {}, 'https://offline.test/', false, true);
|
||||
view.webContents.emit('did-fail-load', {}, -105, 'offline', 'https://offline.test/', true);
|
||||
expect(invoke('state', child)).toMatchObject({ url: 'https://offline.test/', error: true });
|
||||
invoke('openExternal', child, 'default');
|
||||
expect(mocks.external).toHaveBeenCalledWith('default', 'https://offline.test/');
|
||||
invoke('navigate', child, 'https://voicestudio.sh');
|
||||
expect(invoke('state', child).error).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks non-web navigations and popups, closes remote contents and removes parent listeners', () => {
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
const child = parent,
|
||||
view = mocks.views[0];
|
||||
const event = { preventDefault: vi.fn() };
|
||||
view.webContents.emit('will-redirect', event, 'file:///secret');
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
const popup = view.webContents.setWindowOpenHandler.mock.calls[0][0];
|
||||
expect(popup({ url: 'javascript:alert(1)' })).toEqual({ action: 'deny' });
|
||||
expect(popup({ url: 'https://example.com' })).toEqual({ action: 'deny' });
|
||||
expect(view.webContents.loadURL).toHaveBeenLastCalledWith('https://example.com/');
|
||||
invoke('close', child);
|
||||
expect(parent.isDestroyed()).toBe(false);
|
||||
expect(invoke('state', parent)).toBeNull();
|
||||
expect(parent.contentView.removeChildView).toHaveBeenCalledWith(view);
|
||||
expect(view.webContents.close).toHaveBeenCalledWith({ waitForBeforeUnload: false });
|
||||
expect(parent.listenerCount('closed')).toBe(0);
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
parent.destroy();
|
||||
expect(mocks.views[1].webContents.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disposes an embedded page on renderer reload without replacing the workspace URL', () => {
|
||||
expect(invoke('state', parent)).toBeNull();
|
||||
invoke('open', parent, 'https://voicestudio.sh');
|
||||
parent.webContents.emit('did-start-loading');
|
||||
expect(invoke('state', parent)).toBeNull();
|
||||
expect(mocks.views[0].webContents.close).toHaveBeenCalled();
|
||||
expect(parent.listenerCount('resize')).toBe(0);
|
||||
expect(parent.webContents.loadURL).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
BrowserWindow,
|
||||
WebContentsView,
|
||||
ipcMain,
|
||||
session,
|
||||
type IpcMainInvokeEvent,
|
||||
} from 'electron';
|
||||
import { isTrustedRenderer } from './trusted-renderer';
|
||||
import { browserUrl, type SiteBrowserState, type SiteBrowserBounds } from '../shared/site-browser';
|
||||
import { listInstalledBrowsers, openInBrowser } from './installed-browsers';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const SITE_BROWSER_CHANNELS = [
|
||||
'open',
|
||||
'close',
|
||||
'state',
|
||||
'navigate',
|
||||
'command',
|
||||
'bounds',
|
||||
'installed',
|
||||
'openExternal',
|
||||
] as const;
|
||||
|
||||
export function registerSiteBrowser(getMainWindow: () => BrowserWindow | null) {
|
||||
const windows = new Map<
|
||||
number,
|
||||
{
|
||||
window: BrowserWindow;
|
||||
view: WebContentsView;
|
||||
state: SiteBrowserState;
|
||||
bounds: SiteBrowserBounds;
|
||||
dispose: () => void;
|
||||
}
|
||||
>();
|
||||
const trusted = (event: IpcMainInvokeEvent, owner: BrowserWindow | null) => {
|
||||
if (
|
||||
!owner ||
|
||||
event.sender !== owner.webContents ||
|
||||
event.senderFrame !== owner.webContents.mainFrame ||
|
||||
!isTrustedRenderer(event.senderFrame.url, process.env.ELECTRON_RENDERER_URL)
|
||||
)
|
||||
throw new Error('Untrusted browser request');
|
||||
};
|
||||
const entryFor = (event: IpcMainInvokeEvent) => {
|
||||
const entry = windows.get(event.sender.id);
|
||||
trusted(event, entry?.window ?? null);
|
||||
return entry!;
|
||||
};
|
||||
const resize = (entry: ReturnType<typeof entryFor>) => {
|
||||
if (entry.window.isDestroyed()) return;
|
||||
const [width, height] = entry.window.getContentSize();
|
||||
const zoom = entry.window.webContents.getZoomFactor();
|
||||
const x = Math.max(0, Math.min(width, Math.round(entry.bounds.x * zoom)));
|
||||
const y = Math.max(0, Math.min(height, Math.round(entry.bounds.y * zoom)));
|
||||
const bounds = {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(0, Math.min(width - x, Math.round(entry.bounds.width * zoom))),
|
||||
height: Math.max(0, Math.min(height - y, Math.round(entry.bounds.height * zoom))),
|
||||
};
|
||||
entry.view.setBounds(bounds);
|
||||
entry.view.setVisible(bounds.width > 0 && bounds.height > 0);
|
||||
};
|
||||
const navigate = (entry: ReturnType<typeof entryFor>, input: unknown) => {
|
||||
const url = browserUrl(input);
|
||||
entry.state.url = url;
|
||||
entry.state.error = false;
|
||||
void entry.view.webContents.loadURL(url).catch(() => {
|
||||
/* did-fail-load owns feedback */
|
||||
});
|
||||
};
|
||||
ipcMain.handle('site-browser:open', (event, rawUrl: unknown) => {
|
||||
const parent = getMainWindow();
|
||||
trusted(event, parent);
|
||||
const url = browserUrl(rawUrl);
|
||||
const existing = [...windows.values()][0];
|
||||
if (existing) {
|
||||
navigate(existing, url);
|
||||
existing.window.show();
|
||||
existing.window.focus();
|
||||
return;
|
||||
}
|
||||
const window = parent!;
|
||||
const isolated = session.fromPartition(`voicestudio-site-browser-${randomUUID()}`);
|
||||
isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
|
||||
isolated.setPermissionCheckHandler(() => false);
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: isolated,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
navigateOnDragDrop: false,
|
||||
},
|
||||
});
|
||||
const entry = {
|
||||
window,
|
||||
view,
|
||||
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
||||
dispose: () => {},
|
||||
state: { url, title: '', loading: true, canGoBack: false, canGoForward: false, error: false },
|
||||
};
|
||||
const toolbarId = window.webContents.id;
|
||||
const toolbarContents = window.webContents;
|
||||
windows.set(toolbarId, entry);
|
||||
window.contentView.addChildView(view);
|
||||
view.setVisible(false);
|
||||
const publish = () => {
|
||||
if (
|
||||
window.isDestroyed() ||
|
||||
view.webContents.isDestroyed() ||
|
||||
windows.get(toolbarId) !== entry
|
||||
)
|
||||
return;
|
||||
const contents = view.webContents;
|
||||
entry.state = {
|
||||
...entry.state,
|
||||
title: contents.getTitle(),
|
||||
loading: contents.isLoading(),
|
||||
canGoBack: contents.navigationHistory.canGoBack(),
|
||||
canGoForward: contents.navigationHistory.canGoForward(),
|
||||
};
|
||||
window.webContents.send('site-browser:changed', entry.state);
|
||||
};
|
||||
view.webContents.on('did-start-loading', publish);
|
||||
view.webContents.on('did-stop-loading', publish);
|
||||
view.webContents.on('did-navigate', (_event, target) => {
|
||||
entry.state.url = target;
|
||||
publish();
|
||||
});
|
||||
view.webContents.on('did-navigate-in-page', (_event, target, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
entry.state.url = target;
|
||||
publish();
|
||||
}
|
||||
});
|
||||
view.webContents.on('page-title-updated', publish);
|
||||
view.webContents.on('did-fail-load', (_event, code, _description, _url, isMainFrame) => {
|
||||
if (isMainFrame && code !== -3) {
|
||||
entry.state.error = true;
|
||||
publish();
|
||||
}
|
||||
});
|
||||
view.webContents.on('did-start-navigation', (_event, target, _inPlace, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
entry.state.error = false;
|
||||
entry.state.url = target;
|
||||
publish();
|
||||
}
|
||||
});
|
||||
const allowWebNavigation = (event: { preventDefault(): void }, target: string) => {
|
||||
try {
|
||||
browserUrl(target);
|
||||
} catch {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
view.webContents.on('will-navigate', allowWebNavigation);
|
||||
view.webContents.on('will-redirect', allowWebNavigation);
|
||||
view.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
try {
|
||||
navigate(entry, target);
|
||||
} catch {
|
||||
/* Non-web schemes never execute. */
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
view.webContents.on('render-process-gone', () => {
|
||||
entry.state.error = true;
|
||||
publish();
|
||||
});
|
||||
const onResize = () => resize(entry);
|
||||
const dispose = () => {
|
||||
if (!windows.delete(toolbarId)) return;
|
||||
window.removeListener('resize', onResize);
|
||||
window.removeListener('closed', dispose);
|
||||
toolbarContents.removeListener('did-start-loading', dispose);
|
||||
if (!window.isDestroyed()) window.contentView.removeChildView(view);
|
||||
if (!view.webContents.isDestroyed()) view.webContents.close({ waitForBeforeUnload: false });
|
||||
void isolated.clearStorageData().catch(() => {});
|
||||
void isolated.clearCache().catch(() => {});
|
||||
if (!window.isDestroyed() && !toolbarContents.isDestroyed()) {
|
||||
toolbarContents.send('site-browser:changed', null);
|
||||
}
|
||||
};
|
||||
entry.dispose = dispose;
|
||||
view.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
window.on('resize', onResize);
|
||||
window.once('closed', dispose);
|
||||
window.webContents.once('did-start-loading', dispose);
|
||||
publish();
|
||||
navigate(entry, url);
|
||||
});
|
||||
ipcMain.handle('site-browser:close', (event) => {
|
||||
trusted(event, getMainWindow());
|
||||
windows.get(event.sender.id)?.dispose();
|
||||
});
|
||||
ipcMain.handle('site-browser:state', (event) => {
|
||||
trusted(event, getMainWindow());
|
||||
return windows.get(event.sender.id)?.state ?? null;
|
||||
});
|
||||
ipcMain.handle('site-browser:navigate', (event, url: unknown) => navigate(entryFor(event), url));
|
||||
ipcMain.handle('site-browser:command', (event, action: unknown) => {
|
||||
const contents = entryFor(event).view.webContents;
|
||||
if (action === 'back' && contents.navigationHistory.canGoBack())
|
||||
contents.navigationHistory.goBack();
|
||||
else if (action === 'forward' && contents.navigationHistory.canGoForward())
|
||||
contents.navigationHistory.goForward();
|
||||
else if (action === 'reload') contents.reload();
|
||||
else if (action === 'stop') contents.stop();
|
||||
else if (action !== 'back' && action !== 'forward') throw new Error('Invalid browser command');
|
||||
});
|
||||
ipcMain.handle('site-browser:bounds', (event, rect: unknown) => {
|
||||
const entry = entryFor(event);
|
||||
if (
|
||||
!rect ||
|
||||
typeof rect !== 'object' ||
|
||||
!['x', 'y', 'width', 'height'].every(
|
||||
(key) =>
|
||||
typeof (rect as Record<string, unknown>)[key] === 'number' &&
|
||||
Number.isFinite((rect as Record<string, unknown>)[key]),
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid browser bounds');
|
||||
const { x, y, width, height } = rect as SiteBrowserBounds;
|
||||
entry.bounds = { x, y, width, height };
|
||||
resize(entry);
|
||||
});
|
||||
ipcMain.handle('site-browser:installed', (event) => {
|
||||
entryFor(event);
|
||||
return listInstalledBrowsers();
|
||||
});
|
||||
ipcMain.handle('site-browser:openExternal', (event, id: unknown) =>
|
||||
openInBrowser(id, entryFor(event).state.url),
|
||||
);
|
||||
}
|
||||
Vendored
+4
@@ -152,6 +152,9 @@ export interface RepairAgentInfo {
|
||||
version: string;
|
||||
}
|
||||
export interface RepairAgentRunRequest {
|
||||
workspace?: 'app' | 'source';
|
||||
features?: import('../shared/agent-workspace').AgentFeature[];
|
||||
history?: import('../shared/agent-workspace').AgentChatMessage[];
|
||||
agent: RepairAgentId;
|
||||
mode: 'diagnose' | 'fix';
|
||||
report: string;
|
||||
@@ -274,6 +277,7 @@ export interface UninstallTarget {
|
||||
}
|
||||
|
||||
export interface VoiceStudioBridge {
|
||||
browser: import('../shared/site-browser').SiteBrowserBridge;
|
||||
pro: {
|
||||
status(): Promise<{ active: boolean; configured: boolean; error?: 'offline' | 'invalid' | 'storage' }>;
|
||||
activate(key: string): Promise<{ active: boolean; configured: boolean; error?: 'offline' | 'invalid' | 'storage' }>;
|
||||
|
||||
@@ -19,6 +19,17 @@ function subscribe<T>(channel: string, cb: (payload: T) => void): () => void {
|
||||
}
|
||||
|
||||
const bridge: VoiceStudioBridge = {
|
||||
browser: {
|
||||
open: (url) => ipcRenderer.invoke('site-browser:open', url),
|
||||
close: () => ipcRenderer.invoke('site-browser:close'),
|
||||
state: () => ipcRenderer.invoke('site-browser:state'),
|
||||
navigate: (url) => ipcRenderer.invoke('site-browser:navigate', url),
|
||||
command: (action) => ipcRenderer.invoke('site-browser:command', action),
|
||||
bounds: (rect) => ipcRenderer.invoke('site-browser:bounds', rect),
|
||||
installed: () => ipcRenderer.invoke('site-browser:installed'),
|
||||
openExternal: (id) => ipcRenderer.invoke('site-browser:openExternal', id),
|
||||
onState: (callback) => subscribe('site-browser:changed', callback),
|
||||
},
|
||||
pro: {
|
||||
status: () => ipcRenderer.invoke('pro:status'),
|
||||
activate: (key) => ipcRenderer.invoke('pro:activate', key),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob: data:; frame-src https://forms.gle https://docs.google.com https://accounts.google.com; connect-src 'self' blob: ws: http://localhost:* http://127.0.0.1:* https://eu.i.posthog.com https://eu-assets.i.posthog.com https://us.i.posthog.com https://us-assets.i.posthog.com; font-src 'self' data:; worker-src 'self' blob:"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob: data:; frame-src https://forms.gle https://docs.google.com https://accounts.google.com; connect-src 'self' blob: ws: http://localhost:* http://127.0.0.1:* https://api.github.com https://eu.i.posthog.com https://eu-assets.i.posthog.com https://us.i.posthog.com https://us-assets.i.posthog.com; font-src 'self' data:; worker-src 'self' blob:"
|
||||
/>
|
||||
<script src="/early-error-capture.js"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
@@ -32,6 +32,7 @@ export function AppShell() {
|
||||
on Settings too, so the app never swaps its left column. */}
|
||||
<WorkspaceSidebar />
|
||||
<SettingsWorkspace
|
||||
data-slot="workspace-content"
|
||||
role={pathname === '/settings/openapi' ? 'main' : undefined}
|
||||
className="@container relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
@@ -45,7 +46,10 @@ export function AppShell() {
|
||||
<BackendGate repairDock={<RepairAgentDock />}>
|
||||
<CommandPalette />
|
||||
<WorkspaceSidebar />
|
||||
<main className="@container relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<main
|
||||
data-slot="workspace-content"
|
||||
className="@container relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { AudioLinesIcon } from 'lucide-react';
|
||||
import { EngineRow, compactEngineName } from './engine-row';
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ to, params, ...props }: { to: string; params: { family: string } }) => (
|
||||
<a href={to.replace('$family', params.family)} {...props} />
|
||||
),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
const row = {
|
||||
family: 'tts',
|
||||
Icon: AudioLinesIcon,
|
||||
title: 'KittenTTS (English, 8 voices)',
|
||||
detail: 'kitten/default',
|
||||
runtime: 'cpu',
|
||||
state: 'engineRuntime.ready',
|
||||
};
|
||||
|
||||
it('keeps models compact and shows full diagnostics only when explicitly expanded', () => {
|
||||
const toggle = vi.fn();
|
||||
const view = render(
|
||||
<EngineRow
|
||||
row={row}
|
||||
level="details"
|
||||
online
|
||||
dotClass="bg-success"
|
||||
open={false}
|
||||
onToggle={toggle}
|
||||
/>,
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('kitten/default')).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(toggle).toHaveBeenCalledOnce();
|
||||
view.rerender(
|
||||
<EngineRow row={row} level="details" online dotClass="bg-success" open onToggle={toggle} />,
|
||||
);
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('kitten/default')).toBeVisible();
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/settings/models/tts');
|
||||
});
|
||||
it('shows loading text and busy semantics while preserving offline override', () => {
|
||||
const view = render(
|
||||
<EngineRow
|
||||
row={{ ...row, state: 'preferences.loading' }}
|
||||
level="models"
|
||||
online
|
||||
dotClass=""
|
||||
open={false}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('preferences.loading');
|
||||
view.rerender(
|
||||
<EngineRow
|
||||
row={{ ...row, state: 'preferences.loading' }}
|
||||
level="models"
|
||||
online={false}
|
||||
dotClass=""
|
||||
open={false}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('modelMaintenance.offline');
|
||||
});
|
||||
it('shortens capability descriptions without damaging model variants', () => {
|
||||
expect(compactEngineName('KittenTTS (English, 8 voices)')).toBe('KittenTTS');
|
||||
expect(compactEngineName('Whisper large-v3')).toBe('Whisper large-v3');
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useId } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ChevronRightIcon, LoaderCircleIcon, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ComputeVendorIcon, formatComputeRuntime } from '@/components/compute-vendor-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sidebarToolState } from './status-runtime';
|
||||
import type { EngineDetailLevel } from './use-engine-detail-level';
|
||||
|
||||
export function compactEngineName(name: string) {
|
||||
return name.replace(/\s+\([^)]*\)\s*$/, '').trim() || name;
|
||||
}
|
||||
|
||||
export function EngineRow({
|
||||
row,
|
||||
level,
|
||||
online,
|
||||
dotClass,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
row: {
|
||||
family: string;
|
||||
Icon: LucideIcon;
|
||||
detail: string;
|
||||
title?: string | null;
|
||||
runtime?: string | null;
|
||||
problem?: string | null;
|
||||
state: string;
|
||||
};
|
||||
level: EngineDetailLevel;
|
||||
online: boolean;
|
||||
dotClass: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
const { family, Icon, detail, title, runtime, problem, state } = row;
|
||||
const name = t('sidebarTools.' + family);
|
||||
const status = t(sidebarToolState(state, online));
|
||||
const model = title || detail;
|
||||
const loading =
|
||||
online &&
|
||||
['engineRuntime.loading', 'preferences.loading', 'network.switching', 'common.saving'].includes(
|
||||
state,
|
||||
);
|
||||
const modelLabel = detail.includes('/')
|
||||
? detail.slice(detail.lastIndexOf('/') + 1)
|
||||
: compactEngineName(model);
|
||||
const stateLabel = (
|
||||
<span
|
||||
role="status"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-normal text-muted-foreground"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderCircleIcon
|
||||
aria-hidden="true"
|
||||
className="size-3 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1.5 rounded-full', online ? dotClass : 'bg-muted-foreground')}
|
||||
/>
|
||||
)}
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
{level === 'simple' ? (
|
||||
stateLabel
|
||||
) : (
|
||||
<ChevronRightIcon
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-muted-foreground',
|
||||
open && level === 'details' ? 'rotate-90' : 'rtl:rotate-180',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{level !== 'simple' && (
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span
|
||||
data-slot="engine-selected-model"
|
||||
className="min-w-0 truncate text-[11px] text-muted-foreground"
|
||||
title={model}
|
||||
>
|
||||
{modelLabel}
|
||||
</span>
|
||||
{stateLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const rowClass =
|
||||
'flex min-h-11 w-full items-center gap-2 rounded-md px-1.5 py-1 text-start outline-none hover:bg-sidebar-accent/60 focus-visible:ring-2 focus-visible:ring-ring';
|
||||
return (
|
||||
<div data-slot="engine-row" aria-busy={loading} className="min-w-0">
|
||||
{level === 'details' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={rowClass}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
aria-label={`${name}: ${status}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/settings/models/$family"
|
||||
params={{ family }}
|
||||
className={rowClass}
|
||||
aria-label={`${name}: ${status}. ${t('modelSettings.change')}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)}
|
||||
{level === 'details' && open && (
|
||||
<div
|
||||
id={id}
|
||||
data-slot="engine-diagnostics"
|
||||
className="mb-1 ms-5 space-y-1.5 border-s border-border/60 py-2 ps-3 text-[11px] leading-relaxed text-muted-foreground [overflow-wrap:anywhere]"
|
||||
>
|
||||
<p>{model}</p>
|
||||
{title && title !== detail && <code className="block">{detail}</code>}
|
||||
{runtime && (
|
||||
<p className="flex items-center gap-1.5">
|
||||
<ComputeVendorIcon runtime={runtime} className="size-3 shrink-0" />
|
||||
{formatComputeRuntime(runtime)}
|
||||
</p>
|
||||
)}
|
||||
{problem && <p>{problem}</p>}
|
||||
<Link
|
||||
to="/settings/models/$family"
|
||||
params={{ family }}
|
||||
className="inline-flex min-h-8 items-center gap-1 rounded text-foreground underline decoration-border underline-offset-4 outline-none hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{t('modelSettings.change')}
|
||||
<ChevronRightIcon className="size-3 rtl:rotate-180" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/components/bridge', () => ({ getBridge: () => null }));
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
import { GithubStar } from './github-star';
|
||||
|
||||
const REFRESH_MS = 20 * 60 * 1000;
|
||||
|
||||
function mount() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<GithubStar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('gets the small public count and refreshes once every 20 minutes', async () => {
|
||||
const fetchCount = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ count: 43_638 }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ count: 43_755 }) });
|
||||
vi.stubGlobal('fetch', fetchCount);
|
||||
vi.useFakeTimers();
|
||||
const client = mount();
|
||||
|
||||
expect(screen.getByText('43.6K')).toBeVisible();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(fetchCount).toHaveBeenCalledOnce();
|
||||
expect(fetchCount.mock.calls[0][0]).toBe(
|
||||
'https://api.github.com/repos/debpalash/VoiceStudio/stargazers/count',
|
||||
);
|
||||
expect(fetchCount.mock.calls[0][1]).toMatchObject({
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(REFRESH_MS - 1);
|
||||
});
|
||||
expect(fetchCount).toHaveBeenCalledOnce();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(fetchCount).toHaveBeenCalledTimes(2);
|
||||
expect(client.getQueryData(['github-star-count'])).toBe(43_755);
|
||||
vi.useRealTimers();
|
||||
await waitFor(() => expect(screen.getByText('43.8K')).toBeVisible());
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it('keeps the bundled count when GitHub is unreachable', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
vi.useFakeTimers();
|
||||
const client = mount();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getByText('43.6K')).toBeVisible();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it('keeps the last live count if a later refresh fails', async () => {
|
||||
const fetchCount = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ count: 45_000 }) })
|
||||
.mockRejectedValueOnce(new Error('offline'));
|
||||
vi.stubGlobal('fetch', fetchCount);
|
||||
vi.useFakeTimers();
|
||||
const client = mount();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(screen.getByText('45K')).toBeVisible();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(REFRESH_MS + 1);
|
||||
});
|
||||
expect(fetchCount).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText('45K')).toBeVisible();
|
||||
client.clear();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StarIcon } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBridge } from '@/components/bridge';
|
||||
import { runRendererTask } from '@/lib/global-error-recovery';
|
||||
import { REPO_URL } from '@shared/utils/contactLinks';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
|
||||
function GithubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
|
||||
<path d="M12 .5a12 12 0 0 0-3.79 23.39c.6.11.82-.26.82-.58v-2.23c-3.34.73-4.04-1.42-4.04-1.42-.55-1.39-1.33-1.76-1.33-1.76-1.09-.75.08-.73.08-.73 1.2.08 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.49.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.34-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.6-2.81 5.63-5.49 5.93.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.83.58A12 12 0 0 0 12 .5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STARS_URL = 'https://api.github.com/repos/debpalash/VoiceStudio/stargazers/count';
|
||||
const REFRESH_MS = 20 * 60 * 1000;
|
||||
// Shown immediately and retained whenever GitHub or the network is unavailable.
|
||||
const FALLBACK_STARS = 43_638;
|
||||
const compactCount = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
|
||||
|
||||
async function fetchStarCount(signal: AbortSignal): Promise<number> {
|
||||
const response = await fetch(STARS_URL, {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]),
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
if (!response.ok) throw new Error('GitHub star count unavailable');
|
||||
const data: unknown = await response.json();
|
||||
const count = (data as { count?: unknown } | null)?.count;
|
||||
if (!Number.isSafeInteger(count) || (count as number) < 0)
|
||||
throw new Error('Invalid GitHub star count');
|
||||
return count as number;
|
||||
}
|
||||
|
||||
export function GithubStar() {
|
||||
const { t } = useTranslation();
|
||||
const stars = useQuery({
|
||||
queryKey: ['github-star-count'],
|
||||
queryFn: ({ signal }) => fetchStarCount(signal),
|
||||
staleTime: REFRESH_MS,
|
||||
gcTime: REFRESH_MS,
|
||||
refetchInterval: REFRESH_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
retry: false,
|
||||
});
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('support.star_github')}
|
||||
className="github-star-shortcut app-no-drag inline-flex h-8 shrink-0 items-center gap-1.5 px-2.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={(event) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
event.preventDefault();
|
||||
runRendererTask('Open GitHub', () => bridge.files.openExternal(REPO_URL));
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GithubIcon className="size-3.5" />
|
||||
<span>{t('support.star_short')}</span>
|
||||
<span className="github-star-count">
|
||||
<StarIcon aria-hidden="true" className="size-3" />
|
||||
{compactCount.format(stars.data ?? FALLBACK_STARS)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent surface="theme" side="bottom">
|
||||
{t('support.star_github')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { presetEngineFeedback } from './preset-engine-feedback';
|
||||
import type { PerformanceProfileState } from '@/hooks/use-performance-profile';
|
||||
|
||||
const row = {
|
||||
family: 'asr',
|
||||
title: 'Whisper',
|
||||
detail: 'small',
|
||||
runtime: 'cuda',
|
||||
problem: 'Old error',
|
||||
state: 'engineRuntime.ready',
|
||||
};
|
||||
const applied = {
|
||||
selections: { asr: { engine: 'faster-whisper', model: 'large-v3', label: 'Whisper large-v3' } },
|
||||
} as PerformanceProfileState;
|
||||
|
||||
it('shows saving without claiming the model has already changed', () => {
|
||||
expect(presetEngineFeedback(row, null, false, true, ['asr'])).toEqual({
|
||||
...row,
|
||||
state: 'common.saving',
|
||||
});
|
||||
});
|
||||
it('shows the confirmed model while clearing stale readiness and runtime during refresh', () => {
|
||||
expect(presetEngineFeedback(row, applied, true, false, ['asr'])).toEqual({
|
||||
...row,
|
||||
title: 'Whisper large-v3',
|
||||
detail: 'large-v3',
|
||||
runtime: undefined,
|
||||
problem: undefined,
|
||||
state: 'network.switching',
|
||||
});
|
||||
});
|
||||
it('uses observed runtime after refresh and leaves unaffected engines alone', () => {
|
||||
expect(presetEngineFeedback(row, applied, false, false, ['asr'])).toBe(row);
|
||||
expect(presetEngineFeedback(row, applied, true, false, ['tts'])).toBe(row);
|
||||
expect(presetEngineFeedback(row, null, true, false, ['asr'])).toBe(row);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PerformanceFamily, PerformanceProfileState } from '@/hooks/use-performance-profile';
|
||||
|
||||
export function presetEngineFeedback<
|
||||
T extends {
|
||||
family: string;
|
||||
detail: string;
|
||||
title?: string | null;
|
||||
runtime?: string | null;
|
||||
problem?: string | null;
|
||||
state: string;
|
||||
},
|
||||
>(
|
||||
row: T,
|
||||
applied: PerformanceProfileState | null,
|
||||
refreshing: boolean,
|
||||
saving: boolean,
|
||||
applicable: string[],
|
||||
) {
|
||||
if (saving && applicable.includes(row.family)) return { ...row, state: 'common.saving' };
|
||||
const selection = applied?.selections?.[row.family as PerformanceFamily];
|
||||
if (!refreshing || !selection || !applicable.includes(row.family)) return row;
|
||||
return {
|
||||
...row,
|
||||
title: selection.label || selection.model || selection.engine,
|
||||
detail: selection.model || selection.engine,
|
||||
runtime: undefined,
|
||||
problem: undefined,
|
||||
state: 'network.switching',
|
||||
};
|
||||
}
|
||||
@@ -1,574 +1 @@
|
||||
import { useStore } from '@tanstack/react-store';
|
||||
import { translationActivity } from '@/features/dub/translation-activity';
|
||||
import { TranslationAgentDock } from './translation-agent-dock';
|
||||
import { AgentDockFrame } from './agent-dock-frame';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BotIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
FolderOpenIcon,
|
||||
PencilLineIcon,
|
||||
PlayIcon,
|
||||
ShieldCheckIcon,
|
||||
SquareIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import codexIcon from '@lobehub/icons-static-svg/icons/codex-color.svg';
|
||||
import claudeIcon from '@lobehub/icons-static-svg/icons/claudecode-color.svg';
|
||||
import openCodeIcon from '@lobehub/icons-static-svg/icons/opencode.svg';
|
||||
import piIcon from '@lobehub/icons-static-svg/icons/pi.svg';
|
||||
import { getBridge } from '@/components/bridge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getFrontendLogs } from '@shared/utils/consoleBuffer';
|
||||
import { useBackendStatus } from '@/hooks/use-backend-status';
|
||||
import type {
|
||||
RepairAgentId,
|
||||
RepairAgentInfo,
|
||||
RepairAgentStatus,
|
||||
} from '../../../../preload/index.d';
|
||||
import {
|
||||
DEFAULT_REPAIR_AGENT_KEY,
|
||||
REPAIR_AGENT_OPEN_EVENT,
|
||||
takePendingRepairRequest,
|
||||
type RepairAgentRequest,
|
||||
} from '@/lib/repair-agent-events';
|
||||
import { apiJson } from '@/lib/api/client';
|
||||
import { collectRepairLogLines, repairLogCause } from '@/lib/repair-log-analysis';
|
||||
import { canRunRepairRequest, isAppOperationRequest } from '../../../../shared/repair-request';
|
||||
import './repair-agent-dock.css';
|
||||
|
||||
const OUTPUT_LIMIT = 250_000;
|
||||
const AGENT_ICONS = {
|
||||
codex: { src: codexIcon, monochrome: false },
|
||||
claude: { src: claudeIcon, monochrome: false },
|
||||
opencode: { src: openCodeIcon, monochrome: true },
|
||||
pi: { src: piIcon, monochrome: true },
|
||||
};
|
||||
|
||||
function savedRepairAgent(): RepairAgentId | null {
|
||||
try {
|
||||
return localStorage.getItem(DEFAULT_REPAIR_AGENT_KEY) as RepairAgentId | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberRepairAgent(agent: RepairAgentId): void {
|
||||
try {
|
||||
localStorage.setItem(DEFAULT_REPAIR_AGENT_KEY, agent);
|
||||
} catch {
|
||||
// The current session can still run it when persistent storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function RepairGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn('relative block size-5', className)} aria-hidden="true">
|
||||
<BotIcon className="absolute left-0 top-0 size-4" />
|
||||
<WrenchIcon className="absolute bottom-0 right-0 size-3 rounded-full bg-background p-px text-primary" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepairAgentDock() {
|
||||
const { t } = useTranslation();
|
||||
const translation = useStore(translationActivity);
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const backend = useBackendStatus();
|
||||
const bridge = getBridge();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [agents, setAgents] = useState<RepairAgentInfo[]>([]);
|
||||
const [selected, setSelected] = useState<RepairAgentId>('codex');
|
||||
const [status, setStatus] = useState<RepairAgentStatus>('idle');
|
||||
const [workspaceAvailable, setWorkspaceAvailable] = useState(true);
|
||||
const [workspacePath, setWorkspacePath] = useState('');
|
||||
const [sessionId, setSessionId] = useState('');
|
||||
const [output, setOutput] = useState('');
|
||||
const [report, setReport] = useState('');
|
||||
const [captured, setCaptured] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState<'diagnose' | 'fix'>('diagnose');
|
||||
const [error, setError] = useState('');
|
||||
const [autoFixReport, setAutoFixReport] = useState('');
|
||||
const [chooseDefault, setChooseDefault] = useState(false);
|
||||
const [showReportEditor, setShowReportEditor] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const terminal = useRef<HTMLPreElement>(null);
|
||||
const translationRunId = translation.runs.at(-1)?.id;
|
||||
useEffect(() => {
|
||||
if (translationRunId) setOpen(false);
|
||||
}, [translationRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bridge) return;
|
||||
let alive = true;
|
||||
void Promise.all([bridge.repair.list(), bridge.repair.getState()])
|
||||
.then(([found, current]) => {
|
||||
if (!alive) return;
|
||||
setAgents(found);
|
||||
setWorkspaceAvailable(current.workspaceAvailable);
|
||||
setWorkspacePath(current.workspacePath ?? '');
|
||||
setStatus(current.status);
|
||||
setSessionId(current.sessionId ?? '');
|
||||
setOutput(current.output);
|
||||
if (current.mode) setMode(current.mode);
|
||||
const preferred = found.find((agent) => agent.available)?.id;
|
||||
if (preferred) {
|
||||
setSelected((current) =>
|
||||
found.find((agent) => agent.id === current)?.available ? current : preferred,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(
|
||||
(reason) => alive && setError(reason instanceof Error ? reason.message : String(reason)),
|
||||
);
|
||||
const unsubscribe = bridge.repair.onEvent((event) => {
|
||||
if (event.type === 'output') {
|
||||
setSessionId(event.sessionId);
|
||||
setOutput((current) => (current + event.text).slice(-OUTPUT_LIMIT));
|
||||
} else {
|
||||
setSessionId(event.sessionId);
|
||||
setStatus(event.status);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [bridge]);
|
||||
|
||||
useEffect(() => {
|
||||
const openRequest = (detail?: RepairAgentRequest | null) => {
|
||||
if (detail?.report) {
|
||||
const lines = detail.report.split(/\r?\n/).filter(Boolean);
|
||||
setCaptured(collectRepairLogLines(lines));
|
||||
setReport(detail.report);
|
||||
if (detail.autoFix) setAutoFixReport(detail.report);
|
||||
setShowReportEditor(false);
|
||||
setLogsOpen(false);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
const openFromApp = (event: Event) => {
|
||||
openRequest(takePendingRepairRequest() ?? (event as CustomEvent<RepairAgentRequest>).detail);
|
||||
};
|
||||
window.addEventListener(REPAIR_AGENT_OPEN_EVENT, openFromApp);
|
||||
const pending = takePendingRepairRequest();
|
||||
if (pending) openRequest(pending);
|
||||
return () => window.removeEventListener(REPAIR_AGENT_OPEN_EVENT, openFromApp);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFixReport || agents.length === 0 || status === 'running') return;
|
||||
const saved = savedRepairAgent();
|
||||
const agent = agents.find((item) => item.id === saved && item.available);
|
||||
if (!agent || !canRunRepairRequest(autoFixReport, workspaceAvailable)) {
|
||||
setChooseDefault(true);
|
||||
return;
|
||||
}
|
||||
setSelected(agent.id);
|
||||
setChooseDefault(false);
|
||||
setAutoFixReport('');
|
||||
void run('fix', agent.id, autoFixReport);
|
||||
}, [agents, autoFixReport, status, workspaceAvailable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
const frontend = getFrontendLogs().map(
|
||||
(entry) => `${new Date(entry.t).toISOString()} [${entry.level}] ${entry.msg}`,
|
||||
);
|
||||
const finish = (backendLines: string[]) => {
|
||||
if (!alive) return;
|
||||
const found = collectRepairLogLines([...backendLines, ...frontend]);
|
||||
setCaptured((current) => collectRepairLogLines([...current, ...found]));
|
||||
if (found.length > 0) setReport((current) => current || found.join('\n'));
|
||||
};
|
||||
if (pathname.endsWith('/settings/logs')) {
|
||||
void apiJson<{ lines?: string[] }>('/system/logs?tail=1000')
|
||||
.then((response) => finish(response.lines ?? backend.logTail))
|
||||
.catch(() => finish(backend.logTail));
|
||||
} else {
|
||||
finish(backend.logTail);
|
||||
}
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [backend.logTail, open, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminal.current) terminal.current.scrollTop = terminal.current.scrollHeight;
|
||||
}, [output]);
|
||||
|
||||
if (!bridge) return null;
|
||||
const repair = bridge.repair;
|
||||
const running = status === 'running';
|
||||
const available = agents.some((agent) => agent.available);
|
||||
const activeReport = autoFixReport || report;
|
||||
const appOperation = isAppOperationRequest(activeReport);
|
||||
const canRun = canRunRepairRequest(activeReport, workspaceAvailable);
|
||||
const needsAttention =
|
||||
Boolean(backend.lastCrash && !backend.lastCrash.acknowledged) ||
|
||||
['crashed', 'failed'].includes(backend.stage);
|
||||
const recognizedCause = repairLogCause(captured);
|
||||
const selectedAgent = agents.find((agent) => agent.id === selected);
|
||||
const selectedAgentIcon = AGENT_ICONS[selected];
|
||||
const statusText = t(
|
||||
status === 'running'
|
||||
? 'common.loading'
|
||||
: status === 'failed'
|
||||
? 'common.error'
|
||||
: status === 'stopped'
|
||||
? 'common.stop'
|
||||
: status === 'complete'
|
||||
? 'repairAgent.complete'
|
||||
: 'repairAgent.ready',
|
||||
);
|
||||
const causeText =
|
||||
recognizedCause === 'hfAccess'
|
||||
? t('modelMaintenance.gatedAccessRequired')
|
||||
: recognizedCause === 'memory'
|
||||
? t('errors.crash_oom_kill')
|
||||
: recognizedCause === 'port'
|
||||
? t('backend.port_in_use', { port: backend.port })
|
||||
: recognizedCause === 'brokenRuntime'
|
||||
? t('errors.crash_broken_env')
|
||||
: '';
|
||||
async function run(
|
||||
nextMode: 'diagnose' | 'fix',
|
||||
agent: RepairAgentId = selected,
|
||||
nextReport: string = report,
|
||||
) {
|
||||
setError('');
|
||||
setOutput('');
|
||||
setMode(nextMode);
|
||||
setStatus('running');
|
||||
const context = JSON.stringify({
|
||||
route: pathname,
|
||||
frontendLogs: getFrontendLogs().slice(-120),
|
||||
});
|
||||
try {
|
||||
const started = await repair.start({
|
||||
agent,
|
||||
mode: nextMode,
|
||||
report: nextReport,
|
||||
context,
|
||||
});
|
||||
if (nextMode === 'fix') rememberRepairAgent(agent);
|
||||
setSessionId(started.sessionId);
|
||||
} catch (reason) {
|
||||
setStatus('failed');
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
}
|
||||
const chooseWorkspace = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const current = await repair.chooseWorkspace();
|
||||
setWorkspaceAvailable(current.workspaceAvailable);
|
||||
setWorkspacePath(current.workspacePath ?? '');
|
||||
} catch {
|
||||
setError(t('repairAgent.noSource'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!open && status !== 'running' && translation.runs.length) return <TranslationAgentDock />;
|
||||
|
||||
if (!open) {
|
||||
return createPortal(
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
aria-label={t('repairAgent.title')}
|
||||
title={t('repairAgent.title')}
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed right-3 top-1/2 z-40 size-11 -translate-y-1/2 rounded-full border border-sidebar-border bg-sidebar/90 text-sidebar-foreground shadow-[0_8px_28px_rgb(0_0_0/28%)] backdrop-blur-xl hover:bg-sidebar-row-hover"
|
||||
>
|
||||
<RepairGlyph />
|
||||
{needsAttention && (
|
||||
<span className="absolute right-0.5 top-0.5 size-2.5 rounded-full border-2 border-background bg-destructive" />
|
||||
)}
|
||||
</Button>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentDockFrame
|
||||
label={t('repairAgent.title')}
|
||||
className="repair-dock"
|
||||
resizable
|
||||
resizeStorageKey="voicestudio.repair-dock-height"
|
||||
>
|
||||
<header className="repair-dock-header">
|
||||
<div className="repair-dock-identity">
|
||||
<span className="repair-dock-glyph">
|
||||
<RepairGlyph className="text-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold">{t('repairAgent.title')}</p>
|
||||
<p className="repair-dock-status" role="status" title={statusText}>
|
||||
<span
|
||||
className={cn(
|
||||
'repair-dock-status-dot',
|
||||
status === 'running' && 'is-running',
|
||||
status === 'failed' && 'is-failed',
|
||||
status === 'complete' && 'is-complete',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={status === 'idle' ? 'sr-only' : 'truncate'}>{statusText}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="repair-dock-agents" role="group" aria-label={t('repairAgent.title')}>
|
||||
{agents.map((agent) => {
|
||||
const agentIcon = AGENT_ICONS[agent.id];
|
||||
return (
|
||||
<Button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected === agent.id ? 'secondary' : 'ghost'}
|
||||
disabled={running || !agent.available}
|
||||
aria-pressed={selected === agent.id}
|
||||
title={agent.available ? agent.version : t('repairAgent.notInstalled')}
|
||||
onClick={() => setSelected(agent.id)}
|
||||
className="repair-dock-agent"
|
||||
>
|
||||
<img
|
||||
src={agentIcon.src}
|
||||
alt=""
|
||||
className={cn('size-4 shrink-0', agentIcon.monochrome && 'dark:invert')}
|
||||
/>
|
||||
<span className="truncate">{agent.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="repair-dock-header-actions">
|
||||
{running && (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => void repair.stop()}>
|
||||
<SquareIcon />
|
||||
{t('common.stop')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="repair-dock-body">
|
||||
{output ? (
|
||||
<pre
|
||||
ref={terminal}
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
className="repair-dock-terminal studio-scrollbar"
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
) : (
|
||||
<div role="status" aria-live="polite" className="repair-dock-empty">
|
||||
<span className="repair-dock-empty-icon">
|
||||
<RepairGlyph className="size-6 text-muted-foreground" />
|
||||
</span>
|
||||
<p className="max-w-sm text-xs leading-5">
|
||||
{t(
|
||||
!canRun
|
||||
? 'repairAgent.noSource'
|
||||
: !available
|
||||
? 'repairAgent.noneFound'
|
||||
: 'repairAgent.ready',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="repair-dock-inspector studio-scrollbar">
|
||||
{!workspaceAvailable && !appOperation && (
|
||||
<Button type="button" variant="outline" onClick={() => void chooseWorkspace()}>
|
||||
<FolderOpenIcon />
|
||||
{t('settings.models_dir_choose')}
|
||||
</Button>
|
||||
)}
|
||||
{chooseDefault && canRun && available && (
|
||||
<div className="repair-dock-card repair-dock-default">
|
||||
<div className="repair-dock-default-copy">
|
||||
<span className="repair-dock-default-agent" aria-hidden="true">
|
||||
<img
|
||||
src={selectedAgentIcon.src}
|
||||
alt=""
|
||||
className={cn('size-4', selectedAgentIcon.monochrome && 'dark:invert')}
|
||||
/>
|
||||
</span>
|
||||
<p>{t('repairAgent.chooseDefault')}</p>
|
||||
</div>
|
||||
<div className="repair-dock-default-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!selectedAgent?.available}
|
||||
onClick={() => {
|
||||
const pending = autoFixReport || report;
|
||||
setChooseDefault(false);
|
||||
setAutoFixReport('');
|
||||
void run('fix', selected, pending);
|
||||
}}
|
||||
>
|
||||
<WrenchIcon />
|
||||
{t('repairAgent.useAutomatically', {
|
||||
agent: selectedAgent?.label ?? selected,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setChooseDefault(false);
|
||||
setAutoFixReport('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{captured.length > 0 ? (
|
||||
<div
|
||||
className="repair-dock-problems"
|
||||
role="region"
|
||||
aria-label={t('repairAgent.captured', { count: captured.length })}
|
||||
>
|
||||
<div className="repair-dock-problem-heading">
|
||||
<p className="flex min-w-0 items-center gap-2 font-medium text-destructive">
|
||||
<span className="repair-dock-problem-icon">
|
||||
<AlertTriangleIcon className="size-3.5" />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{t('repairAgent.captured', { count: captured.length })}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant={showReportEditor ? 'secondary' : 'ghost'}
|
||||
aria-label={t('repairAgent.placeholder')}
|
||||
aria-pressed={showReportEditor}
|
||||
title={t('repairAgent.placeholder')}
|
||||
onClick={() => setShowReportEditor((current) => !current)}
|
||||
>
|
||||
<PencilLineIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{causeText && <p className="mt-1.5 leading-4 text-foreground/80">{causeText}</p>}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="repair-dock-log-toggle"
|
||||
aria-expanded={logsOpen}
|
||||
onClick={() => setLogsOpen((current) => !current)}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn('repair-dock-log-chevron', logsOpen && 'is-open')}
|
||||
/>
|
||||
<span className="truncate">{t('crash.stderr_title')}</span>
|
||||
</Button>
|
||||
{logsOpen && (
|
||||
<pre className="repair-dock-log studio-scrollbar">{captured.join('\n')}</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="repair-dock-card flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2Icon className="size-3.5" />
|
||||
{t('logs.all_clear')}
|
||||
</p>
|
||||
)}
|
||||
{(captured.length === 0 || showReportEditor) && (
|
||||
<textarea
|
||||
value={report}
|
||||
disabled={running || !canRun || !available}
|
||||
onChange={(event) => setReport(event.target.value)}
|
||||
placeholder={t('repairAgent.placeholder')}
|
||||
aria-label={t('repairAgent.placeholder')}
|
||||
className="repair-dock-report"
|
||||
autoFocus={showReportEditor}
|
||||
/>
|
||||
)}
|
||||
{(error || (status === 'failed' && !output)) && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{error || t('common.error')}
|
||||
</p>
|
||||
)}
|
||||
{status === 'complete' && mode === 'fix' && workspaceAvailable && (
|
||||
<p className="text-xs text-emerald-500">{t('repairAgent.prReady')}</p>
|
||||
)}
|
||||
<div className="repair-dock-footer">
|
||||
<div className="repair-dock-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={running || !canRun || !available}
|
||||
onClick={() => void run('diagnose')}
|
||||
>
|
||||
<PlayIcon />
|
||||
{t('repairAgent.diagnose')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={running || !canRun || !available}
|
||||
onClick={() => void run('fix')}
|
||||
>
|
||||
<WrenchIcon />
|
||||
{t('repairAgent.fix')}
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t('repairAgent.contextNotice')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72 text-xs leading-5">
|
||||
{t('repairAgent.contextNotice')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{sessionId && (
|
||||
<span className="truncate font-mono text-[9px] text-muted-foreground">{sessionId}</span>
|
||||
)}
|
||||
{workspacePath && (
|
||||
<span
|
||||
className="truncate font-mono text-[9px] text-muted-foreground"
|
||||
title={workspacePath}
|
||||
>
|
||||
{workspacePath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AgentDockFrame>
|
||||
);
|
||||
}
|
||||
export { VoiceStudioAgent as RepairAgentDock } from './voice-studio-agent';
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
font-weight: 550;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
transition: background-color 150ms, border-color 150ms, color 150ms;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
|
||||
.sponsor-footer-action:hover {
|
||||
@@ -98,13 +101,214 @@
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sponsor-footer-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sponsor-follow-popup {
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 24px);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.sponsor-support-popup {
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 24px);
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.sponsor-support-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.sponsor-support-list .sponsor-support-link {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 45%, transparent);
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
color-mix(in srgb, var(--primary) 3%, transparent),
|
||||
transparent 75%
|
||||
);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sponsor-support-list .sponsor-support-link:hover {
|
||||
background-color: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.sponsor-support-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sponsor-support-link:hover,
|
||||
.sponsor-support-link:focus-visible {
|
||||
background: color-mix(in srgb, var(--primary) 7%, transparent);
|
||||
}
|
||||
|
||||
.sponsor-support-link:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.sponsor-support-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--muted) 60%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.sponsor-brand-logo {
|
||||
display: block;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sponsor-support-icon[data-brand='kofi'] {
|
||||
color: #ff6433;
|
||||
}
|
||||
.sponsor-support-icon[data-brand='paypal'] {
|
||||
color: #0070ba;
|
||||
}
|
||||
.sponsor-support-icon[data-brand='github'] {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.sponsor-support-icon[data-brand='discord'] {
|
||||
color: #5865f2;
|
||||
}
|
||||
|
||||
.sponsor-support-all {
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
padding: 13px 18px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.sponsor-follow-heading {
|
||||
padding: 14px 18px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.sponsor-follow-heading--warm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sponsor-follow-heading--warm > svg {
|
||||
color: color-mix(in srgb, var(--primary) 70%, var(--foreground));
|
||||
fill: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.sponsor-follow-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 2px 12px 12px;
|
||||
}
|
||||
|
||||
.sponsor-follow-card {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 13px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
border-radius: 13px;
|
||||
background: color-mix(in srgb, var(--foreground) 2%, var(--popover));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.sponsor-follow-identity {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sponsor-follow-card:hover,
|
||||
.sponsor-follow-card:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--foreground) 4%, var(--popover));
|
||||
border-color: color-mix(in srgb, var(--foreground) 16%, var(--border));
|
||||
}
|
||||
|
||||
.sponsor-follow-card:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.sponsor-follow-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.sponsor-follow-cta {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
align-self: center;
|
||||
max-width: 110px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sponsor-follow-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sponsor-footer-pro {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-left: auto;
|
||||
border-radius: 6px;
|
||||
color: var(--sidebar-muted-foreground);
|
||||
cursor: pointer;
|
||||
@@ -115,6 +319,10 @@
|
||||
color: var(--sidebar-foreground);
|
||||
}
|
||||
|
||||
.sponsor-footer-donate:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.sponsor-footer-pro:focus-visible {
|
||||
outline: 2px solid var(--sidebar-ring);
|
||||
outline-offset: 1px;
|
||||
|
||||
@@ -22,18 +22,57 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows Integrations, Become a Sponsor, then the Pro shortcut', () => {
|
||||
it('places follow and donate links immediately before the Pro shortcut', () => {
|
||||
render(<SponsorFooter />);
|
||||
const buttons = screen.getByRole('contentinfo').querySelectorAll('button');
|
||||
expect(Array.from(buttons, (button) => button.textContent)).toEqual([
|
||||
'integrationCatalog.title',
|
||||
'sponsorSlot.footer_brand',
|
||||
'',
|
||||
]);
|
||||
expect(buttons[2]).toHaveAccessibleName('supportPlans.title');
|
||||
expect(buttons).toHaveLength(5);
|
||||
expect(buttons[2]).toHaveAccessibleName('contact.follow_cta');
|
||||
expect(buttons[3]).toHaveAccessibleName('donate.title');
|
||||
expect(buttons[4]).toHaveAccessibleName('homeUi.collapseFooter');
|
||||
expect(screen.queryByRole('link', { name: 'support.star_github' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'contact.website' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://voicestudio.sh',
|
||||
);
|
||||
expect(screen.queryByRole('img')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['idebpalash', 'voicestudiosh'])('opens @%s in the external browser', async (handle) => {
|
||||
render(<SponsorFooter />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'contact.follow_cta' }));
|
||||
expect(await screen.findByText('Palash')).toBeVisible();
|
||||
expect(screen.getByText('VoiceStudio')).toBeVisible();
|
||||
const link = screen.getByRole('link', { name: `contact.follow_cta · @${handle}` });
|
||||
expect(link).toHaveAttribute('href', `https://x.com/${handle}`);
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(mock.open).toHaveBeenCalledWith(`https://x.com/${handle}`));
|
||||
});
|
||||
|
||||
it('opens compact support links from the heart and links to the full page', async () => {
|
||||
render(<SponsorFooter />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'donate.title' }));
|
||||
expect(await screen.findByRole('link', { name: 'Ko-fi' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://ko-fi.com/debpalash',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'PayPal' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://paypal.me/palashCoder',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'support.star_github' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/debpalash/VoiceStudio',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'support.join_discord' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://discord.gg/bzQavDfVV9',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Ko-fi' }));
|
||||
await waitFor(() => expect(mock.open).toHaveBeenCalledWith('https://ko-fi.com/debpalash'));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'donate.title' })[1]);
|
||||
expect(mock.navigate).toHaveBeenCalledWith({ to: '/settings/support' });
|
||||
});
|
||||
|
||||
it('opens Integrations from the first button', () => {
|
||||
render(<SponsorFooter />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'integrationCatalog.title' }));
|
||||
@@ -57,10 +96,17 @@ it('shows sourced audience details on sponsor focus', async () => {
|
||||
expect(screen.getByText('sponsorSlot.footer_stats_note')).toBeVisible();
|
||||
});
|
||||
|
||||
it('opens the Pro comparison from the right-hand X', () => {
|
||||
it('collapses and restores the footer without navigating to Pro', () => {
|
||||
render(<SponsorFooter />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'supportPlans.title' }));
|
||||
expect(mock.navigate).toHaveBeenCalledWith({ to: '/pro' });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'homeUi.collapseFooter' }));
|
||||
expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument();
|
||||
expect(mock.navigate).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'homeUi.expandFooter' }));
|
||||
expect(screen.getByRole('contentinfo')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'homeUi.collapseFooter' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the email fallback available from the booking form', async () => {
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { BlocksIcon, PlusIcon, XIcon } from 'lucide-react';
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BlocksIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
GlobeIcon,
|
||||
HeartIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { runRendererTask } from '@/lib/global-error-recovery';
|
||||
import { SponsorInquiry } from './sponsor-inquiry';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover';
|
||||
import palashAvatar from '../../../../../public/social/palash.png';
|
||||
import studioAvatar from '../../../../../public/social/voicestudio.jpg';
|
||||
import kofiLogo from '../../../../../public/social/kofi.svg';
|
||||
import paypalLogo from '../../../../../public/social/paypal.svg';
|
||||
import githubLogo from '../../../../../public/social/github.svg';
|
||||
import discordLogo from '../../../../../public/social/discord.svg';
|
||||
import { getBridge } from '@/components/bridge';
|
||||
import { DISCORD_URL, REPO_URL, X_URL } from '@shared/utils/contactLinks';
|
||||
import { KOFI_URL, PAYPAL_URL } from '@shared/utils/donateLinks';
|
||||
import './sponsor-footer.css';
|
||||
|
||||
// GitHub snapshot checked 2026-09-25. Installer downloads sum .AppImage,
|
||||
@@ -21,6 +39,23 @@ export function SponsorFooter() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [inquiryOpen, setInquiryOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (collapsed)
|
||||
return (
|
||||
<div className="flex h-6 shrink-0 justify-end border-t border-border/50 bg-sidebar px-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('homeUi.expandFooter')}
|
||||
title={t('homeUi.expandFooter')}
|
||||
aria-expanded={false}
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="flex w-8 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<ChevronUpIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sponsor-footer-host">
|
||||
@@ -73,27 +108,208 @@ export function SponsorFooter() {
|
||||
<small>{t('sponsorSlot.footer_stats_note')}</small>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="sponsor-footer-links">
|
||||
{[{ label: t('contact.website'), href: 'https://voicestudio.sh', Icon: GlobeIcon }].map(
|
||||
({ label, href, Icon }) => (
|
||||
<Tooltip key={href}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sponsor-footer-pro"
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
event.preventDefault();
|
||||
runRendererTask('Open project link', () => bridge.files.openExternal(href));
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent surface="theme" side="top">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={180}
|
||||
closeDelay={250}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="sponsor-footer-pro"
|
||||
aria-label={t('contact.follow_cta')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<path d="M18.9 2H22l-6.8 7.8L23.2 22h-6.3L12 14.6 5.5 22H2.3l8.2-9.4L.8 2h6.5l5.1 6.8L18.9 2Zm-1.1 18h1.7L6.4 3.9H4.6L17.8 20Z" />
|
||||
</svg>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="sponsor-follow-popup"
|
||||
aria-label={t('contact.follow_cta')}
|
||||
>
|
||||
<p className="sponsor-follow-heading sponsor-follow-heading--warm">
|
||||
<HeartIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
<span>{t('contact.support_follow_title')}</span>
|
||||
</p>
|
||||
<div className="sponsor-follow-list">
|
||||
{[
|
||||
{ name: 'Palash', handle: 'idebpalash', href: X_URL, avatar: palashAvatar },
|
||||
{
|
||||
name: 'VoiceStudio',
|
||||
handle: 'voicestudiosh',
|
||||
href: 'https://x.com/voicestudiosh',
|
||||
avatar: studioAvatar,
|
||||
},
|
||||
].map(({ name, handle, href, avatar }) => (
|
||||
<a
|
||||
key={handle}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sponsor-follow-card"
|
||||
data-profile={handle}
|
||||
aria-label={`${t('contact.follow_cta')} · @${handle}`}
|
||||
onClick={(event) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
event.preventDefault();
|
||||
runRendererTask('Open X profile', () => bridge.files.openExternal(href));
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
className="sponsor-follow-avatar"
|
||||
/>
|
||||
<span className="sponsor-follow-cta">{t('contact.follow_cta')}</span>
|
||||
<span className="sponsor-follow-identity">
|
||||
<span className="block text-sm font-semibold">{name}</span>
|
||||
<span className="block text-xs text-muted-foreground">@{handle}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={180}
|
||||
closeDelay={250}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="sponsor-footer-pro sponsor-footer-donate"
|
||||
aria-label={t('donate.title')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HeartIcon aria-hidden="true" className="size-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="sponsor-support-popup"
|
||||
aria-label={t('donate.title')}
|
||||
>
|
||||
<p className="sponsor-follow-heading sponsor-follow-heading--warm">
|
||||
<HeartIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
<span>{t('donate.hero_title')}</span>
|
||||
</p>
|
||||
<div className="sponsor-support-list">
|
||||
{[
|
||||
{ label: 'Ko-fi', href: KOFI_URL, logo: kofiLogo, brand: 'kofi' },
|
||||
{ label: 'PayPal', href: PAYPAL_URL, logo: paypalLogo, brand: 'paypal' },
|
||||
{
|
||||
label: t('support.star_github'),
|
||||
href: REPO_URL,
|
||||
logo: githubLogo,
|
||||
brand: 'github',
|
||||
},
|
||||
{
|
||||
label: t('support.join_discord'),
|
||||
href: DISCORD_URL,
|
||||
logo: discordLogo,
|
||||
brand: 'discord',
|
||||
},
|
||||
].map(({ label, href, logo, brand }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sponsor-support-link"
|
||||
onClick={(event) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
event.preventDefault();
|
||||
runRendererTask('Open support link', () => bridge.files.openExternal(href));
|
||||
}}
|
||||
>
|
||||
<span className="sponsor-support-icon" data-brand={brand}>
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
width={21}
|
||||
height={21}
|
||||
className="sponsor-brand-logo"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
<ArrowUpRightIcon
|
||||
aria-hidden="true"
|
||||
className="size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sponsor-footer-pro"
|
||||
aria-label={t('supportPlans.title')}
|
||||
className="sponsor-support-link sponsor-support-all"
|
||||
onClick={() =>
|
||||
runRendererTask('Open Pro comparison', () =>
|
||||
navigate({ to: '/pro' }),
|
||||
)
|
||||
runRendererTask('Open donations', () => navigate({ to: '/settings/support' }))
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon aria-hidden="true" className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent surface="theme" side="top">
|
||||
{t('supportPlans.title')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
>
|
||||
<HeartIcon aria-hidden="true" className="size-4" />
|
||||
<span className="min-w-0 flex-1">{t('donate.title')}</span>
|
||||
<ArrowUpRightIcon aria-hidden="true" className="size-3.5" />
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="sponsor-footer-pro"
|
||||
aria-label={t('homeUi.collapseFooter')}
|
||||
aria-expanded={true}
|
||||
onClick={() => setCollapsed(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon aria-hidden="true" className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent surface="theme" side="top">
|
||||
{t('homeUi.collapseFooter')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SponsorInquiry open={inquiryOpen} onOpenChange={setInquiryOpen} />
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EnginesResponse } from '@/lib/api/types';
|
||||
import { engineFamilyState } from '@/hooks/use-engines';
|
||||
import { resolveRemoteRuntime, resolveRuntimeHealth } from './status-runtime';
|
||||
import { resolveRemoteRuntime, resolveRuntimeHealth, sidebarToolState } from './status-runtime';
|
||||
|
||||
describe('plain-language tool status', () => {
|
||||
it('never reports cached readiness when the backend is offline', () => {
|
||||
for (const state of ['engineRuntime.ready', 'engineRuntime.idle', 'engineRuntime.working']) {
|
||||
expect(sidebarToolState(state, false)).toBe('modelMaintenance.offline');
|
||||
}
|
||||
});
|
||||
|
||||
it('distinguishes loading on use from setup problems without relying on color', () => {
|
||||
expect(sidebarToolState('engineRuntime.idle', true)).toBe('modelSettings.available');
|
||||
expect(sidebarToolState('modelSettings.unavailable', true)).toBe('sidebarTools.checkSetup');
|
||||
expect(sidebarToolState('engineRuntime.working', true)).toBe('engineRuntime.working');
|
||||
expect(sidebarToolState('engineSidebar.inactive', true)).toBe('engineSidebar.inactive');
|
||||
});
|
||||
});
|
||||
|
||||
const engines = (available = true): EnginesResponse => ({
|
||||
tts: {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useRef, useState, type ReactNode } from 'react';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
usePerformanceProfile,
|
||||
type PerformanceProfileState,
|
||||
} from '@/hooks/use-performance-profile';
|
||||
import { presetEngineFeedback } from './preset-engine-feedback';
|
||||
import { engineDetailLevels, useEngineDetailLevel } from './use-engine-detail-level';
|
||||
import { EngineRow } from './engine-row';
|
||||
import { useTranslationEngines } from '@/features/settings/translation-settings';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import {
|
||||
@@ -9,18 +15,21 @@ import {
|
||||
BrainCircuitIcon,
|
||||
KeyboardIcon,
|
||||
UsersRoundIcon,
|
||||
ArrowLeftRightIcon,
|
||||
ChevronDownIcon,
|
||||
SlidersHorizontalIcon,
|
||||
ListIcon,
|
||||
LayersIcon,
|
||||
CodeXmlIcon,
|
||||
ChevronRightIcon,
|
||||
CpuIcon,
|
||||
MemoryStickIcon,
|
||||
MonitorUpIcon,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsFetching, useQuery } from '@tanstack/react-query';
|
||||
import { apiJson } from '@/lib/api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBackendStatus } from '@/hooks/use-backend-status';
|
||||
import { engineFamilyState, useEngines } from '@/hooks/use-engines';
|
||||
import { useDeviceUsage } from '@/hooks/use-device-usage';
|
||||
import { useDictationSelection } from '@/hooks/use-dictation-selection';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppActivities } from '@/lib/app-activity';
|
||||
@@ -30,7 +39,6 @@ import {
|
||||
type SidebarModelStatus,
|
||||
} from './status-runtime';
|
||||
import { PerformanceProfile } from '@/components/performance-profile';
|
||||
import { ComputeVendorIcon, formatComputeRuntime } from '@/components/compute-vendor-icon';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover';
|
||||
import { ComputeTargetChoices } from '@/components/compute-target-choices';
|
||||
import { useComputeRuntime, useComputeTarget } from '@/hooks/use-compute-target';
|
||||
@@ -59,21 +67,6 @@ interface LoadedModelStatus {
|
||||
is_active_engine?: boolean | null;
|
||||
}
|
||||
|
||||
interface DeviceUsage {
|
||||
cpu: number;
|
||||
cpu_model: string;
|
||||
cpu_physical_cores: number;
|
||||
cpu_logical_cores: number;
|
||||
cpu_frequency_ghz: number;
|
||||
ram: number;
|
||||
total_ram: number;
|
||||
gpu_name: string;
|
||||
gpu_utilization: number | null;
|
||||
vram: number;
|
||||
total_vram: number;
|
||||
gpu_active: boolean;
|
||||
}
|
||||
|
||||
function boundedPercent(value: number, total = 100) {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, (value / total) * 100));
|
||||
@@ -82,7 +75,9 @@ function boundedPercent(value: number, total = 100) {
|
||||
function formatBytes(bytes: number) {
|
||||
const gib = Math.max(0, bytes) / 1024 ** 3;
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'unit', unit: 'gigabyte', maximumFractionDigits: gib >= 10 ? 0 : 1,
|
||||
style: 'unit',
|
||||
unit: 'gigabyte',
|
||||
maximumFractionDigits: gib >= 10 ? 0 : 1,
|
||||
}).format(gib);
|
||||
}
|
||||
|
||||
@@ -143,21 +138,6 @@ const engineLinkClass =
|
||||
const engineIconClass =
|
||||
'size-4 transition-filter duration-150 group-hover/engine-icon:drop-shadow-[0_1px_3px_rgb(0_0_0/20%)]';
|
||||
|
||||
function EngineIconStatus({ Icon, state }: { Icon: typeof AudioLinesIcon; state: string }) {
|
||||
return (
|
||||
<>
|
||||
<Icon className={engineIconClass} aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-2 bottom-0.5 h-0.5 rounded-full transition-colors duration-200',
|
||||
engineStateClass(state),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DOT: Record<BackendStage, string> = {
|
||||
setup_required: 'bg-warning',
|
||||
installing: 'bg-warning animate-pulse motion-reduce:animate-none',
|
||||
@@ -170,65 +150,6 @@ const DOT: Record<BackendStage, string> = {
|
||||
failed: 'bg-destructive',
|
||||
};
|
||||
|
||||
function EngineTip({
|
||||
family,
|
||||
detail,
|
||||
title,
|
||||
runtime,
|
||||
problem,
|
||||
state,
|
||||
online,
|
||||
}: {
|
||||
family: string;
|
||||
detail: string;
|
||||
title?: string | null;
|
||||
runtime?: string | null;
|
||||
problem?: string | null;
|
||||
state: string;
|
||||
online: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const shownState = online ? state : 'engineSidebar.offline';
|
||||
return (
|
||||
<div className="flex w-full min-w-0 max-w-[calc(100vw-2rem)] flex-col gap-2 py-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{t('engineSidebar.' + family)}</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
online ? engineStateClass(state) : 'bg-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{t(shownState)}
|
||||
</span>
|
||||
</div>
|
||||
{title && title !== detail && <p className="text-sm font-medium leading-snug">{title}</p>}
|
||||
<div className="space-y-1 border-t border-border/60 pt-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">
|
||||
{t('modelSettings.selected')}
|
||||
</p>
|
||||
<p className="break-words text-xs leading-relaxed [overflow-wrap:anywhere]">{detail}</p>
|
||||
</div>
|
||||
{runtime && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ComputeVendorIcon runtime={runtime} className="size-3.5 shrink-0" />
|
||||
<span>{formatComputeRuntime(runtime)}</span>
|
||||
</div>
|
||||
)}
|
||||
{problem && (
|
||||
<p className="border-t border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
<p className="border-t border-border/60 pt-2 text-[10px] text-muted-foreground">
|
||||
{t('modelSettings.change')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
compact = false,
|
||||
inline = false,
|
||||
@@ -239,23 +160,22 @@ export function StatusBar({
|
||||
footerLeading?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { level, chooseLevel } = useEngineDetailLevel();
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [deviceOpen, setDeviceOpen] = useState(false);
|
||||
const profile = usePerformanceProfile();
|
||||
const [appliedProfile, setAppliedProfile] = useState<PerformanceProfileState | null>(null);
|
||||
const enginesRefreshing = useIsFetching({ queryKey: ['engines'] }) > 0;
|
||||
const status = useBackendStatus();
|
||||
const computeTarget = useComputeTarget(status.stage === 'ready');
|
||||
const activeComputeTarget = computeTarget.data?.active;
|
||||
const activeRemoteTarget = activeComputeTarget?.remote
|
||||
? computeTarget.data?.targets.find((item) => item.id === activeComputeTarget.worker_id)
|
||||
: undefined;
|
||||
const tipAnchor = useRef<HTMLDivElement>(null);
|
||||
const [selectedDetail, setSelectedDetail] = useState<string | null>('tts');
|
||||
const activities = useAppActivities();
|
||||
const activityCount = Object.values(activities).reduce((total, count) => total + count, 0);
|
||||
const deviceUsage = useQuery({
|
||||
queryKey: ['sysinfo'],
|
||||
enabled: status.stage === 'ready' && deviceOpen && !activeComputeTarget?.remote,
|
||||
queryFn: ({ signal }) => apiJson<DeviceUsage>('/sysinfo', { signal }),
|
||||
refetchInterval: deviceOpen ? 2_000 : false,
|
||||
});
|
||||
const deviceUsage = useDeviceUsage(deviceOpen && !activeComputeTarget?.remote);
|
||||
const { data, isLoading: enginesLoading, isError: enginesError } = useEngines();
|
||||
const selectedTtsFamily = engineFamilyState(data, 'tts');
|
||||
const selectedTts = selectedTtsFamily?.backends.find(
|
||||
@@ -516,32 +436,36 @@ export function StatusBar({
|
||||
)}
|
||||
{activeRemoteTarget.gpu_name &&
|
||||
(activeRemoteTarget.gpu_utilization_percent != null ||
|
||||
(activeRemoteTarget.free_memory_bytes != null && activeRemoteTarget.gpu_memory_bytes > 0)) && (
|
||||
<DeviceMetric
|
||||
Icon={MonitorUpIcon}
|
||||
label={t('settings.device_family_gpu')}
|
||||
value={
|
||||
[
|
||||
(activeRemoteTarget.free_memory_bytes != null &&
|
||||
activeRemoteTarget.gpu_memory_bytes > 0)) && (
|
||||
<DeviceMetric
|
||||
Icon={MonitorUpIcon}
|
||||
label={t('settings.device_family_gpu')}
|
||||
value={[
|
||||
activeRemoteTarget.gpu_utilization_percent != null
|
||||
? `${Math.round(activeRemoteTarget.gpu_utilization_percent)}%`
|
||||
: null,
|
||||
activeRemoteTarget.free_memory_bytes != null && activeRemoteTarget.gpu_memory_bytes > 0
|
||||
activeRemoteTarget.free_memory_bytes != null &&
|
||||
activeRemoteTarget.gpu_memory_bytes > 0
|
||||
? `${formatBytes(
|
||||
activeRemoteTarget.gpu_memory_bytes - activeRemoteTarget.free_memory_bytes,
|
||||
)} / ${formatBytes(activeRemoteTarget.gpu_memory_bytes)}`
|
||||
: null,
|
||||
]
|
||||
.filter((value): value is string => value != null)
|
||||
.join(' · ')
|
||||
}
|
||||
percent={
|
||||
activeRemoteTarget.free_memory_bytes != null && activeRemoteTarget.gpu_memory_bytes > 0
|
||||
? boundedPercent(activeRemoteTarget.gpu_memory_bytes - activeRemoteTarget.free_memory_bytes, activeRemoteTarget.gpu_memory_bytes)
|
||||
: activeRemoteTarget.gpu_utilization_percent ?? 0
|
||||
}
|
||||
detail={activeRemoteTarget.gpu_name}
|
||||
/>
|
||||
)}
|
||||
.join(' · ')}
|
||||
percent={
|
||||
activeRemoteTarget.free_memory_bytes != null &&
|
||||
activeRemoteTarget.gpu_memory_bytes > 0
|
||||
? boundedPercent(
|
||||
activeRemoteTarget.gpu_memory_bytes - activeRemoteTarget.free_memory_bytes,
|
||||
activeRemoteTarget.gpu_memory_bytes,
|
||||
)
|
||||
: (activeRemoteTarget.gpu_utilization_percent ?? 0)
|
||||
}
|
||||
detail={activeRemoteTarget.gpu_name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : deviceUsage.isError ? (
|
||||
<button
|
||||
@@ -712,6 +636,87 @@ export function StatusBar({
|
||||
: 'modelSettings.unavailable',
|
||||
},
|
||||
];
|
||||
const refreshing = {
|
||||
tts: enginesRefreshing,
|
||||
asr: enginesRefreshing,
|
||||
llm: enginesRefreshing,
|
||||
translation: translation.isFetching,
|
||||
dictation: dictation.isFetching,
|
||||
diarisation: diarisation.isFetching,
|
||||
};
|
||||
const presetRefreshing = Object.values(refreshing).some(Boolean);
|
||||
const appliedRefreshStarted = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!appliedProfile) return;
|
||||
if (presetRefreshing) appliedRefreshStarted.current = true;
|
||||
else if (appliedRefreshStarted.current) {
|
||||
appliedRefreshStarted.current = false;
|
||||
setAppliedProfile(null);
|
||||
}
|
||||
}, [presetRefreshing, appliedProfile]);
|
||||
const applicable = profile.data?.applicable_families ?? profile.data?.implemented_families ?? [];
|
||||
const displayEngines = engines.map((row) =>
|
||||
activeComputeTarget?.remote && row.family === 'tts'
|
||||
? row
|
||||
: presetEngineFeedback(
|
||||
row,
|
||||
appliedProfile,
|
||||
refreshing[row.family as keyof typeof refreshing],
|
||||
profile.isSaving,
|
||||
applicable,
|
||||
),
|
||||
);
|
||||
const viewControl = (
|
||||
<Popover open={viewOpen} onOpenChange={setViewOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
data-slot="engine-view-toggle"
|
||||
aria-label={
|
||||
t('sidebarTools.title') +
|
||||
': ' +
|
||||
t(level === 'models' ? 'modelSettings.models' : 'sidebarTools.' + level)
|
||||
}
|
||||
title={t('sidebarTools.title')}
|
||||
className="grid size-8 shrink-0 place-items-center rounded-md outline-none hover:bg-sidebar-accent/60 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SlidersHorizontalIcon className="size-3.5" aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" className="w-36 p-1">
|
||||
<div role="group" aria-label={t('sidebarTools.title')}>
|
||||
{engineDetailLevels.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={level === value}
|
||||
onClick={() => {
|
||||
chooseLevel(value);
|
||||
setViewOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-h-9 w-full items-center gap-2 rounded px-3 text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
level === value
|
||||
? 'bg-sidebar-accent font-medium text-foreground'
|
||||
: 'hover:bg-sidebar-accent/50',
|
||||
)}
|
||||
>
|
||||
{value === 'simple' ? (
|
||||
<ListIcon className="size-3.5" aria-hidden="true" />
|
||||
) : value === 'models' ? (
|
||||
<LayersIcon className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<CodeXmlIcon className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t(value === 'models' ? 'modelSettings.models' : 'sidebarTools.' + value)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
const iconDevicePopover = (
|
||||
<Popover open={deviceOpen} onOpenChange={setDeviceOpen}>
|
||||
<PopoverTrigger
|
||||
@@ -751,7 +756,7 @@ export function StatusBar({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<footer className="@container/engines w-full min-w-0 max-w-full border-t border-border/50 px-3 py-1.5 text-[length:var(--text-caption)] text-muted-foreground">
|
||||
<footer className="@container/engines max-h-[55dvh] w-full min-w-0 max-w-full overflow-y-auto overscroll-contain border-t border-border/50 px-3 py-1.5 text-[length:var(--text-caption)] text-muted-foreground">
|
||||
<div>
|
||||
{!footerLeading && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
@@ -776,126 +781,50 @@ export function StatusBar({
|
||||
</PopoverTrigger>
|
||||
{deviceContent}
|
||||
</Popover>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? t('paneActions.collapse') : t('modelSettings.models')}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="sidebar-engine-details"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md outline-none transition-colors hover:bg-sidebar-accent/65 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-150 motion-reduce:transition-none',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{viewControl}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={tipAnchor}
|
||||
className="mt-0.5 grid w-full min-w-0 grid-cols-6 gap-1 rounded-lg border border-border/50 bg-sidebar-accent/25 p-1"
|
||||
>
|
||||
{engines.map(({ family, Icon, detail, title, problem, runtime, state }) => (
|
||||
<Tooltip key={family}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Link
|
||||
to="/settings/models/$family"
|
||||
params={{ family }}
|
||||
aria-label={t('modelSettings.change') + ' ' + t('engineSidebar.' + family)}
|
||||
className={engineLinkClass}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EngineIconStatus Icon={Icon} state={state} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
surface="theme"
|
||||
anchor={tipAnchor}
|
||||
showArrow={false}
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-[var(--anchor-width)] max-w-none flex-col items-start gap-1"
|
||||
>
|
||||
<EngineTip
|
||||
family={family}
|
||||
detail={detail}
|
||||
title={title}
|
||||
runtime={runtime}
|
||||
problem={problem}
|
||||
state={state}
|
||||
online={status.stage === 'ready'}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
{status.stage === 'ready' && (
|
||||
<PerformanceProfile
|
||||
onApplied={(applied) => {
|
||||
appliedRefreshStarted.current = presetRefreshing;
|
||||
setAppliedProfile(applied);
|
||||
if (level === 'simple') chooseLevel('models');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
id="sidebar-engine-details"
|
||||
hidden={status.stage !== 'ready' || !expanded}
|
||||
className="mt-1.5 space-y-0.5"
|
||||
data-detail-level={level}
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
level === 'simple'
|
||||
? 'rounded-lg bg-sidebar-accent/20'
|
||||
: 'max-h-[32dvh] space-y-0.5 overflow-y-auto overscroll-contain',
|
||||
)}
|
||||
>
|
||||
{status.stage === 'ready' &&
|
||||
engines.map(({ family, Icon, detail, title, problem, runtime, state }) => (
|
||||
<Link
|
||||
key={family}
|
||||
to="/settings/models/$family"
|
||||
params={{ family }}
|
||||
aria-label={t('modelSettings.change') + ' ' + t('engineSidebar.' + family)}
|
||||
title={[title, detail, runtime, problem, t(state)].filter(Boolean).join(' \u00b7 ')}
|
||||
className="group/engine flex min-w-0 items-center gap-2 rounded-md px-2 py-1 outline-none transition-[background-color,box-shadow,backdrop-filter] duration-150 hover:bg-sidebar-accent/65 hover:backdrop-blur-xl hover:shadow-[inset_0_1px_0_rgb(255_255_255/8%),0_5px_14px_rgb(0_0_0/10%)] hover:ring-1 hover:ring-inset hover:ring-sidebar-border/60 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Icon
|
||||
className="size-3.5 shrink-0 transition-filter duration-150 group-hover/engine:drop-shadow-[0_1px_3px_rgb(0_0_0/20%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<p className="min-w-0 basis-[45%] shrink-0 truncate font-medium text-foreground/85">
|
||||
{t('engineSidebar.' + family)}
|
||||
</p>
|
||||
<p className="min-w-0 flex-1 truncate">{detail}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', engineStateClass(state))}
|
||||
title={t(state)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ArrowLeftRightIcon
|
||||
className="size-3.5 shrink-0 opacity-50 transition-opacity duration-150 group-hover/engine:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
{displayEngines
|
||||
.filter(({ family }) => level !== 'simple' || family === 'tts')
|
||||
.map((row) => (
|
||||
<EngineRow
|
||||
key={row.family}
|
||||
row={row}
|
||||
level={level}
|
||||
online={status.stage === 'ready'}
|
||||
dotClass={engineStateClass(row.state)}
|
||||
open={selectedDetail === row.family}
|
||||
onToggle={() =>
|
||||
setSelectedDetail((current) => (current === row.family ? null : row.family))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{status.stage === 'ready' && (
|
||||
<div className="mt-1.5">
|
||||
<PerformanceProfile tooltipAnchor={tipAnchor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footerLeading && (
|
||||
<div className="mt-1.5 flex items-center gap-1 border-t border-border/50 pt-1.5">
|
||||
<div className="sticky bottom-0 mt-1.5 flex items-center gap-1 border-t border-border/50 bg-sidebar pt-1.5">
|
||||
{footerLeading}
|
||||
<div className="min-w-0 flex-1">{iconDevicePopover}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? t('paneActions.collapse') : t('modelSettings.models')}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="sidebar-engine-details"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="ml-auto flex size-7 shrink-0 items-center justify-center rounded-md outline-none transition-colors hover:bg-sidebar-accent/65 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-150 motion-reduce:transition-none',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{viewControl}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,13 @@ export interface SidebarModelStatus {
|
||||
|
||||
export type RuntimeHealth = 'checking' | 'unavailable' | 'loading' | 'ready';
|
||||
|
||||
export function sidebarToolState(state: string, online: boolean) {
|
||||
if (!online) return 'modelMaintenance.offline';
|
||||
if (state === 'engineRuntime.idle') return 'modelSettings.available';
|
||||
if (state === 'modelSettings.unavailable') return 'sidebarTools.checkSetup';
|
||||
return state;
|
||||
}
|
||||
|
||||
export type RemoteRuntimeState = 'checking' | 'unavailable' | 'working' | 'ready' | 'idle';
|
||||
|
||||
export function resolveRemoteRuntime(
|
||||
|
||||
@@ -2,42 +2,77 @@
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 28%, var(--border));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, var(--primary) 11%, var(--card));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
border: 1px solid color-mix(in oklab, #b99afa 35%, var(--border));
|
||||
border-radius: 9px;
|
||||
color: color-mix(in oklab, #b99afa 60%, var(--foreground));
|
||||
background: linear-gradient(
|
||||
115deg,
|
||||
color-mix(in oklab, #9874df 15%, var(--card)),
|
||||
color-mix(in oklab, #ecb2d5 7%, var(--card))
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(220 200 255 / 10%),
|
||||
0 2px 10px rgb(133 94 196 / 5%);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
color 180ms ease;
|
||||
}
|
||||
.support-shortcut:hover,
|
||||
.support-shortcut:focus-visible {
|
||||
border-color: color-mix(in oklab, var(--primary) 50%, var(--border));
|
||||
background: color-mix(in oklab, var(--primary) 17%, var(--card));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: color-mix(in oklab, #d1b8ff 50%, var(--foreground));
|
||||
border-color: color-mix(in oklab, #b99afa 60%, var(--border));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(220 200 255 / 18%),
|
||||
0 0 16px rgb(160 119 220 / 13%);
|
||||
}
|
||||
.support-shortcut::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: -85% -45%;
|
||||
inset: -70% -40%;
|
||||
content: '';
|
||||
opacity: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 40%, color-mix(in srgb, var(--primary) 30%, transparent), transparent 50%),
|
||||
repeating-radial-gradient(ellipse at 0% 100%, transparent 0 12px, color-mix(in srgb, var(--primary) 22%, transparent) 13px 15px, transparent 16px 28px);
|
||||
transform: translateX(-12%) rotate(-5deg);
|
||||
transition: opacity 180ms ease;
|
||||
background: linear-gradient(110deg, transparent 35%, rgb(222 196 255 / 12%) 50%, transparent 65%);
|
||||
transform: translateX(-35%);
|
||||
transition: transform 500ms ease;
|
||||
}
|
||||
.support-shortcut:hover::before,
|
||||
.support-shortcut:focus-visible::before {
|
||||
opacity: 1;
|
||||
animation: support-shortcut-waves 1.8s ease-in-out infinite alternate;
|
||||
transform: translateX(35%);
|
||||
}
|
||||
@keyframes support-shortcut-waves {
|
||||
from { transform: translateX(-12%) rotate(-5deg) scale(1); }
|
||||
to { transform: translateX(12%) rotate(5deg) scale(1.08); }
|
||||
.github-star-shortcut {
|
||||
border: 1px solid color-mix(in oklab, #d9b36a 22%, var(--border));
|
||||
border-radius: 9px;
|
||||
color: color-mix(in oklab, #d9b36a 18%, var(--foreground));
|
||||
background: linear-gradient(120deg, color-mix(in oklab, #d9b36a 5%, var(--card)), var(--card));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
.github-star-shortcut:hover,
|
||||
.github-star-shortcut:focus-visible {
|
||||
border-color: color-mix(in oklab, #d9b36a 50%, var(--border));
|
||||
box-shadow: 0 0 14px rgb(217 179 106 / 9%);
|
||||
}
|
||||
.github-star-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 2px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in oklab, #d9b36a 9%, transparent);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.github-star-count svg {
|
||||
color: color-mix(in oklab, #d9b36a 70%, var(--foreground));
|
||||
fill: color-mix(in srgb, #d9b36a 22%, transparent);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.support-shortcut,
|
||||
.support-shortcut::before { transition: none; }
|
||||
.support-shortcut:hover::before,
|
||||
.support-shortcut:focus-visible::before { animation: none; }
|
||||
.support-shortcut::before,
|
||||
.github-star-shortcut {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,33 @@ import { ArrowUpRightIcon, GemIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import './support-shortcut.css';
|
||||
import { GithubStar } from './github-star';
|
||||
|
||||
export function SupportShortcut() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Link
|
||||
to="/pro"
|
||||
aria-label={t('supportPlans.get_pro')}
|
||||
className="support-shortcut app-no-drag flex h-8 items-center gap-1.5 px-1.5 text-primary hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary motion-safe:transition-colors"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GemIcon aria-hidden="true" className="size-3.5" />
|
||||
<span className="hidden text-xs font-semibold sm:inline">{t('supportPlans.get_pro')}</span>
|
||||
<ArrowUpRightIcon aria-hidden="true" className="size-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent surface="theme" side="bottom">
|
||||
{t('supportPlans.get_pro')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Link
|
||||
to="/pro"
|
||||
aria-label={t('supportPlans.get_pro')}
|
||||
className="support-shortcut app-no-drag flex h-8 items-center gap-1.5 px-2.5 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GemIcon aria-hidden="true" className="size-3.5" />
|
||||
<span className="hidden text-xs font-semibold sm:inline">
|
||||
{t('supportPlans.get_pro')}
|
||||
</span>
|
||||
<ArrowUpRightIcon aria-hidden="true" className="size-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent surface="theme" side="bottom">
|
||||
{t('supportPlans.get_pro')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<GithubStar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEngineDetailLevel } from './use-engine-detail-level';
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('engine detail preference', () => {
|
||||
it('starts simple and remembers an explicit view without changing engine settings', () => {
|
||||
const first = renderHook(useEngineDetailLevel);
|
||||
expect(first.result.current.level).toBe('simple');
|
||||
act(() => first.result.current.chooseLevel('details'));
|
||||
first.unmount();
|
||||
const restored = renderHook(useEngineDetailLevel);
|
||||
expect(restored.result.current.level).toBe('details');
|
||||
act(() => restored.result.current.chooseLevel('simple'));
|
||||
expect(localStorage.getItem('voicestudio.engine-detail-level')).toBe('simple');
|
||||
});
|
||||
|
||||
it('falls back for invalid preferences and keeps working without storage', () => {
|
||||
localStorage.setItem('voicestudio.engine-detail-level', 'invalid');
|
||||
expect(renderHook(useEngineDetailLevel).result.current.level).toBe('simple');
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
});
|
||||
const view = renderHook(useEngineDetailLevel);
|
||||
act(() => view.result.current.chooseLevel('models'));
|
||||
expect(view.result.current.level).toBe('models');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export const engineDetailLevels = ['simple', 'models', 'details'] as const;
|
||||
export type EngineDetailLevel = (typeof engineDetailLevels)[number];
|
||||
const storageKey = 'voicestudio.engine-detail-level';
|
||||
|
||||
export function useEngineDetailLevel() {
|
||||
const [level, setLevel] = useState<EngineDetailLevel>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
return engineDetailLevels.includes(saved as EngineDetailLevel)
|
||||
? (saved as EngineDetailLevel)
|
||||
: 'simple';
|
||||
} catch {
|
||||
return 'simple';
|
||||
}
|
||||
});
|
||||
const chooseLevel = (next: EngineDetailLevel) => {
|
||||
setLevel(next);
|
||||
try {
|
||||
localStorage.setItem(storageKey, next);
|
||||
} catch {
|
||||
// Keep the control usable when persistent storage is unavailable.
|
||||
}
|
||||
};
|
||||
return { level, chooseLevel };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
.agent-workspace {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.agent-workspace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.agent-workspace-header strong {
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.agent-workspace-state {
|
||||
flex: 1;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
.agent-workspace-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.agent-workspace-harnesses,
|
||||
.agent-workspace-modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
.agent-workspace-modes {
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
padding: 2px;
|
||||
}
|
||||
.agent-workspace-details {
|
||||
flex-shrink: 0;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.agent-workspace-feature-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.agent-workspace-source {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.agent-workspace-diagnostics {
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.agent-workspace-chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
.agent-workspace-welcome {
|
||||
max-width: 660px;
|
||||
margin: 8px auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.agent-workspace-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.agent-workspace-presets button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 7px;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--primary) 4%, var(--card));
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.agent-workspace-presets button:hover {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--card));
|
||||
}
|
||||
.agent-workspace-presets button:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.agent-workspace-message {
|
||||
width: fit-content;
|
||||
max-width: min(850px, 95%);
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: var(--card);
|
||||
}
|
||||
.agent-workspace-message.is-user {
|
||||
margin-left: auto;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--card));
|
||||
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
|
||||
}
|
||||
.agent-workspace-speaker {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.agent-workspace-message-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.agent-workspace-result {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.agent-workspace-composer {
|
||||
padding: 8px 14px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.agent-workspace-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.agent-workspace-input:focus-within {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
.agent-workspace-input textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
max-height: 100px;
|
||||
resize: vertical;
|
||||
padding: 4px;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
}
|
||||
.agent-workspace-hint {
|
||||
margin-top: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@container (max-width: 600px) {
|
||||
.agent-workspace-presets {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.agent-workspace-chat {
|
||||
padding: 12px;
|
||||
}
|
||||
.agent-workspace-state {
|
||||
display: none;
|
||||
}
|
||||
.agent-workspace-header strong {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { VoiceStudioAgent } from './voice-studio-agent';
|
||||
import { agentConversation, receiveAgentEvent } from '@/lib/agent-conversation';
|
||||
const mocks = vi.hoisted(() => ({
|
||||
repair: {
|
||||
list: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
onEvent: vi.fn(() => () => {}),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(async () => ({})),
|
||||
chooseWorkspace: vi.fn(),
|
||||
},
|
||||
app: { platform: 'win32' },
|
||||
}));
|
||||
vi.mock('@/components/bridge', () => ({ getBridge: () => mocks }));
|
||||
vi.mock('@/hooks/use-backend-status', () => ({
|
||||
useBackendStatus: () => ({ stage: 'ready', logTail: [] }),
|
||||
}));
|
||||
vi.mock('@tanstack/react-router', () => ({ useRouterState: () => '/' }));
|
||||
vi.mock('./agent-dock-frame', () => ({
|
||||
AgentDockFrame: ({ children }: { children: ReactNode }) => <section>{children}</section>,
|
||||
}));
|
||||
vi.mock('@shared/utils/consoleBuffer', () => ({ getFrontendLogs: () => [] }));
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, args?: { feature?: string }) =>
|
||||
args?.feature ? `${key}: ${args.feature}` : key,
|
||||
}),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
agentConversation.setState(() => ({ messages: [], draft: '', activeId: '' }));
|
||||
mocks.repair.list.mockResolvedValue([
|
||||
{ id: 'codex', label: 'Codex', available: true },
|
||||
{ id: 'claude', label: 'Claude Code', available: false },
|
||||
]);
|
||||
mocks.repair.getState.mockResolvedValue({
|
||||
status: 'idle',
|
||||
output: '',
|
||||
workspaceAvailable: false,
|
||||
});
|
||||
mocks.repair.start.mockResolvedValue({ sessionId: 'one' });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
async function open() {
|
||||
render(<VoiceStudioAgent />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'repairAgent.title' }));
|
||||
await screen.findByRole('button', { name: 'Codex' });
|
||||
}
|
||||
it('runs a feature preset without a checkout and keeps the reply for follow-up chat', async () => {
|
||||
await open();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'audiobook.title' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'agentWorkspace.send' }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.repair.start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspace: 'app',
|
||||
features: ['audiobook'],
|
||||
mode: 'fix',
|
||||
history: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
act(() => {
|
||||
receiveAgentEvent({ type: 'output', sessionId: 'one', text: 'Created book-123' });
|
||||
receiveAgentEvent({ type: 'state', sessionId: 'one', status: 'complete' });
|
||||
});
|
||||
expect(screen.getByText('Created book-123')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'repairAgent.title' }));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Render that preview' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'agentWorkspace.send' }));
|
||||
await waitFor(() => expect(mocks.repair.start).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.repair.start.mock.calls[1][0].history).toContainEqual({
|
||||
role: 'assistant',
|
||||
content: 'Created book-123',
|
||||
});
|
||||
});
|
||||
it('offers read-only planning, disables unavailable harnesses, and stops a running request', async () => {
|
||||
await open();
|
||||
expect(screen.getByRole('button', { name: 'Claude Code' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'agentWorkspace.plan' }));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Check my models' } });
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
|
||||
await waitFor(() =>
|
||||
expect(mocks.repair.start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'diagnose', workspace: 'app' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.stop' }));
|
||||
expect(mocks.repair.stop).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'agentWorkspace.new_chat' })).toBeDisabled();
|
||||
});
|
||||
it('requires an attached checkout only for explicitly selected source repair', async () => {
|
||||
await open();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Fix the code' } });
|
||||
expect(screen.getByRole('button', { name: 'agentWorkspace.send' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'agentWorkspace.features' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'agentWorkspace.source' }));
|
||||
expect(screen.getByRole('button', { name: 'agentWorkspace.send' })).toBeDisabled();
|
||||
mocks.repair.chooseWorkspace.mockResolvedValue({
|
||||
workspaceAvailable: true,
|
||||
workspacePath: '/source',
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.models_dir_choose' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'agentWorkspace.send' })).toBeEnabled(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'agentWorkspace.send' }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.repair.start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace: 'source' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import { useStore } from '@tanstack/react-store';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
BotIcon,
|
||||
ChevronDownIcon,
|
||||
FolderOpenIcon,
|
||||
PlusIcon,
|
||||
SquareIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import codexIcon from '@lobehub/icons-static-svg/icons/codex-color.svg';
|
||||
import claudeIcon from '@lobehub/icons-static-svg/icons/claudecode-color.svg';
|
||||
import openCodeIcon from '@lobehub/icons-static-svg/icons/opencode.svg';
|
||||
import piIcon from '@lobehub/icons-static-svg/icons/pi.svg';
|
||||
import { getBridge } from '@/components/bridge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getFrontendLogs } from '@shared/utils/consoleBuffer';
|
||||
import { agentFeatures, type AgentFeature } from '@shared/agent-workspace';
|
||||
import { useBackendStatus } from '@/hooks/use-backend-status';
|
||||
import type { RepairAgentId, RepairAgentInfo } from '../../../../preload/index.d';
|
||||
import {
|
||||
DEFAULT_REPAIR_AGENT_KEY,
|
||||
REPAIR_AGENT_OPEN_EVENT,
|
||||
takePendingRepairRequest,
|
||||
type RepairAgentRequest,
|
||||
} from '@/lib/repair-agent-events';
|
||||
import {
|
||||
agentConversation,
|
||||
beginAgentTurn,
|
||||
conversationHistory,
|
||||
receiveAgentEvent,
|
||||
restoreAgentTurn,
|
||||
updateAgentTurn,
|
||||
} from '@/lib/agent-conversation';
|
||||
import { translationActivity } from '@/features/dub/translation-activity';
|
||||
import { TranslationAgentDock } from './translation-agent-dock';
|
||||
import { AgentDockFrame } from './agent-dock-frame';
|
||||
import './voice-studio-agent.css';
|
||||
|
||||
const icons = { codex: codexIcon, claude: claudeIcon, opencode: openCodeIcon, pi: piIcon };
|
||||
const labels: Record<AgentFeature, string> = {
|
||||
repair: 'agentWorkspace.repair',
|
||||
setup: 'agentWorkspace.setup',
|
||||
clone: 'nav.clone',
|
||||
design: 'designWorkspace.title',
|
||||
dub: 'dubWorkspace.title',
|
||||
transcribe: 'nav.transcribe',
|
||||
stories: 'nav.stories',
|
||||
audiobook: 'audiobook.title',
|
||||
workflows: 'workflows.title',
|
||||
tools: 'tools.title',
|
||||
};
|
||||
function savedAgent(): RepairAgentId {
|
||||
try {
|
||||
const id = localStorage.getItem(DEFAULT_REPAIR_AGENT_KEY);
|
||||
if (id && id in icons) return id as RepairAgentId;
|
||||
} catch {
|
||||
/* Session-only preference. */
|
||||
}
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
export function VoiceStudioAgent() {
|
||||
const { t } = useTranslation();
|
||||
const bridge = getBridge();
|
||||
const backend = useBackendStatus();
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const conversation = useStore(agentConversation);
|
||||
const translation = useStore(translationActivity);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [agents, setAgents] = useState<RepairAgentInfo[]>([]);
|
||||
const [selected, setSelected] = useState<RepairAgentId>(savedAgent);
|
||||
const [mode, setMode] = useState<'diagnose' | 'fix'>('fix');
|
||||
const [features, setFeatures] = useState<AgentFeature[]>([]);
|
||||
const [source, setSource] = useState(false);
|
||||
const [workspace, setWorkspace] = useState({ available: false, path: '' });
|
||||
const [details, setDetails] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [pending, setPending] = useState<RepairAgentRequest | null>(null);
|
||||
const log = useRef<HTMLDivElement>(null);
|
||||
const follow = useRef(true);
|
||||
const submitting = useRef(false);
|
||||
const active = conversation.messages.find((message) => message.id === conversation.activeId);
|
||||
const running = active?.status === 'running';
|
||||
const selectedAgent = agents.find((agent) => agent.id === selected);
|
||||
const canSend = Boolean(
|
||||
selectedAgent?.available &&
|
||||
conversation.draft.trim() &&
|
||||
!running &&
|
||||
(!source || workspace.available),
|
||||
);
|
||||
const setDraft = (draft: string) => agentConversation.setState((state) => ({ ...state, draft }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!bridge) return;
|
||||
let alive = true;
|
||||
const unsubscribe = bridge.repair.onEvent(receiveAgentEvent);
|
||||
void Promise.all([bridge.repair.list(), bridge.repair.getState()])
|
||||
.then(([found, state]) => {
|
||||
if (!alive) return;
|
||||
setAgents(found);
|
||||
setSelected((current) =>
|
||||
found.some((agent) => agent.id === current && agent.available)
|
||||
? current
|
||||
: (found.find((agent) => agent.available)?.id ?? current),
|
||||
);
|
||||
setWorkspace({ available: state.workspaceAvailable, path: state.workspacePath ?? '' });
|
||||
restoreAgentTurn(state);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (alive) setError(String(reason));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [bridge]);
|
||||
|
||||
useEffect(() => {
|
||||
const receive = (request?: RepairAgentRequest | null) => {
|
||||
setOpen(true);
|
||||
if (!request?.report) return;
|
||||
setFeatures(['repair']);
|
||||
setDraft(request.report);
|
||||
if (request.autoFix) setPending(request);
|
||||
};
|
||||
const listener = (event: Event) =>
|
||||
receive(takePendingRepairRequest() ?? (event as CustomEvent<RepairAgentRequest>).detail);
|
||||
window.addEventListener(REPAIR_AGENT_OPEN_EVENT, listener);
|
||||
const initial = takePendingRepairRequest();
|
||||
if (initial) receive(initial);
|
||||
return () => window.removeEventListener(REPAIR_AGENT_OPEN_EVENT, listener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending || !selectedAgent?.available || running || submitting.current) return;
|
||||
setPending(null);
|
||||
// Crash recovery reuses only a harness the user previously chose for a run.
|
||||
// Otherwise the prefilled request waits for Send.
|
||||
try {
|
||||
if (localStorage.getItem(DEFAULT_REPAIR_AGENT_KEY) !== selected) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void send(pending.report!, 'fix', false);
|
||||
}, [pending, selectedAgent, running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && follow.current && log.current) log.current.scrollTop = log.current.scrollHeight;
|
||||
}, [conversation.messages, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (details && log.current) {
|
||||
follow.current = false;
|
||||
log.current.scrollTop = 0;
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
async function send(text = conversation.draft, nextMode = mode, useSource = source) {
|
||||
if (!bridge || !selectedAgent?.available || submitting.current || running || !text.trim())
|
||||
return;
|
||||
submitting.current = true;
|
||||
setError('');
|
||||
const history = conversationHistory();
|
||||
const turn = beginAgentTurn(text.trim());
|
||||
follow.current = true;
|
||||
try {
|
||||
const result = await bridge.repair.start({
|
||||
agent: selected,
|
||||
mode: nextMode,
|
||||
workspace: useSource ? 'source' : 'app',
|
||||
features,
|
||||
report: text.trim(),
|
||||
history,
|
||||
context: JSON.stringify({ route: pathname, frontendLogs: getFrontendLogs().slice(-60) }),
|
||||
});
|
||||
updateAgentTurn(turn, { sessionId: result.sessionId });
|
||||
try {
|
||||
localStorage.setItem(DEFAULT_REPAIR_AGENT_KEY, selected);
|
||||
} catch {
|
||||
/* No persistence required. */
|
||||
}
|
||||
} catch (reason) {
|
||||
updateAgentTurn(turn, { status: 'failed' });
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
setDraft(text);
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
async function chooseWorkspace() {
|
||||
if (!bridge) return;
|
||||
try {
|
||||
const state = await bridge.repair.chooseWorkspace();
|
||||
setWorkspace({ available: state.workspaceAvailable, path: state.workspacePath ?? '' });
|
||||
} catch {
|
||||
setError(t('repairAgent.noSource'));
|
||||
}
|
||||
}
|
||||
async function stop() {
|
||||
try {
|
||||
await bridge?.repair.stop();
|
||||
} catch (reason) {
|
||||
setError(String(reason));
|
||||
}
|
||||
}
|
||||
if (!bridge) return null;
|
||||
if (!open && !running && translation.runs.length) return <TranslationAgentDock />;
|
||||
if (!open)
|
||||
return createPortal(
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
aria-label={t('repairAgent.title')}
|
||||
title={t('repairAgent.title')}
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed right-3 top-1/2 z-40 size-11 -translate-y-1/2 rounded-full border border-sidebar-border bg-sidebar text-sidebar-foreground shadow-lg"
|
||||
>
|
||||
<BotIcon />
|
||||
{running && <span className="absolute right-1 top-1 size-2 rounded-full bg-primary" />}
|
||||
</Button>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentDockFrame
|
||||
label={t('repairAgent.title')}
|
||||
className="agent-workspace"
|
||||
resizable
|
||||
resizeStorageKey="voicestudio.agent-workspace-height"
|
||||
>
|
||||
<header className="agent-workspace-header">
|
||||
<BotIcon className="size-5 text-primary" />
|
||||
<strong>{t('repairAgent.title')}</strong>
|
||||
<span className="agent-workspace-state" role="status">
|
||||
{running ? t('common.loading') : t('repairAgent.ready')}
|
||||
</span>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t('agentWorkspace.new_chat')}
|
||||
title={t('agentWorkspace.new_chat')}
|
||||
disabled={running}
|
||||
onClick={() => {
|
||||
agentConversation.setState(() => ({ messages: [], draft: '', activeId: '' }));
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="agent-workspace-options">
|
||||
<div
|
||||
className="agent-workspace-harnesses"
|
||||
role="group"
|
||||
aria-label={t('agentWorkspace.harness')}
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<Button
|
||||
key={agent.id}
|
||||
size="sm"
|
||||
variant={selected === agent.id ? 'secondary' : 'ghost'}
|
||||
disabled={running || !agent.available}
|
||||
aria-pressed={selected === agent.id}
|
||||
title={agent.available ? agent.version : t('repairAgent.notInstalled')}
|
||||
onClick={() => setSelected(agent.id)}
|
||||
>
|
||||
<img
|
||||
src={icons[agent.id]}
|
||||
alt=""
|
||||
className={cn('size-4', ['pi', 'opencode'].includes(agent.id) && 'dark:invert')}
|
||||
/>
|
||||
{agent.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="agent-workspace-modes" role="group" aria-label={t('agentWorkspace.mode')}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === 'diagnose' ? 'secondary' : 'ghost'}
|
||||
aria-pressed={mode === 'diagnose'}
|
||||
disabled={running}
|
||||
onClick={() => setMode('diagnose')}
|
||||
>
|
||||
{t('agentWorkspace.plan')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === 'fix' ? 'secondary' : 'ghost'}
|
||||
aria-pressed={mode === 'fix'}
|
||||
disabled={running}
|
||||
onClick={() => setMode('fix')}
|
||||
>
|
||||
{t('agentWorkspace.autopilot')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-expanded={details}
|
||||
onClick={() => setDetails((value) => !value)}
|
||||
>
|
||||
{t('agentWorkspace.features')}
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={log}
|
||||
className="agent-workspace-chat studio-scrollbar"
|
||||
role="log"
|
||||
aria-label={t('agentWorkspace.conversation')}
|
||||
aria-live="polite"
|
||||
onScroll={() => {
|
||||
const element = log.current;
|
||||
if (element)
|
||||
follow.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
||||
}}
|
||||
>
|
||||
{details && (
|
||||
<div className="agent-workspace-details studio-scrollbar">
|
||||
<div
|
||||
className="agent-workspace-feature-list"
|
||||
role="group"
|
||||
aria-label={t('agentWorkspace.features')}
|
||||
>
|
||||
{agentFeatures.map((feature) => (
|
||||
<Button
|
||||
key={feature}
|
||||
size="xs"
|
||||
variant={features.includes(feature) ? 'secondary' : 'outline'}
|
||||
aria-pressed={features.includes(feature)}
|
||||
disabled={running}
|
||||
onClick={() =>
|
||||
setFeatures((current) =>
|
||||
current.includes(feature)
|
||||
? current.filter((item) => item !== feature)
|
||||
: [...current, feature],
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(labels[feature])}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<label className="agent-workspace-source">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={source}
|
||||
disabled={running}
|
||||
onChange={(event) => setSource(event.target.checked)}
|
||||
/>
|
||||
<WrenchIcon className="size-3.5" />
|
||||
{t('agentWorkspace.source')}
|
||||
</label>
|
||||
{source && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={running}
|
||||
onClick={() => void chooseWorkspace()}
|
||||
>
|
||||
<FolderOpenIcon />
|
||||
{t('settings.models_dir_choose')}
|
||||
</Button>
|
||||
<span className="truncate" title={workspace.path}>
|
||||
{workspace.path || t('repairAgent.noSource')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary>{t('repairAgent.contextNotice')}</summary>
|
||||
<pre className="agent-workspace-diagnostics">
|
||||
{backend.logTail.slice(-40).join('\n')}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{conversation.messages.length === 0 ? (
|
||||
<div className="agent-workspace-welcome">
|
||||
<p>{t('agentWorkspace.welcome')}</p>
|
||||
<div className="agent-workspace-presets">
|
||||
{(['setup', 'repair', 'audiobook', 'clone'] as const).map((feature) => (
|
||||
<button
|
||||
key={feature}
|
||||
disabled={running}
|
||||
onClick={() => {
|
||||
setFeatures([feature]);
|
||||
setSource(false);
|
||||
setDraft(t('agentWorkspace.starter', { feature: t(labels[feature]) }));
|
||||
}}
|
||||
>
|
||||
<span>{t(labels[feature])}</span>
|
||||
<ArrowUpIcon className="size-3.5 rotate-45" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!agents.some((agent) => agent.available) && (
|
||||
<p className="text-muted-foreground">{t('agentWorkspace.no_harness')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
conversation.messages.map((message) => (
|
||||
<article
|
||||
key={message.id}
|
||||
className={cn('agent-workspace-message', message.role === 'user' && 'is-user')}
|
||||
>
|
||||
<span className="agent-workspace-speaker">
|
||||
{t(message.role === 'user' ? 'agentWorkspace.you' : 'repairAgent.title')}
|
||||
</span>
|
||||
<div className="agent-workspace-message-text">
|
||||
{message.content ||
|
||||
t(
|
||||
message.status === 'running'
|
||||
? 'common.loading'
|
||||
: message.status === 'failed'
|
||||
? 'common.error'
|
||||
: message.status === 'stopped'
|
||||
? 'common.stop'
|
||||
: 'repairAgent.complete',
|
||||
)}
|
||||
</div>
|
||||
{message.status && message.status !== 'running' && (
|
||||
<span className="agent-workspace-result">
|
||||
{t(
|
||||
message.status === 'complete'
|
||||
? 'repairAgent.complete'
|
||||
: message.status === 'stopped'
|
||||
? 'common.stop'
|
||||
: 'common.error',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className="agent-workspace-composer"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canSend) void send();
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="agent-workspace-input">
|
||||
<textarea
|
||||
aria-label={t('agentWorkspace.placeholder')}
|
||||
placeholder={t('agentWorkspace.placeholder')}
|
||||
value={conversation.draft}
|
||||
maxLength={12000}
|
||||
disabled={running}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
aria-label={t('common.stop')}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
<SquareIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!canSend}
|
||||
aria-label={t('agentWorkspace.send')}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="agent-workspace-hint">
|
||||
{t(mode === 'fix' ? 'agentWorkspace.autopilot_hint' : 'agentWorkspace.plan_hint')}
|
||||
</p>
|
||||
</form>
|
||||
</AgentDockFrame>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function WorkspaceSidebar() {
|
||||
ref={sidebarResize.host}
|
||||
style={{ width: sidebarResize.width }}
|
||||
aria-label={t('clone.saved_profiles')}
|
||||
className="brand-sidebar relative isolate grid h-full min-h-0 min-w-0 shrink-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_auto_minmax(0,1fr)_auto] overflow-hidden border-r border-border/50 bg-sidebar"
|
||||
className="brand-sidebar relative isolate grid h-full min-h-0 min-w-0 shrink-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_minmax(0,auto)_minmax(7rem,1fr)_auto] overflow-hidden border-r border-border/50 bg-sidebar"
|
||||
>
|
||||
<img
|
||||
src={brandArtwork}
|
||||
@@ -148,7 +148,7 @@ export function WorkspaceSidebar() {
|
||||
/>
|
||||
{/* Navigation first, like the compact rail: it stays at a fixed
|
||||
position under the header instead of riding the library's height. */}
|
||||
<div className="min-w-0 shrink-0 border-b border-border/50">
|
||||
<div className="min-h-0 min-w-0 overflow-y-auto border-b border-border/50">
|
||||
<WorkspaceNavigation />
|
||||
</div>
|
||||
<VoicesSidebar key={libraryTab} initialTab={libraryTab} />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { LiveDeviceUsage } from './live-device-usage';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ usage: vi.fn(), retry: vi.fn(), stage: 'ready' }));
|
||||
vi.mock('@/hooks/use-device-usage', () => ({ useDeviceUsage: mocks.usage }));
|
||||
vi.mock('@/hooks/use-backend-status', () => ({ useBackendStatus: () => ({ stage: mocks.stage }) }));
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mocks.stage = 'ready';
|
||||
});
|
||||
const data = { cpu: 24, gpu_utilization: 61, ram: 18.5, total_ram: 63, vram: 9.2, total_vram: 24 };
|
||||
|
||||
it('shows live load and used/capacity memory independently', () => {
|
||||
mocks.usage.mockReturnValue({ data, isError: false });
|
||||
render(<LiveDeviceUsage open />);
|
||||
expect(screen.getByText('24%')).toBeVisible();
|
||||
expect(screen.getByText('61%')).toBeVisible();
|
||||
expect(screen.getByText('18.5 / 63 GB')).toBeVisible();
|
||||
expect(screen.getByText('9.2 / 24 GB')).toBeVisible();
|
||||
expect(mocks.usage).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
it('keeps genuine zero utilization distinct from unsupported GPU readings', () => {
|
||||
mocks.usage.mockReturnValue({
|
||||
data: { ...data, cpu: 0, gpu_utilization: null, total_vram: 0 },
|
||||
isError: false,
|
||||
});
|
||||
render(<LiveDeviceUsage open />);
|
||||
expect(screen.getByText('0%')).toBeVisible();
|
||||
expect(screen.getAllByText('modelSettings.unavailable')).toHaveLength(2);
|
||||
});
|
||||
it('hides stale readings on errors and offers retry', () => {
|
||||
mocks.usage.mockReturnValue({ data, isError: true, refetch: mocks.retry });
|
||||
render(<LiveDeviceUsage open />);
|
||||
expect(screen.queryByText('24%')).toBeNull();
|
||||
expect(screen.getAllByText('modelSettings.unavailable')).toHaveLength(4);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
|
||||
expect(mocks.retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('identifies process-only VRAM when whole-device telemetry is unavailable', () => {
|
||||
mocks.usage.mockReturnValue({ data: { ...data, gpu_utilization: null }, isError: false });
|
||||
render(<LiveDeviceUsage open />);
|
||||
expect(screen.getByText('performanceHardware.appVram')).toBeVisible();
|
||||
expect(screen.getByText('9.2 / 24 GB')).toBeVisible();
|
||||
});
|
||||
it('does not present cached readings as live while the backend is offline', () => {
|
||||
mocks.stage = 'failed';
|
||||
mocks.usage.mockReturnValue({ data, isError: false });
|
||||
render(<LiveDeviceUsage open={false} />);
|
||||
expect(screen.queryByText('24%')).toBeNull();
|
||||
expect(screen.queryByText('performanceHardware.everyTwoSeconds')).toBeNull();
|
||||
expect(mocks.usage).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDeviceUsage } from '@/hooks/use-device-usage';
|
||||
import { useBackendStatus } from '@/hooks/use-backend-status';
|
||||
|
||||
function valid(value: number | null | undefined): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
export function LiveDeviceUsage({ open }: { open: boolean }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const query = useDeviceUsage(open);
|
||||
const backend = useBackendStatus();
|
||||
// Don't keep presenting an old successful sample as live after an error.
|
||||
const data = !query.isError && backend.stage === 'ready' ? query.data : undefined;
|
||||
const number = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 });
|
||||
const percent = new Intl.NumberFormat(i18n.language, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
const gb = new Intl.NumberFormat(i18n.language, {
|
||||
style: 'unit',
|
||||
unit: 'gigabyte',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const unavailable = t('modelSettings.unavailable');
|
||||
const usage = (value: number | null | undefined) =>
|
||||
valid(value) ? percent.format(Math.min(value, 100) / 100) : unavailable;
|
||||
const memory = (used: number | undefined, total: number | undefined) =>
|
||||
valid(used) && valid(total) && total > 0
|
||||
? `${number.format(used)} / ${gb.format(total)}`
|
||||
: unavailable;
|
||||
const ratio = (used: number | null | undefined, total = 100) =>
|
||||
valid(used) && valid(total) && total > 0 ? Math.min(100, (used / total) * 100) : null;
|
||||
const metrics = [
|
||||
{ label: t('settings.device_family_cpu'), value: usage(data?.cpu), fill: ratio(data?.cpu) },
|
||||
{
|
||||
label: t('settings.device_family_gpu'),
|
||||
value: usage(data?.gpu_utilization),
|
||||
fill: ratio(data?.gpu_utilization),
|
||||
},
|
||||
{
|
||||
label: t('about.ram'),
|
||||
value: memory(data?.ram, data?.total_ram),
|
||||
fill: data ? ratio(data.ram, data.total_ram) : null,
|
||||
},
|
||||
{
|
||||
// /sysinfo falls back to this process's torch allocation when device-wide
|
||||
// GPU telemetry is unavailable. Don't label that as whole-device usage.
|
||||
label: t(
|
||||
data && data.gpu_utilization == null && data.total_vram > 0
|
||||
? 'performanceHardware.appVram'
|
||||
: 'about.vram',
|
||||
),
|
||||
value: memory(data?.vram, data?.total_vram),
|
||||
fill: data ? ratio(data.vram, data.total_vram) : null,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<section
|
||||
aria-label={t('performanceHardware.liveUsage')}
|
||||
data-slot="live-device-usage"
|
||||
className="space-y-2 rounded-lg bg-muted/40 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="font-medium">{t('performanceHardware.liveUsage')}</span>
|
||||
{query.isError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void query.refetch()}
|
||||
className="rounded text-muted-foreground underline outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
backend.stage !== 'ready'
|
||||
? 'modelSettings.unavailable'
|
||||
: !data
|
||||
? 'preferences.loading'
|
||||
: 'performanceHardware.everyTwoSeconds',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{metrics.map(({ label, value, fill }) => (
|
||||
<div key={label} className="min-w-0 space-y-1">
|
||||
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
||||
<dd className="text-xs font-medium tabular-nums" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
<div className="h-0.5 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300 motion-reduce:transition-none"
|
||||
style={{ width: `${fill ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Slider } from '@base-ui/react/slider';
|
||||
import { DirectionProvider } from '@base-ui/react/direction-provider';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { CrownIcon, GaugeIcon, SparkleIcon, SparklesIcon } from 'lucide-react';
|
||||
import {
|
||||
CrownIcon,
|
||||
GaugeIcon,
|
||||
SparkleIcon,
|
||||
SparklesIcon,
|
||||
WandSparklesIcon,
|
||||
InfoIcon,
|
||||
ChevronRightIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useId, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { apiJson, describeError } from '@/lib/api/client';
|
||||
@@ -12,25 +20,27 @@ import { useDictationSelection } from '@/hooks/use-dictation-selection';
|
||||
import { engineFamilyState, useEngines } from '@/hooks/use-engines';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { useAppActivities } from '@/lib/app-activity';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover';
|
||||
import { LiveDeviceUsage } from '@/components/live-device-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
performanceTiers,
|
||||
usePerformanceProfile,
|
||||
type PerformanceFamily,
|
||||
type PerformanceTier,
|
||||
type PerformanceChoice,
|
||||
type PerformanceProfileState,
|
||||
} from '@/hooks/use-performance-profile';
|
||||
|
||||
export function PerformanceProfile({
|
||||
family = null,
|
||||
tooltipAnchor,
|
||||
variant = 'compact',
|
||||
onApplied,
|
||||
}: {
|
||||
family?: PerformanceFamily | null;
|
||||
tooltipAnchor?: RefObject<HTMLElement | null>;
|
||||
variant?: 'compact' | 'settings';
|
||||
onApplied?: (state: PerformanceProfileState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const profile = usePerformanceProfile();
|
||||
const activities = useAppActivities();
|
||||
const backend = useBackendStatus();
|
||||
@@ -43,9 +53,10 @@ export function PerformanceProfile({
|
||||
staleTime: 1_000,
|
||||
refetchInterval: (query) => (query.state.data?.length ? 1_000 : 15_000),
|
||||
});
|
||||
const [failed, setFailed] = useState<PerformanceTier | null>(null);
|
||||
const [failed, setFailed] = useState<PerformanceChoice | null>(null);
|
||||
const groupId = useId();
|
||||
const [draft, setDraft] = useState<number | null>(null);
|
||||
const [hardwareOpen, setHardwareOpen] = useState(false);
|
||||
const busy =
|
||||
profile.isSaving ||
|
||||
backend.stage !== 'ready' ||
|
||||
@@ -53,24 +64,22 @@ export function PerformanceProfile({
|
||||
batch.isError ||
|
||||
Boolean(batch.data?.length) ||
|
||||
Object.values(activities).some((count) => count > 0);
|
||||
const choices: readonly PerformanceChoice[] = family
|
||||
? performanceTiers
|
||||
: [...performanceTiers, 'auto'];
|
||||
const selected = family ? profile.data?.effective[family] : profile.data?.global;
|
||||
const applicable = profile.data?.applicable_families ?? profile.data?.implemented_families ?? [];
|
||||
const supported = family === null ? applicable.length > 0 : applicable.includes(family);
|
||||
const supported = family === null ? true : applicable.includes(family);
|
||||
const disabled = busy || !profile.data || !supported;
|
||||
const selectedIndex = selected ? performanceTiers.indexOf(selected) : -1;
|
||||
const selectedIndex = selected ? choices.indexOf(selected) : -1;
|
||||
const position = draft ?? Math.max(0, selectedIndex);
|
||||
const TierIcon = [GaugeIcon, SparkleIcon, SparklesIcon, CrownIcon][position];
|
||||
const tipState = !supported
|
||||
? 'modelSettings.unavailable'
|
||||
: busy
|
||||
? 'engineRuntime.working'
|
||||
: 'engineRuntime.ready';
|
||||
const TierIcon = [GaugeIcon, SparkleIcon, SparklesIcon, CrownIcon, WandSparklesIcon][position];
|
||||
const accent = [
|
||||
'var(--muted-foreground)',
|
||||
'color-mix(in oklab, var(--primary) 72%, var(--foreground))',
|
||||
'var(--primary)',
|
||||
'color-mix(in oklab, var(--primary) 82%, white)',
|
||||
][position];
|
||||
][Math.min(position, 3)];
|
||||
const glow = disabled
|
||||
? 'none'
|
||||
: [
|
||||
@@ -78,7 +87,7 @@ export function PerformanceProfile({
|
||||
'inset 0 1px 0 rgb(255 255 255 / 18%), 0 0 8px color-mix(in oklab, var(--primary) 16%, transparent)',
|
||||
'inset 0 1px 0 rgb(255 255 255 / 30%), 0 0 16px color-mix(in oklab, var(--primary) 34%, transparent)',
|
||||
'inset 0 1px 0 rgb(255 255 255 / 36%), 0 0 20px color-mix(in oklab, var(--primary) 46%, transparent)',
|
||||
][position];
|
||||
][Math.min(position, 3)];
|
||||
const thumbGlow = disabled
|
||||
? 'none'
|
||||
: [
|
||||
@@ -86,16 +95,17 @@ export function PerformanceProfile({
|
||||
'0 2px 6px rgb(0 0 0 / 22%), 0 0 7px color-mix(in oklab, var(--primary) 16%, transparent)',
|
||||
'0 3px 9px rgb(0 0 0 / 24%), 0 0 13px color-mix(in oklab, var(--primary) 34%, transparent)',
|
||||
'0 4px 11px rgb(0 0 0 / 26%), 0 0 17px color-mix(in oklab, var(--primary) 48%, transparent)',
|
||||
][position];
|
||||
const choose = async (tier: PerformanceTier) => {
|
||||
][Math.min(position, 3)];
|
||||
const choose = async (tier: PerformanceChoice) => {
|
||||
if (disabled) {
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
setDraft(performanceTiers.indexOf(tier));
|
||||
setDraft(choices.indexOf(tier));
|
||||
setFailed(null);
|
||||
try {
|
||||
await profile.setTier({ tier, family });
|
||||
const applied = await profile.setTier({ tier, family });
|
||||
onApplied?.(applied);
|
||||
toast.success(
|
||||
t('performanceProfile.applied', {
|
||||
tier: t('performanceProfile.' + tier),
|
||||
@@ -108,6 +118,13 @@ export function PerformanceProfile({
|
||||
setDraft(null);
|
||||
}
|
||||
};
|
||||
const plan = profile.data?.plan;
|
||||
const hardwareSummary = profile.isSaving
|
||||
? t('common.saving')
|
||||
: plan
|
||||
? (selected === 'auto' ? t('performanceProfile.' + plan.resolved) + ' · ' : '') +
|
||||
t('performanceHardware.' + (plan.status === 'limited' ? 'reason.memory' : plan.status))
|
||||
: '';
|
||||
const selection = family ? profile.data?.selections?.[family] : null;
|
||||
const target = family ? profile.data?.targets?.[family] : null;
|
||||
const legacyFamily =
|
||||
@@ -147,15 +164,15 @@ export function PerformanceProfile({
|
||||
aria-labelledby={groupId}
|
||||
aria-busy={profile.isSaving}
|
||||
className={cn(
|
||||
'grid min-w-0 grid-cols-2 gap-1 rounded-xl border border-border/60 bg-muted/45 p-1 sm:grid-cols-4',
|
||||
'grid min-w-0 grid-cols-2 gap-1 rounded-xl border border-border/60 bg-muted/45 p-1 sm:grid-flow-col sm:auto-cols-fr',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<span id={groupId} className="sr-only">
|
||||
{t(supported ? 'performanceProfile.title' : 'modelSettings.unavailable')}
|
||||
</span>
|
||||
{performanceTiers.map((tier, index) => {
|
||||
const Icon = [GaugeIcon, SparkleIcon, SparklesIcon, CrownIcon][index];
|
||||
{choices.map((tier, index) => {
|
||||
const Icon = [GaugeIcon, SparkleIcon, SparklesIcon, CrownIcon, WandSparklesIcon][index];
|
||||
const active = supported && position === index;
|
||||
return (
|
||||
<button
|
||||
@@ -229,68 +246,170 @@ export function PerformanceProfile({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const control = (
|
||||
<div className="min-w-0">
|
||||
return (
|
||||
<div className="min-w-0 py-1">
|
||||
<div className="mb-1 flex items-center justify-between gap-2 px-1 text-[11px] text-muted-foreground">
|
||||
<span>{t('performanceProfile.title')}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{t('performanceProfile.' + choices[position])}
|
||||
</span>
|
||||
</div>
|
||||
<span id={groupId} className="sr-only">
|
||||
{t(supported ? 'performanceProfile.title' : 'modelSettings.unavailable')}
|
||||
</span>
|
||||
<Slider.Root
|
||||
min={0}
|
||||
max={performanceTiers.length - 1}
|
||||
step={1}
|
||||
largeStep={1}
|
||||
value={position}
|
||||
disabled={disabled}
|
||||
aria-busy={profile.isSaving}
|
||||
onValueChange={(value) => setDraft(value)}
|
||||
onValueCommitted={(value) => void choose(performanceTiers[value])}
|
||||
className={`w-full min-w-0 ${disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Slider.Control className="relative flex h-9 w-full min-w-0 touch-none select-none items-center px-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-full border border-border/60 transition-[background-color] duration-200 motion-reduce:transition-none"
|
||||
style={{
|
||||
backgroundColor: disabled
|
||||
? 'var(--muted)'
|
||||
: `color-mix(in oklab, ${accent} ${[5, 9, 14, 20][position]}%, var(--muted))`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-1 left-0 rounded-full transition-[background,box-shadow,opacity] duration-200 motion-reduce:transition-none"
|
||||
style={{
|
||||
width: `calc(1rem + (100% - 2rem) * ${position / (performanceTiers.length - 1)})`,
|
||||
background: `linear-gradient(110deg, color-mix(in oklab, ${accent} 65%, var(--muted)), ${accent} 75%, color-mix(in oklab, ${accent} ${position >= 2 ? 82 : 95}%, white))`,
|
||||
boxShadow: glow,
|
||||
}}
|
||||
/>
|
||||
<Slider.Track className="relative h-7 w-full min-w-0">
|
||||
<div
|
||||
<DirectionProvider direction={i18n.dir(i18n.language)}>
|
||||
<Slider.Root
|
||||
min={0}
|
||||
max={choices.length - 1}
|
||||
step={1}
|
||||
largeStep={1}
|
||||
value={position}
|
||||
disabled={disabled}
|
||||
aria-busy={profile.isSaving}
|
||||
onValueChange={(value) => setDraft(value)}
|
||||
onValueCommitted={(value) => void choose(choices[value])}
|
||||
className={`w-full min-w-0 ${disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Slider.Control className="relative flex h-9 w-full min-w-0 touch-none select-none items-center px-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-between"
|
||||
>
|
||||
{performanceTiers.map((tier, index) => (
|
||||
<span
|
||||
key={tier}
|
||||
title={t('performanceProfile.' + tier)}
|
||||
className={`size-1 shrink-0 rounded-full ${index <= position ? 'bg-primary-foreground/50' : 'bg-muted-foreground/60'}`}
|
||||
/>
|
||||
))}
|
||||
className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-full border border-border/60 transition-[background-color] duration-200 motion-reduce:transition-none"
|
||||
style={{
|
||||
backgroundColor: disabled
|
||||
? 'var(--muted)'
|
||||
: `color-mix(in oklab, ${accent} ${[5, 9, 14, 20][Math.min(position, 3)]}%, var(--muted))`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-1 start-0 rounded-full transition-[background,box-shadow,opacity] duration-200 motion-reduce:transition-none"
|
||||
style={{
|
||||
width: `calc(1rem + (100% - 2rem) * ${position / (choices.length - 1)})`,
|
||||
background: `linear-gradient(110deg, color-mix(in oklab, ${accent} 65%, var(--muted)), ${accent} 75%, color-mix(in oklab, ${accent} ${position >= 2 ? 82 : 95}%, white))`,
|
||||
boxShadow: glow,
|
||||
}}
|
||||
/>
|
||||
<Slider.Track className="relative h-7 w-full min-w-0">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-between"
|
||||
>
|
||||
{choices.map((tier, index) => (
|
||||
<span
|
||||
key={tier}
|
||||
title={t('performanceProfile.' + tier)}
|
||||
className={`size-1 shrink-0 rounded-full ${index <= position ? 'bg-primary-foreground/50' : 'bg-muted-foreground/60'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Slider.Thumb
|
||||
aria-labelledby={groupId}
|
||||
aria-describedby={groupId + '-help'}
|
||||
getAriaValueText={(_formatted, value) => t('performanceProfile.' + choices[value])}
|
||||
className="absolute top-1/2 z-10 grid size-7 place-items-center rounded-full border border-background/40 bg-foreground text-background outline-none ring-ring/40 transition-[box-shadow,transform] hover:scale-105 focus-within:ring-4 data-dragging:scale-105 motion-reduce:transition-none"
|
||||
style={{ boxShadow: thumbGlow }}
|
||||
>
|
||||
<TierIcon className="size-3.5" aria-hidden="true" />
|
||||
</Slider.Thumb>
|
||||
</Slider.Track>
|
||||
</Slider.Control>
|
||||
</Slider.Root>
|
||||
</DirectionProvider>
|
||||
{plan && !family && (
|
||||
<Popover open={hardwareOpen} onOpenChange={setHardwareOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={hardwareSummary}
|
||||
className="mt-0.5 flex min-h-7 w-full items-center gap-1.5 rounded px-1 text-start text-[11px] text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 flex-1" role="status">
|
||||
{hardwareSummary}
|
||||
</span>
|
||||
<InfoIcon className="size-3 shrink-0" aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
className="max-h-[min(42rem,80dvh)] w-80 max-w-[calc(100vw-1rem)] space-y-3 overflow-y-auto p-4 text-xs"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('performanceProfile.max') +
|
||||
' · ' +
|
||||
t(
|
||||
'performanceHardware.' +
|
||||
(plan.max_status === 'limited' ? 'reason.memory' : plan.max_status),
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{t('performanceHardware.voiceFirst')}</p>
|
||||
</div>
|
||||
<Slider.Thumb
|
||||
aria-labelledby={groupId}
|
||||
getAriaValueText={(_formatted, value) =>
|
||||
t('performanceProfile.' + performanceTiers[value])
|
||||
}
|
||||
className="absolute top-1/2 z-10 grid size-7 place-items-center rounded-full border border-background/40 bg-foreground text-background outline-none ring-ring/40 transition-[box-shadow,transform] hover:scale-105 focus-within:ring-4 data-dragging:scale-105 motion-reduce:transition-none"
|
||||
style={{ boxShadow: thumbGlow }}
|
||||
>
|
||||
<TierIcon className="size-3.5" aria-hidden="true" />
|
||||
</Slider.Thumb>
|
||||
</Slider.Track>
|
||||
</Slider.Control>
|
||||
</Slider.Root>
|
||||
<LiveDeviceUsage open={hardwareOpen} />
|
||||
<div className="-mx-1">
|
||||
{(['tts', 'asr', 'translation', 'dictation', 'diarisation'] as const).map((name) => {
|
||||
const entry = plan.families[name];
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
to="/settings/models/$family"
|
||||
params={{ family: name }}
|
||||
className="group flex min-h-7 items-center justify-between gap-3 rounded-md px-1 py-1 outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span>{t('sidebarTools.' + name)}</span>
|
||||
<span className="flex items-center gap-1.5 text-end text-[11px] text-muted-foreground">
|
||||
{t('performanceHardware.reason.' + entry.reason)}
|
||||
<ChevronRightIcon
|
||||
className="size-3 shrink-0 opacity-50 group-hover:opacity-100 rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<details className="group/details border-t pt-1 text-muted-foreground">
|
||||
<summary className="flex min-h-8 cursor-pointer list-none items-center justify-between gap-2 rounded outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
|
||||
{t('sidebarTools.details')}
|
||||
<ChevronRightIcon
|
||||
className="size-3.5 shrink-0 group-open/details:rotate-90 rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
<div className="space-y-2 pt-1 leading-relaxed">
|
||||
<p className="text-foreground/80 tabular-nums">
|
||||
{t('performanceHardware.specs', {
|
||||
ram:
|
||||
plan.hardware.ram_gb?.toLocaleString(i18n.language, {
|
||||
maximumFractionDigits: 0,
|
||||
}) ?? '—',
|
||||
cores: plan.hardware.cpu_threads,
|
||||
})}
|
||||
{plan.hardware.vram_gb != null &&
|
||||
' · ' +
|
||||
t('performanceHardware.vram', {
|
||||
memory: plan.hardware.vram_gb.toLocaleString(i18n.language, {
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
<p>{t('performanceHardware.priority')}</p>
|
||||
<p>{t('performanceHardware.estimate')}</p>
|
||||
</div>
|
||||
</details>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<p id={groupId + '-help'} className="sr-only">
|
||||
{t(
|
||||
!supported
|
||||
? 'performanceProfile.noTarget'
|
||||
: busy
|
||||
? 'sidebarTools.wait'
|
||||
: 'sidebarTools.speedHelp',
|
||||
)}
|
||||
</p>
|
||||
{(failed || profile.isError || batch.isError) && (
|
||||
<div role="alert" className="text-xs text-destructive">
|
||||
{t('common.error')}
|
||||
@@ -310,60 +429,4 @@ export function PerformanceProfile({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="min-w-0" />}>{control}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
surface="theme"
|
||||
anchor={tooltipAnchor}
|
||||
showArrow={!tooltipAnchor}
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
'flex-col items-start',
|
||||
tooltipAnchor ? 'w-[var(--anchor-width)] max-w-none' : 'w-64',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full min-w-0 max-w-[calc(100vw-2rem)] flex-col gap-2 py-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex min-w-0 items-center gap-2 font-semibold">
|
||||
<TierIcon className="size-4 shrink-0" style={{ color: accent }} aria-hidden="true" />
|
||||
<span className="truncate">{t('performanceProfile.title')}</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
!supported
|
||||
? 'bg-destructive'
|
||||
: busy
|
||||
? 'animate-pulse bg-amber-400 motion-reduce:animate-none'
|
||||
: 'bg-emerald-400',
|
||||
)}
|
||||
/>
|
||||
{t(tipState)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 border-t border-border/60 pt-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">
|
||||
{t('modelSettings.selected')}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: accent }}>
|
||||
{t('performanceProfile.' + performanceTiers[position])}
|
||||
</p>
|
||||
</div>
|
||||
<p className="border-t border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('performanceProfile.hint')}
|
||||
</p>
|
||||
{position === performanceTiers.length - 1 && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t('performanceProfile.maxHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,16 @@ it('requires passing preflight and installed models before completion', async ()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'setup.continue_ok' }));
|
||||
await screen.findByText('setup.ready_desc');
|
||||
expect(screen.getByRole('button', { name: 'dub.advanced' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /demo.dictation_title/ })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByText('Shortcuts')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dub.advanced' }));
|
||||
expect(screen.queryByText('Shortcuts')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /demo.dictation_title/ }));
|
||||
expect(screen.getByText('Shortcuts')).toBeInTheDocument();
|
||||
|
||||
@@ -46,8 +46,10 @@ export function SetupGate({ children }: { children: ReactNode }) {
|
||||
const [needed, setNeeded] = useState<boolean | null>(null);
|
||||
const [setupInProgress, setSetupInProgress] = useState(setupWasStarted);
|
||||
const [step, setStep] = useState(0);
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [dictationSetup, setDictationSetup] = useState(false);
|
||||
const [setupAdvanced, setSetupAdvanced] = useState(false);
|
||||
const [dictationSetup, setDictationSetup] = useState(true);
|
||||
const advanced = step === 3 ? dictationSetup : setupAdvanced;
|
||||
const setAdvanced = step === 3 ? setDictationSetup : setSetupAdvanced;
|
||||
const [family, setFamily] = useState<ModelFamily>('tts');
|
||||
const [consentRequired, setConsentRequired] = useState(true);
|
||||
const [enteringStudio, setEnteringStudio] = useState(false);
|
||||
@@ -216,7 +218,7 @@ export function SetupGate({ children }: { children: ReactNode }) {
|
||||
>
|
||||
{t('demo.dictation_title')} · {t('firstrun.chip_optional')}
|
||||
</Button>
|
||||
{(dictationSetup || advanced) && (
|
||||
{dictationSetup && (
|
||||
<>
|
||||
<PermissionsSettings />
|
||||
<ShortcutSettings />
|
||||
|
||||
@@ -7,8 +7,9 @@ function Slider({
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
thumbProps,
|
||||
...props
|
||||
}: SliderPrimitive.Root.Props) {
|
||||
}: SliderPrimitive.Root.Props & { thumbProps?: SliderPrimitive.Thumb.Props }) {
|
||||
const _values = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
@@ -38,6 +39,7 @@ function Slider({
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
{...thumbProps}
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="relative block size-3 shrink-0 rounded-md border border-ring bg-white ring-ring/30 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden active:ring-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { SiteBrowserHost } from './site-browser-host';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: vi.fn(async () => null),
|
||||
onState: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/components/bridge', () => ({ getBridge: () => ({ browser: mocks }) }));
|
||||
vi.mock('./site-browser-page', () => ({
|
||||
SiteBrowserPage: () => <div data-testid="embedded-browser" />,
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
|
||||
it('preserves the workspace DOM, draft and keyboard focus when browsing and returning', async () => {
|
||||
let update: (value: unknown) => void = () => {};
|
||||
mocks.onState.mockImplementation((callback) => {
|
||||
update = callback;
|
||||
return mocks.unsubscribe;
|
||||
});
|
||||
const { unmount } = render(
|
||||
<SiteBrowserHost>
|
||||
<input aria-label="Draft" />
|
||||
</SiteBrowserHost>,
|
||||
);
|
||||
const draft = screen.getByRole('textbox', { name: 'Draft' });
|
||||
fireEvent.change(draft, { target: { value: 'My unsaved voice script' } });
|
||||
draft.focus();
|
||||
await act(async () => update({ url: 'https://voicestudio.sh' }));
|
||||
expect(screen.getByTestId('embedded-browser')).toBeVisible();
|
||||
expect(draft.parentElement).toHaveAttribute('inert');
|
||||
act(() => update(null));
|
||||
await waitFor(() => expect(draft).toHaveFocus());
|
||||
expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(draft);
|
||||
expect(draft).toHaveValue('My unsaved voice script');
|
||||
expect(draft.parentElement).not.toHaveAttribute('inert');
|
||||
expect(screen.queryByTestId('embedded-browser')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
expect(mocks.unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { getBridge } from '@/components/bridge';
|
||||
import { SiteBrowserPage } from './site-browser-page';
|
||||
|
||||
/** Preserve the workspace and its drafts while browsing inside the same window. */
|
||||
export function SiteBrowserHost({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const returnFocus = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const browser = getBridge()?.browser;
|
||||
if (!browser) return;
|
||||
let active = true;
|
||||
let received = false;
|
||||
const update = (isOpen: boolean) => {
|
||||
if (!active) return;
|
||||
if (isOpen && !returnFocus.current)
|
||||
returnFocus.current = document.activeElement as HTMLElement;
|
||||
setOpen(isOpen);
|
||||
};
|
||||
const unsubscribe = browser.onState((state) => {
|
||||
received = true;
|
||||
update(Boolean(state));
|
||||
});
|
||||
void browser
|
||||
.state()
|
||||
.then((state) => {
|
||||
if (!received) update(Boolean(state));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!open && returnFocus.current) {
|
||||
returnFocus.current.focus();
|
||||
returnFocus.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-slot="studio-workspace"
|
||||
className="h-full"
|
||||
inert={open}
|
||||
aria-hidden={open || undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{open && <SiteBrowserPage anchor={returnFocus.current} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
.site-browser-page {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 70px rgb(0 0 0 / 35%);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.site-browser-page::backdrop {
|
||||
background: rgb(0 0 0 / 16%);
|
||||
}
|
||||
.site-browser-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 1px 12px;
|
||||
}
|
||||
.site-browser-toolbar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
max-height: 65%;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.site-browser-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
.site-browser-title > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.site-browser-title > button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.site-browser-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.site-browser-navigation form {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.site-browser-navigation input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.site-browser-navigation input:focus-visible {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent);
|
||||
}
|
||||
.site-browser-external {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 130px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0 2px;
|
||||
}
|
||||
.site-browser-error {
|
||||
font-size: 12px;
|
||||
margin-top: 9px;
|
||||
color: var(--destructive);
|
||||
}
|
||||
.site-browser-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
.site-browser-progress[data-loading='true'] {
|
||||
background: linear-gradient(90deg, transparent, var(--primary), transparent);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { SiteBrowserState } from '@shared/site-browser';
|
||||
import { SiteBrowserPage } from './site-browser-page';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
browser: {
|
||||
close: vi.fn(async () => {}),
|
||||
state: vi.fn(),
|
||||
navigate: vi.fn(async () => {}),
|
||||
command: vi.fn(async () => {}),
|
||||
installed: vi.fn(),
|
||||
openExternal: vi.fn(async () => {}),
|
||||
bounds: vi.fn(async () => {}),
|
||||
onState: vi.fn(),
|
||||
},
|
||||
unsubscribe: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/components/bridge', () => ({
|
||||
getBridge: () => ({ browser: mocks.browser }),
|
||||
isMac: () => false,
|
||||
}));
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
let onState: (state: SiteBrowserState) => void;
|
||||
const ready = {
|
||||
url: 'https://voicestudio.sh/',
|
||||
title: 'VoiceStudio',
|
||||
loading: false,
|
||||
error: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
|
||||
configurable: true,
|
||||
value: function (this: HTMLDialogElement) {
|
||||
this.setAttribute('open', '');
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
mocks.browser.state.mockResolvedValue(ready);
|
||||
mocks.browser.installed.mockResolvedValue([{ id: 'firefox', name: 'Firefox' }]);
|
||||
mocks.browser.onState.mockImplementation((callback) => {
|
||||
onState = callback;
|
||||
return mocks.unsubscribe;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('synchronizes the address and history, navigates typed sites, and stops or reloads', async () => {
|
||||
const { unmount } = render(<SiteBrowserPage />);
|
||||
const address = await screen.findByDisplayValue(ready.url);
|
||||
expect(screen.getByRole('button', { name: 'siteBrowser.back' })).toBeDisabled();
|
||||
fireEvent.change(address, { target: { value: 'example.com/docs' } });
|
||||
fireEvent.submit(address.closest('form')!);
|
||||
await waitFor(() =>
|
||||
expect(mocks.browser.navigate).toHaveBeenCalledWith('https://example.com/docs'),
|
||||
);
|
||||
act(() => onState({ ...ready, url: 'https://example.com/docs', canGoBack: true, loading: true }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'siteBrowser.back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'siteBrowser.stop' }));
|
||||
expect(mocks.browser.command).toHaveBeenCalledWith('back');
|
||||
expect(mocks.browser.command).toHaveBeenCalledWith('stop');
|
||||
act(() => onState(ready));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'siteBrowser.reload' }));
|
||||
expect(mocks.browser.command).toHaveBeenCalledWith('reload');
|
||||
unmount();
|
||||
expect(mocks.unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists detected browsers and a system-default option with a working launch action', async () => {
|
||||
render(<SiteBrowserPage />);
|
||||
await screen.findByDisplayValue(ready.url);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'siteBrowser.open_in' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Firefox' }));
|
||||
expect(mocks.browser.openExternal).toHaveBeenCalledWith('firefox');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'siteBrowser.default_browser' }));
|
||||
expect(mocks.browser.openExternal).toHaveBeenCalledWith('default');
|
||||
});
|
||||
|
||||
it('keeps controls available after navigation failure and rejects executable URLs', async () => {
|
||||
render(<SiteBrowserPage />);
|
||||
const address = await screen.findByDisplayValue(ready.url);
|
||||
fireEvent.change(address, { target: { value: 'javascript:alert(1)' } });
|
||||
fireEvent.submit(address.closest('form')!);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('siteBrowser.failed');
|
||||
expect(mocks.browser.navigate).not.toHaveBeenCalled();
|
||||
act(() => onState({ ...ready, error: true }));
|
||||
expect(screen.getByRole('button', { name: 'siteBrowser.reload' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'siteBrowser.open_in' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('returns to Studio through the embedded view close action', async () => {
|
||||
render(<SiteBrowserPage />);
|
||||
await screen.findByDisplayValue(ready.url);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'siteBrowser.return_studio' }));
|
||||
expect(mocks.browser.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('anchors the modal below the clicked item inside the right workspace and reports its body bounds', async () => {
|
||||
const workspace = document.createElement('main');
|
||||
workspace.dataset.slot = 'workspace-content';
|
||||
const anchor = document.createElement('button');
|
||||
workspace.append(anchor);
|
||||
document.body.append(workspace);
|
||||
vi.spyOn(workspace, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 250,
|
||||
top: 0,
|
||||
right: 1024,
|
||||
bottom: 768,
|
||||
} as DOMRect);
|
||||
vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({ left: 280, bottom: 140 } as DOMRect);
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
if (this.className === 'site-browser-viewport')
|
||||
return { x: 268, y: 250, width: 738, height: 490 } as DOMRect;
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
try {
|
||||
render(<SiteBrowserPage anchor={anchor} />);
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal.style.top).toBe('152px');
|
||||
expect(modal.style.left).toBe('266px');
|
||||
expect(modal.style.width).toBe('742px');
|
||||
await waitFor(() =>
|
||||
expect(mocks.browser.bounds).toHaveBeenCalledWith({
|
||||
x: 268,
|
||||
y: 250,
|
||||
width: 738,
|
||||
height: 490,
|
||||
}),
|
||||
);
|
||||
fireEvent(modal, new Event('cancel', { cancelable: true }));
|
||||
expect(mocks.browser.close).toHaveBeenCalled();
|
||||
} finally {
|
||||
workspace.remove();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
RotateCwIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getBridge } from '@/components/bridge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { browserUrl, type InstalledBrowser, type SiteBrowserState } from '@shared/site-browser';
|
||||
import './site-browser-page.css';
|
||||
|
||||
const initialState: SiteBrowserState = {
|
||||
url: '',
|
||||
title: '',
|
||||
loading: true,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export function SiteBrowserPage({ anchor = null }: { anchor?: HTMLElement | null }) {
|
||||
const { t } = useTranslation();
|
||||
const browser = getBridge()?.browser;
|
||||
const toolbar = useRef<HTMLElement>(null);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const [page, setPage] = useState(initialState);
|
||||
const [address, setAddress] = useState('');
|
||||
const [browsers, setBrowsers] = useState<InstalledBrowser[]>([]);
|
||||
const [showBrowsers, setShowBrowsers] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setError(false);
|
||||
try {
|
||||
await action();
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the native web view inside the measured modal body, including when
|
||||
// controls expand, the window is resized, or the renderer zoom changes.
|
||||
useLayoutEffect(() => {
|
||||
if (!browser || !dialog.current || !viewport.current) return;
|
||||
const modal = dialog.current;
|
||||
modal.showModal?.();
|
||||
const workspace =
|
||||
anchor?.closest('[data-slot="workspace-content"]') ??
|
||||
document.querySelector('[data-slot="workspace-content"]');
|
||||
let active = true;
|
||||
const measure = () => {
|
||||
const area = workspace?.getBoundingClientRect() ?? {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: window.innerWidth,
|
||||
bottom: window.innerHeight,
|
||||
};
|
||||
const left = Math.max(0, area.left) + 16;
|
||||
const right = Math.min(window.innerWidth, area.right) - 16;
|
||||
const bottom = Math.min(window.innerHeight, area.bottom) - 16;
|
||||
const width = Math.max(0, Math.min(900, right - left));
|
||||
const target = anchor?.isConnected ? anchor.getBoundingClientRect() : null;
|
||||
const x = Math.max(left, Math.min(target?.left ?? left, right - width));
|
||||
const y = Math.max(
|
||||
area.top + 12,
|
||||
Math.min(target ? target.bottom + 12 : area.top + 64, bottom - 280),
|
||||
);
|
||||
Object.assign(modal.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${width}px`,
|
||||
height: `${Math.max(0, Math.min(640, bottom - y))}px`,
|
||||
});
|
||||
const rect = viewport.current!.getBoundingClientRect();
|
||||
void browser
|
||||
.bounds({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })
|
||||
.catch(() => {
|
||||
if (active) setError(true);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(viewport.current);
|
||||
if (workspace) observer.observe(workspace);
|
||||
if (anchor) observer.observe(anchor);
|
||||
window.addEventListener('resize', measure);
|
||||
window.addEventListener('scroll', measure, true);
|
||||
measure();
|
||||
return () => {
|
||||
active = false;
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
window.removeEventListener('scroll', measure, true);
|
||||
};
|
||||
}, [browser, anchor, showBrowsers, browsers.length, error, page.error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!browser) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let received = false;
|
||||
const update = (state: SiteBrowserState | null) => {
|
||||
if (active && state) setPage(state);
|
||||
};
|
||||
const unsubscribe = browser.onState((state) => {
|
||||
received = true;
|
||||
update(state);
|
||||
});
|
||||
void browser
|
||||
.state()
|
||||
.then((state) => {
|
||||
if (!received) update(state);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(true);
|
||||
});
|
||||
void browser
|
||||
.installed()
|
||||
.then((list) => {
|
||||
if (active) setBrowsers(list);
|
||||
})
|
||||
.catch(() => {
|
||||
/* System default remains available. */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [browser]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddress(page.url);
|
||||
}, [page.url]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="site-browser-page"
|
||||
aria-label={t('siteBrowser.preview')}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
void run(() => browser!.close());
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right ||
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom
|
||||
)
|
||||
void run(() => browser!.close());
|
||||
}}
|
||||
>
|
||||
<header ref={toolbar} className="site-browser-toolbar">
|
||||
<div className="site-browser-title">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
autoFocus
|
||||
onClick={() => void run(() => browser!.close())}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
{t('siteBrowser.return_studio')}
|
||||
</Button>
|
||||
<GlobeIcon size={15} aria-hidden="true" />
|
||||
<span>{page.title || t('app.name')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-expanded={showBrowsers}
|
||||
aria-controls="external-browsers"
|
||||
onClick={() => setShowBrowsers((value) => !value)}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden="true" />
|
||||
{t('siteBrowser.open_in')}
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="site-browser-navigation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t('siteBrowser.back')}
|
||||
title={t('siteBrowser.back')}
|
||||
disabled={!page.canGoBack}
|
||||
onClick={() => void run(() => browser!.command('back'))}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t('siteBrowser.forward')}
|
||||
title={t('siteBrowser.forward')}
|
||||
disabled={!page.canGoForward}
|
||||
onClick={() => void run(() => browser!.command('forward'))}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t(page.loading ? 'siteBrowser.stop' : 'siteBrowser.reload')}
|
||||
title={t(page.loading ? 'siteBrowser.stop' : 'siteBrowser.reload')}
|
||||
onClick={() => void run(() => browser!.command(page.loading ? 'stop' : 'reload'))}
|
||||
>
|
||||
{page.loading ? <XIcon /> : <RotateCwIcon />}
|
||||
</Button>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void run(() => browser!.navigate(browserUrl(address)));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t('siteBrowser.address')}
|
||||
value={address}
|
||||
onChange={(event) => setAddress(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
dir="ltr"
|
||||
type="text"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
{showBrowsers && (
|
||||
<div
|
||||
id="external-browsers"
|
||||
className="site-browser-external"
|
||||
aria-label={t('siteBrowser.open_in')}
|
||||
>
|
||||
{[{ id: 'default', name: t('siteBrowser.default_browser') }, ...browsers].map(
|
||||
(item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void run(() => browser!.openExternal(item.id))}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden="true" />
|
||||
{item.name}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(page.error || error) && (
|
||||
<p role="alert" className="site-browser-error">
|
||||
{t('siteBrowser.failed')}
|
||||
</p>
|
||||
)}
|
||||
<div className="site-browser-progress" data-loading={page.loading} aria-hidden="true" />
|
||||
</header>
|
||||
<div ref={viewport} className="site-browser-viewport" />
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import '@/i18n';
|
||||
import { ActionBar } from './action-bar';
|
||||
import { ActionBar, ProductionSettings } from './action-bar';
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
|
||||
@@ -112,7 +112,7 @@ describe('ActionBar', () => {
|
||||
const view = render(<ActionBar />);
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('LibsndfileError');
|
||||
expect(view.container.querySelector('details')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('alert').querySelector('details')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('pre')).not.toBeInTheDocument();
|
||||
});
|
||||
it('keeps synthesis available while the latest take is playing', () => {
|
||||
@@ -123,16 +123,15 @@ describe('ActionBar', () => {
|
||||
expect(generate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('button', { name: 'Stop playback' })).not.toBeInTheDocument();
|
||||
});
|
||||
it('renders the production overrides with their current values', () => {
|
||||
render(<ActionBar />);
|
||||
// Base UI exposes the slider root as a labelled group (the thumb input is
|
||||
// only materialised with layout, which jsdom lacks).
|
||||
expect(screen.getByRole('group', { name: 'Steps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('group', { name: 'CFG' })).toBeInTheDocument();
|
||||
expect(screen.getByText('32')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Postprocess' })).toBeChecked();
|
||||
it('keeps everyday controls clear and advanced tuning collapsed', () => {
|
||||
const { container } = render(<ProductionSettings />);
|
||||
expect(screen.getByRole('switch', { name: 'Polish generated audio' })).toBeChecked();
|
||||
expect(screen.getByText('1.0×')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/1× is normal speed/)).toBeVisible();
|
||||
expect(container.querySelector('details')).not.toHaveAttribute('open');
|
||||
fireEvent.click(screen.getByText('Advanced model tuning'));
|
||||
expect(screen.getByText('Prompt guidance')).toBeVisible();
|
||||
expect(screen.getByText('Sound variation')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to defaults' }));
|
||||
expect(resetOverrides).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ const SLIDERS: SliderSpec[] = [
|
||||
},
|
||||
{
|
||||
key: 'cfg',
|
||||
labelKey: 'clone.cfg',
|
||||
labelKey: 'voiceControls.guidance',
|
||||
icon: FocusIcon,
|
||||
min: 1,
|
||||
max: 4,
|
||||
@@ -84,7 +84,7 @@ const SLIDERS: SliderSpec[] = [
|
||||
},
|
||||
{
|
||||
key: 'tShift',
|
||||
labelKey: 'clone.tshift',
|
||||
labelKey: 'voiceControls.timing',
|
||||
icon: TimerIcon,
|
||||
min: 0,
|
||||
max: 1,
|
||||
@@ -92,7 +92,7 @@ const SLIDERS: SliderSpec[] = [
|
||||
},
|
||||
{
|
||||
key: 'posTemp',
|
||||
labelKey: 'clone.pos_temp',
|
||||
labelKey: 'voiceControls.order',
|
||||
icon: ThermometerIcon,
|
||||
min: 0,
|
||||
max: 10,
|
||||
@@ -100,7 +100,7 @@ const SLIDERS: SliderSpec[] = [
|
||||
},
|
||||
{
|
||||
key: 'classTemp',
|
||||
labelKey: 'clone.class_temp',
|
||||
labelKey: 'voiceControls.variation',
|
||||
icon: ShuffleIcon,
|
||||
min: 0,
|
||||
max: 2,
|
||||
@@ -108,7 +108,7 @@ const SLIDERS: SliderSpec[] = [
|
||||
},
|
||||
{
|
||||
key: 'layerPenalty',
|
||||
labelKey: 'clone.layer_pen',
|
||||
labelKey: 'voiceControls.balance',
|
||||
icon: LayersIcon,
|
||||
min: 0,
|
||||
max: 10,
|
||||
@@ -132,9 +132,8 @@ function SliderRow({ spec, value }: SliderRowProps) {
|
||||
const labelId = useId();
|
||||
const Icon = spec.icon;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2 rounded-lg bg-muted/40 p-3">
|
||||
<div className="flex min-w-0 flex-col gap-2 px-1 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Base UI names the thumb's <input> from the root's aria-labelledby. */}
|
||||
<span
|
||||
id={labelId}
|
||||
className="inline-flex items-center gap-1.5 text-[length:var(--text-label)] font-medium"
|
||||
@@ -148,7 +147,7 @@ function SliderRow({ spec, value }: SliderRowProps) {
|
||||
</output>
|
||||
</div>
|
||||
<Slider
|
||||
aria-labelledby={labelId}
|
||||
thumbProps={{ 'aria-labelledby': labelId }}
|
||||
min={spec.min}
|
||||
max={spec.max}
|
||||
step={spec.step}
|
||||
@@ -173,7 +172,7 @@ function SwitchRow({ settingKey, labelKey, icon: Icon, checked }: SwitchRowProps
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg bg-muted/40 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3 px-1 py-2.5">
|
||||
<Label htmlFor={id} className="gap-1.5 text-[length:var(--text-label)]">
|
||||
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
{t(labelKey)}
|
||||
@@ -196,11 +195,12 @@ function Overrides({ settings }: OverridesProps) {
|
||||
const durationId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<p className="px-1 text-xs text-muted-foreground">{t('voiceControls.basics')}</p>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,200px),1fr))] gap-2">
|
||||
{SLIDERS.map((spec) => (
|
||||
{SLIDERS.filter((spec) => spec.key === 'speed').map((spec) => (
|
||||
<SliderRow key={spec.key} spec={spec} value={settings[spec.key]} />
|
||||
))}
|
||||
<div className="flex min-w-0 flex-col gap-2 rounded-lg bg-muted/40 p-3">
|
||||
<div className="flex min-w-0 flex-col gap-2 px-1 py-3">
|
||||
<Label htmlFor={durationId} className="gap-1.5 text-[length:var(--text-label)]">
|
||||
<ClockIcon className="size-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
{t('clone.duration')}
|
||||
@@ -218,17 +218,32 @@ function Overrides({ settings }: OverridesProps) {
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,200px),1fr))] gap-2">
|
||||
<SwitchRow
|
||||
settingKey="denoise"
|
||||
labelKey="clone.denoise"
|
||||
labelKey="voiceControls.denoise"
|
||||
icon={AudioLinesIcon}
|
||||
checked={settings.denoise}
|
||||
/>
|
||||
<SwitchRow
|
||||
settingKey="postprocess"
|
||||
labelKey="clone.postprocess"
|
||||
labelKey="voiceControls.polish"
|
||||
icon={SparklesIcon}
|
||||
checked={settings.postprocess}
|
||||
/>
|
||||
</div>
|
||||
<details className="group border-t border-border/50 pt-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
{t('voiceControls.advanced')}
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 transition-transform group-open:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t('voiceControls.hint')}</p>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{SLIDERS.filter((spec) => spec.key !== 'speed' && spec.key !== 'steps').map((spec) => (
|
||||
<SliderRow key={spec.key} spec={spec} value={settings[spec.key]} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="xs" onClick={() => resetOverrides()}>
|
||||
<RotateCcwIcon data-icon="inline-start" />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { readWavInfo, type WavInfo } from '@/lib/audio/wav-info';
|
||||
|
||||
export function AudioFileDetails({ blob }: { blob: Blob }) {
|
||||
const { t } = useTranslation();
|
||||
const [info, setInfo] = useState<{ blob: Blob; wav: WavInfo | null } | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void blob
|
||||
.slice(0, 65536)
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
if (active) setInfo({ blob, wav: readWavInfo(buffer) });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setInfo(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [blob]);
|
||||
const wav = info?.blob === blob ? info.wav : null;
|
||||
const size = (blob.size / 1048576).toFixed(2);
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{wav
|
||||
? t('cloneQuality.fileInfo', {
|
||||
size,
|
||||
rate: wav.sampleRate / 1000,
|
||||
channels: wav.channels,
|
||||
bits: wav.bits,
|
||||
encoding: wav.encoding === 'float' ? t('cloneQuality.float') : 'PCM',
|
||||
})
|
||||
: t('cloneQuality.fileSize', { size })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
const mocks = vi.hoisted(() => ({ api: vi.fn(), seek: vi.fn() }));
|
||||
vi.mock('@/lib/api/client', () => ({ apiJson: mocks.api }));
|
||||
vi.mock('@/lib/audio/playback-clock', () => ({
|
||||
requestPlaybackSeek: mocks.seek,
|
||||
}));
|
||||
vi.mock('@/components/waveform-player', () => ({
|
||||
formatClock: (value: number) => String(value),
|
||||
}));
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
import { AudioQuality } from './audio-quality';
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
function mount(path = '12345678.wav') {
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AudioQuality audioPath={path} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
it('checks only on request, seeks to warnings, and dismisses without changing audio', async () => {
|
||||
mocks.api.mockResolvedValue({
|
||||
truncated: false,
|
||||
warnings: [{ kind: 'silence', start: 2, end: 4 }],
|
||||
});
|
||||
mount();
|
||||
expect(mocks.api).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText('audioQuality.check'));
|
||||
fireEvent.click(await screen.findByText('2–4: audioQuality.silence'));
|
||||
expect(mocks.api).toHaveBeenCalledWith(
|
||||
'/audio/12345678/quality',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(mocks.seek).toHaveBeenCalledWith('output', 2);
|
||||
fireEvent.click(screen.getByText('common.close'));
|
||||
expect(screen.queryByText('audioQuality.advisory')).toBeNull();
|
||||
});
|
||||
it('reports a limited scan without promising clean audio', async () => {
|
||||
mocks.api.mockResolvedValue({ truncated: true, warnings: [] });
|
||||
mount();
|
||||
fireEvent.click(screen.getByText('audioQuality.check'));
|
||||
expect(await screen.findByText('audioQuality.truncated')).toBeInTheDocument();
|
||||
});
|
||||
it('handles analysis failure and rejects arbitrary paths', async () => {
|
||||
mocks.api.mockRejectedValue(new Error('unavailable'));
|
||||
mount();
|
||||
fireEvent.click(screen.getByText('audioQuality.check'));
|
||||
expect(await screen.findByText('audioQuality.failed')).toBeInTheDocument();
|
||||
cleanup();
|
||||
mount('../private.wav');
|
||||
expect(screen.queryByText('audioQuality.check')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiJson } from '@/lib/api/client';
|
||||
import { requestPlaybackSeek } from '@/lib/audio/playback-clock';
|
||||
import { formatClock } from '@/components/waveform-player';
|
||||
|
||||
interface QualityReport {
|
||||
truncated: boolean;
|
||||
warnings: {
|
||||
kind: 'empty' | 'silence' | 'quiet' | 'loud' | 'clipping' | 'invalid';
|
||||
start: number;
|
||||
end: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Mounted with the output URL as key so dismissed state never leaks between takes. */
|
||||
export function AudioQuality({ audioPath }: { audioPath: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [requested, setRequested] = useState(false);
|
||||
const id = /^([0-9a-f]{8})\.wav$/.exec(audioPath)?.[1];
|
||||
const report = useQuery({
|
||||
queryKey: ['audio-quality', audioPath],
|
||||
queryFn: ({ signal }) => apiJson<QualityReport>(`/audio/${id}/quality`, { signal }),
|
||||
enabled: requested && Boolean(id),
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
if (!id) return null;
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRequested(!requested)}
|
||||
>
|
||||
{t(requested ? 'common.close' : 'audioQuality.check')}
|
||||
</Button>
|
||||
{requested ? (
|
||||
<div aria-live="polite" className="space-y-1">
|
||||
{report.isFetching ? <p>{t('common.loading')}</p> : null}
|
||||
{report.isError ? <p>{t('audioQuality.failed')}</p> : null}
|
||||
{report.data ? (
|
||||
<>
|
||||
<p>{t('audioQuality.advisory')}</p>
|
||||
{report.data.truncated ? <p>{t('audioQuality.truncated')}</p> : null}
|
||||
{!report.data.warnings.length ? <p>{t('audioQuality.clear')}</p> : null}
|
||||
<ul className="max-h-32 overflow-y-auto">
|
||||
{report.data.warnings.map((warning, index) => (
|
||||
<li key={index}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => requestPlaybackSeek('output', warning.start)}
|
||||
>
|
||||
{formatClock(warning.start)}–{formatClock(warning.end)}:{' '}
|
||||
{t(`audioQuality.${warning.kind}`)}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { ActionBar, ProductionSettings } from './action-bar';
|
||||
import { OutputPanel } from './output-panel';
|
||||
import { ReferencePanel, SaveProfileForm } from './reference-panel';
|
||||
import { ScriptPanel } from './script-panel';
|
||||
import { QualityControls } from './quality-controls';
|
||||
import { useCloneDemo } from '@/hooks/use-clone-demo';
|
||||
import { runRendererTask } from '@/lib/global-error-recovery';
|
||||
|
||||
@@ -253,6 +254,7 @@ export function ClonePage() {
|
||||
coachmark={showDemoCoachmark ? t('demo.clone_coachmark') : undefined}
|
||||
onUserEdit={() => setShowDemoCoachmark(false)}
|
||||
/>
|
||||
<QualityControls disabled={isGenerating} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { audioUrl } from '@/lib/api/client';
|
||||
import { clearLatestOutput, useLatestOutput } from '@/lib/store/output';
|
||||
import { activePlaybackSource, stopActivePlayback } from '@/lib/audio/playback';
|
||||
import { formatSeconds } from './format';
|
||||
import { AudioQuality } from './audio-quality';
|
||||
import { AudioFileDetails } from './audio-file-details';
|
||||
import { SectionLabel } from './section-label';
|
||||
|
||||
export function OutputPanel() {
|
||||
@@ -49,6 +51,8 @@ export function OutputPanel() {
|
||||
{result && objectUrl ? (
|
||||
<>
|
||||
<WaveformPlayer key={objectUrl} src={objectUrl} source="output" height={36} />
|
||||
<AudioFileDetails blob={result.blob} />
|
||||
{result.audioPath && <AudioQuality key={objectUrl} audioPath={result.audioPath} />}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<p
|
||||
className="line-clamp-2 min-w-40 flex-1 text-sm text-muted-foreground"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { QualityControls } from './quality-controls';
|
||||
import { cloneSettingsStore, DEFAULT_CLONE_SETTINGS } from '@/lib/store/clone-settings';
|
||||
|
||||
const engine = vi.hoisted(() => ({ id: 'omnivoice' }));
|
||||
vi.mock('@/hooks/use-engines', () => ({ useEngines: () => ({ activeTts: engine }) }));
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: { size?: string }) => (params?.size ? `${key}: ${params.size}` : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
// Base UI measures the track before exposing its keyboard input. jsdom has
|
||||
// no layout, so supply a real track size rather than bypassing accessibility.
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 300,
|
||||
bottom: 16,
|
||||
width: 300,
|
||||
height: 16,
|
||||
toJSON() {},
|
||||
});
|
||||
cloneSettingsStore.setState(() => ({ ...DEFAULT_CLONE_SETTINGS }));
|
||||
engine.id = 'omnivoice';
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('changes export precision with the keyboard, updates size and keeps sampling independent', async () => {
|
||||
render(<QualityControls />);
|
||||
const precision = await screen.findByRole('slider', { name: 'cloneQuality.title' });
|
||||
expect(precision).toHaveAttribute('aria-valuetext', 'cloneQuality.bits16');
|
||||
expect(screen.getByText('cloneQuality.size: 2.75')).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(precision, { key: 'ArrowRight' });
|
||||
});
|
||||
expect(cloneSettingsStore.state.wavBits).toBe(24);
|
||||
expect(screen.getByText('cloneQuality.size: 4.12')).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(precision, { key: 'End' });
|
||||
});
|
||||
expect(cloneSettingsStore.state.wavBits).toBe(32);
|
||||
expect(screen.getByText('cloneQuality.size: 5.49')).toBeInTheDocument();
|
||||
expect(cloneSettingsStore.state.steps).toBe(16);
|
||||
fireEvent.click(screen.getByText('voiceControls.options'));
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'cloneQuality.effort' }), {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
});
|
||||
expect(cloneSettingsStore.state.steps).toBe(17);
|
||||
expect(cloneSettingsStore.state.wavBits).toBe(32);
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'cloneQuality.mastering' }));
|
||||
expect(cloneSettingsStore.state.effectPreset).toBe('raw');
|
||||
});
|
||||
|
||||
it('hides unsupported sampling controls and disables quality changes while generating', async () => {
|
||||
engine.id = 'kitten';
|
||||
render(<QualityControls disabled />);
|
||||
expect(screen.queryByRole('slider', { name: 'cloneQuality.effort' })).toBeNull();
|
||||
expect(await screen.findByRole('slider')).toBeDisabled();
|
||||
fireEvent.click(screen.getByText('voiceControls.options'));
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it.each(['omnivoice-subprocess', 'voxcpm2', 'dots-tts', 'supertonic3'])(
|
||||
'offers only supported sampling ranges for %s',
|
||||
async (id) => {
|
||||
engine.id = id;
|
||||
render(<QualityControls />);
|
||||
fireEvent.click(screen.getByText('voiceControls.options'));
|
||||
const steps = await screen.findByRole('slider', { name: 'cloneQuality.effort' });
|
||||
expect(steps).toHaveAttribute('max', id === 'supertonic3' ? '12' : '64');
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(steps, { key: 'Home' });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(steps, { key: 'End' });
|
||||
});
|
||||
expect(cloneSettingsStore.state.steps).toBe(id === 'supertonic3' ? 12 : 64);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps detailed tuning collapsed until requested', () => {
|
||||
const { container } = render(<QualityControls />);
|
||||
expect(container.querySelector('details')).not.toHaveAttribute('open');
|
||||
expect(screen.getByRole('switch')).not.toBeVisible();
|
||||
fireEvent.click(screen.getByText('voiceControls.options'));
|
||||
expect(screen.getByRole('switch', { name: 'cloneQuality.mastering' })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useId } from 'react';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useEngines } from '@/hooks/use-engines';
|
||||
import { effectiveSamplingSteps, samplingStepRange } from '@/lib/audio/quality';
|
||||
import { setCloneSetting, useCloneSettings } from '@/lib/store/clone-settings';
|
||||
|
||||
const precisions = [16, 24, 32] as const;
|
||||
|
||||
export function QualityControls({ disabled = false }: { disabled?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const settings = useCloneSettings();
|
||||
const { activeTts } = useEngines();
|
||||
const id = useId();
|
||||
const bits = settings.wavBits;
|
||||
const stepRange = samplingStepRange(activeTts?.id);
|
||||
const steps = effectiveSamplingSteps(settings.steps, activeTts?.id);
|
||||
return (
|
||||
<section aria-labelledby={id} className="my-3 shrink-0 border-t border-border/50 pt-3">
|
||||
<h3 id={id} className="sr-only">
|
||||
{t('cloneQuality.title')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-56 space-y-2">
|
||||
<div className="flex flex-wrap justify-between gap-2 text-xs">
|
||||
<span id={`${id}-precision`}>{t('cloneQuality.title')}</span>
|
||||
<output className="font-medium tabular-nums">{t(`cloneQuality.bits${bits}`)}</output>
|
||||
</div>
|
||||
<Slider
|
||||
thumbProps={{
|
||||
'aria-labelledby': `${id}-precision`,
|
||||
'aria-describedby': `${id}-size`,
|
||||
getAriaValueText: (_formatted, value) => t(`cloneQuality.bits${precisions[value]}`),
|
||||
}}
|
||||
min={0}
|
||||
max={2}
|
||||
step={1}
|
||||
value={[precisions.indexOf(bits)]}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) =>
|
||||
setCloneSetting('wavBits', precisions[Array.isArray(value) ? value[0] : value])
|
||||
}
|
||||
/>
|
||||
<p id={`${id}-size`} className="text-xs text-muted-foreground">
|
||||
{t('cloneQuality.size', { size: ((24000 * 60 * bits) / 8 / 1048576).toFixed(2) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<details className="group mt-2">
|
||||
<summary className="flex w-fit cursor-pointer list-none items-center gap-1 text-xs text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
{t('voiceControls.options')}
|
||||
<ChevronDownIcon
|
||||
className="size-3 transition-transform group-open:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
<div className="mt-3 space-y-4 pb-1">
|
||||
<p className="text-xs text-muted-foreground">{t(`cloneQuality.hint${bits}`)}</p>
|
||||
{stepRange && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between gap-2 text-xs">
|
||||
<span id={`${id}-steps`}>{t('cloneQuality.effort')}</span>
|
||||
<output className="tabular-nums">{steps}</output>
|
||||
</div>
|
||||
<Slider
|
||||
thumbProps={{
|
||||
'aria-labelledby': `${id}-steps`,
|
||||
'aria-describedby': `${id}-effort`,
|
||||
}}
|
||||
min={stepRange[0]}
|
||||
max={stepRange[1]}
|
||||
step={1}
|
||||
value={[steps]}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) =>
|
||||
setCloneSetting('steps', Array.isArray(value) ? value[0] : value)
|
||||
}
|
||||
/>
|
||||
<p id={`${id}-effort`} className="text-xs text-muted-foreground">
|
||||
{t('cloneQuality.effortHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between gap-3 text-xs">
|
||||
<span>
|
||||
{t('cloneQuality.mastering')}
|
||||
<span className="mt-1 block text-muted-foreground">
|
||||
{t('cloneQuality.masteringHint')}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
aria-label={t('cloneQuality.mastering')}
|
||||
checked={settings.effectPreset === 'broadcast'}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setCloneSetting('effectPreset', checked ? 'broadcast' : 'raw')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('cloneQuality.nextTake')}</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user