fix(ui): name OmniVoice correctly and cycle active engine labels
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Changed
|
||||
|
||||
- The title-bar engine button shows the selected engine name, with its full description in the tooltip (#1823)
|
||||
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
|
||||
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
|
||||
### Added
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEngines, useSelectEngine } from '../api/hooks';
|
||||
import { notifyEngineSelected } from '../utils/engineSelectToast';
|
||||
import { useAppStore } from '../store';
|
||||
import { MENU_SURFACE } from './computeTarget';
|
||||
import { engineDisplayName } from '../utils/engineDisplayName';
|
||||
|
||||
/**
|
||||
* A compact TTS/ASR/LLM picker for chrome that needs to expose the active
|
||||
@@ -150,7 +151,8 @@ export default function EngineQuickSwitch({
|
||||
{available.map((engine) => {
|
||||
const isActive = engine.id === familyData.active;
|
||||
const warm = residentIds.has(engine.id);
|
||||
const parts = engine.display_name.match(/^(.+?)\s*\((.*)\)$/);
|
||||
const displayName = engineDisplayName(engine.display_name);
|
||||
const parts = displayName.match(/^(.+?)\s*\((.*)\)$/);
|
||||
return (
|
||||
<button
|
||||
key={engine.id}
|
||||
@@ -177,7 +179,7 @@ export default function EngineQuickSwitch({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
engine.display_name
|
||||
displayName
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-[color:var(--chrome-fg-muted)]">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GpuTarget from './GpuTarget';
|
||||
import EngineQuickSwitch from './EngineQuickSwitch';
|
||||
import { engineDisplayName } from '../utils/engineDisplayName';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
@@ -166,11 +167,21 @@ export default function Header({
|
||||
const [flushOpen, setFlushOpen] = useState(false);
|
||||
const [engineFamily, setEngineFamily] = useState('tts');
|
||||
const { data: engines } = useEngines();
|
||||
const activeFamily = engines?.[engineFamily];
|
||||
const activeEngineName = activeFamily?.backends?.find(
|
||||
(engine) => engine.id === activeFamily.active,
|
||||
)?.display_name;
|
||||
const [engineLabelIndex, setEngineLabelIndex] = useState(0);
|
||||
const [engineLabelPaused, setEngineLabelPaused] = useState(false);
|
||||
const activeEngines = ['tts', 'asr', 'llm'].flatMap((family) => {
|
||||
const inventory = engines?.[family];
|
||||
const active = inventory?.backends?.find((engine) => engine.id === inventory.active);
|
||||
return active ? [{ family, name: engineDisplayName(active.display_name) }] : [];
|
||||
});
|
||||
const displayedEngine = activeEngines[engineLabelIndex % (activeEngines.length || 1)];
|
||||
const activeEngineName = displayedEngine?.name;
|
||||
const activeEngineShortName = activeEngineName?.split(' (')[0];
|
||||
useEffect(() => {
|
||||
if (flushOpen || engineLabelPaused || activeEngines.length < 2) return;
|
||||
const timer = setInterval(() => setEngineLabelIndex((index) => index + 1), 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [flushOpen, engineLabelPaused, activeEngines.length]);
|
||||
const [loadedModels, setLoadedModels] = useState([]);
|
||||
const [unloading, setUnloading] = useState(null);
|
||||
const flushRef = useRef(null);
|
||||
@@ -419,10 +430,25 @@ export default function Header({
|
||||
loading={flushing}
|
||||
leading={!flushing && <Zap size={8} />}
|
||||
trailing={<ChevronDown size={8} />}
|
||||
onClick={() => setFlushOpen((o) => !o)}
|
||||
onMouseEnter={() => setEngineLabelPaused(true)}
|
||||
onMouseLeave={() => setEngineLabelPaused(false)}
|
||||
onFocus={() => setEngineLabelPaused(true)}
|
||||
onBlur={() => setEngineLabelPaused(false)}
|
||||
onClick={() => {
|
||||
if (!flushOpen && displayedEngine) setEngineFamily(displayedEngine.family);
|
||||
setFlushOpen((o) => !o);
|
||||
}}
|
||||
className="ml-[2px]"
|
||||
>
|
||||
<span className="max-w-[160px] truncate max-[600px]:max-w-[100px]">
|
||||
<span
|
||||
key={displayedEngine?.family}
|
||||
className="engine-title-label w-[190px] text-left truncate max-[600px]:w-[140px]"
|
||||
>
|
||||
{displayedEngine && (
|
||||
<span className="text-[var(--chrome-fg-dim)]">
|
||||
{t(`models.role_${displayedEngine.family}`)} ·{' '}
|
||||
</span>
|
||||
)}
|
||||
{activeEngineShortName || t('settings.engines')}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -505,7 +531,7 @@ export default function Header({
|
||||
>
|
||||
<div className="flex flex-col gap-[1px] min-w-0">
|
||||
<span className="text-[12px] text-[var(--color-fg)] font-medium">
|
||||
{m.name}
|
||||
{engineDisplayName(m.name)}
|
||||
{/* Resident-but-not-routed engine (e.g. VoiceStudio still in
|
||||
VRAM after switching to another backend) — say so. */}
|
||||
{m.is_active_engine === false && (
|
||||
|
||||
@@ -2470,6 +2470,15 @@ input[type="file"]::file-selector-button:hover {
|
||||
}
|
||||
|
||||
/* from MultiLangPicker (components/MultiLangPicker.css) */
|
||||
@keyframes engine-title-enter {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.engine-title-label { animation: engine-title-enter 180ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.engine-title-label { animation: none; }
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.multi-lang__drop {
|
||||
position: fixed;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* navigation at all (the rail isn't rendered either).
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => windowActions
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderHeader(props, engines) {
|
||||
@@ -39,6 +40,31 @@ function renderHeader(props, engines) {
|
||||
}
|
||||
|
||||
describe('Header — rail mode (default)', () => {
|
||||
it('cycles all three active engines and pauses while the panel is open', () => {
|
||||
vi.useFakeTimers();
|
||||
renderHeader(
|
||||
{ onFlushMemory: vi.fn() },
|
||||
{
|
||||
tts: {
|
||||
active: 'omni',
|
||||
backends: [
|
||||
{ id: 'omni', display_name: 'VoiceStudio (k2-fsa/OmniVoice, 600+ languages)' },
|
||||
],
|
||||
},
|
||||
asr: { active: 'whisper', backends: [{ id: 'whisper', display_name: 'Whisper' }] },
|
||||
llm: { active: 'off', backends: [{ id: 'off', display_name: 'Off (no LLM)' }] },
|
||||
},
|
||||
);
|
||||
const trigger = screen.getByRole('button', { name: 'Engines' });
|
||||
expect(trigger).toHaveTextContent('TTS · OmniVoice');
|
||||
act(() => vi.advanceTimersByTime(4000));
|
||||
expect(trigger).toHaveTextContent('ASR · Whisper');
|
||||
act(() => vi.advanceTimersByTime(4000));
|
||||
expect(trigger).toHaveTextContent('LLM · Off');
|
||||
fireEvent.click(trigger);
|
||||
act(() => vi.advanceTimersByTime(4000));
|
||||
expect(trigger).toHaveTextContent('LLM · Off');
|
||||
});
|
||||
it('shows the selected engine name on the title-bar trigger', () => {
|
||||
renderHeader(
|
||||
{ onFlushMemory: vi.fn() },
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Older backends used the application name for the bundled OmniVoice model.
|
||||
export function engineDisplayName(name = '') {
|
||||
return name.replace(/^VoiceStudio(?= \(k2-fsa\/OmniVoice| TTS$|$)/, 'OmniVoice');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { engineDisplayName } from './engineDisplayName';
|
||||
|
||||
describe('engineDisplayName', () => {
|
||||
it('uses the model name for legacy engine and resident labels', () => {
|
||||
expect(engineDisplayName('VoiceStudio (k2-fsa/OmniVoice, 600+ languages)')).toBe(
|
||||
'OmniVoice (k2-fsa/OmniVoice, 600+ languages)',
|
||||
);
|
||||
expect(engineDisplayName('VoiceStudio TTS')).toBe('OmniVoice TTS');
|
||||
expect(engineDisplayName('KittenTTS (English)')).toBe('KittenTTS (English)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user