refactor(ui): Phase 7 — strip chat/voice/journal/workspace/home surfaces

Frontend deletion phase of the MCP-first pivot. All in-app
conversational surfaces are gone — Claude/MCP is the assistant now.

Deleted views:
  ChatView, JournalView, WorkspaceView, HomeView

Deleted components:
  ChatPanel, ChatInputBar, ChatMessage, ChatStreamingBubble,
  ToolCallCard, ToolConfirmCard, WorkspaceChatWidget

Deleted composables + store:
  useVoiceRecorder, useVoiceAudio, useStreamingTts, stores/chat

Router changes:
  - / now redirects to /knowledge (was /journal)
  - dropped /chat, /chat/:id, /journal, /workspace/:projectId
  - /tasks still redirects to / (→ /knowledge)
  - /notes still redirects to /knowledge

KnowledgeView:
  - removed ChatPanel + ChatInputBar embeds
  - removed minichat floating widget + state + handlers
  - removed Chat link from today bar
  - removed `chatStore` driven auto-refresh-on-tool-call watch

App.vue:
  - removed useChatStore + startStatusPolling/stopStatusPolling
  - removed VAD ONNX preloader (voice subsystem dead)
  - removed visibilitychange listener (only did voice status re-check)
  - removed `c` single-key shortcut (focus chat / goto chat)
  - removed `g+c` two-key sequence (goto chat)
  - removed Chat section from shortcuts overlay
  - removed `.chat-page` / `.workspace-root` CSS overflow rule

AppHeader.vue:
  - removed useChatStore + status indicator (Ollama model status)
  - removed Chat / Journal nav links (desktop + mobile)

SettingsView.vue (4598 → 4079 lines):
  - removed Voice tab entirely
  - Notifications tab: dropped Push Notifications + Chat History
    + About sections (kept Email Notifications)
  - General tab: dropped Assistant (name + model pickers) +
    Model Management sections (kept Tasks + Timezone)
  - Profile tab: dropped Journal + Observations sections
  - VALID_TABS + tab list array no longer include "voice"
  - removed `loadVoiceTab()` activation trigger

Service worker (frontend/public/sw.js):
  - dropped push and notificationclick handlers (push subsystem
    only fired on internal generation completion, which is gone)
  - kept empty fetch handler as PWA installability shell

Script-level dead code (state refs, helper functions referencing
removed APIs) remains in SettingsView and stores/push.ts and
stores/settings.ts for now — Phase 8 backend deletion will clean
those up alongside the matching backend route removals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:14:00 -04:00
parent 05b0bf97d7
commit 18eb1e7ab2
21 changed files with 15 additions and 8062 deletions
+2 -510
View File
@@ -82,7 +82,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "voice", "apikeys", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -92,7 +92,6 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
}
if (tab === "voice") loadVoiceTab();
if (tab === "apikeys") { fetchApiKeys(); }
}
@@ -1482,7 +1481,7 @@ function formatUserDate(iso: string): string {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'voice', 'apikeys']"
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -1506,59 +1505,6 @@ function formatUserDate(iso: string): string {
<!-- General -->
<div v-show="activeTab === 'general'" class="settings-grid">
<section class="settings-section full-width">
<h2>Assistant</h2>
<div class="assistant-grid">
<div class="field">
<label for="assistant-name">Assistant Name</label>
<input
id="assistant-name"
v-model="assistantName"
type="text"
placeholder="Fable"
class="input"
/>
<p class="field-hint">The name used in chat messages and LLM context.</p>
</div>
<div class="field">
<label for="default-model">Chat &amp; Voice Model</label>
<select id="default-model" v-model="defaultModel" class="input">
<option value="">Default ({{ defaultChatModel || "qwen3:latest" }})</option>
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
</select>
<p class="field-hint">
Powers the journal conversation (typed or voice-driven) AND small conversational automations: note-title generation, tag suggestions.
Pick a small fast model (e.g. <code>qwen3:8b</code>, <code>llama3.2:3b</code>) speed and responsiveness matter more than depth.
Ideally runs on GPU.
<br><br>
<strong>Tip for snappy chat:</strong> set <code>OLLAMA_NUM_PARALLEL=2</code> (or higher) on your Ollama server so background automations (tags, titles) get their own KV-cache slot and don't evict the chat model's working state. With <code>NUM_PARALLEL=1</code>, every background call pauses the chat and re-warms the prompt on the next user turn.
</p>
</div>
<div class="field">
<label for="background-model">Curator Model</label>
<select id="background-model" v-model="backgroundModel" class="input">
<option value="">Default (qwen3:latest)</option>
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
</select>
<p class="field-hint">
Powers the journal curator (capture moments, propose updates) and other heavy async work: daily prep generation, end-of-day closeout, task body consolidation, project summaries, profile observation processing.
Pick a smart model latency doesn't matter, quality does. Often runs on CPU with system RAM (e.g. <code>qwen3:32b</code>, <code>qwen3:30b-a3b</code>, <code>llama3.1:70b</code>).
<br><br>
<strong>Serialized:</strong> the app enforces one curator pass at a time globally, regardless of <code>OLLAMA_NUM_PARALLEL</code>. Manual triggers fired while the curator is busy return a "try again" response rather than spawning a second instance. This matters most for large CPU models where a second KV-cache slot would waste system RAM.
<span v-if="backgroundModel && backgroundModel === (defaultModel || defaultChatModel)" class="field-hint-warn">
⚠ Using the same model for both means curator passes compete with chat for the same KV cache. Pick different models so both can stay loaded simultaneously (<code>OLLAMA_MAX_LOADED_MODELS&nbsp;=&nbsp;2</code>+).
</span>
</p>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveAssistant" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<span v-if="saved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- Tasks -->
<section class="settings-section full-width">
<h2>Tasks</h2>
@@ -1608,68 +1554,6 @@ function formatUserDate(iso: string): string {
</div>
</section>
<!-- Model Management -->
<section class="settings-section full-width">
<div class="model-mgmt-header">
<div>
<h2>Model Management</h2>
<p class="section-desc">Install and remove Ollama models without leaving the app. Search <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a> for available models.</p>
</div>
<button class="btn-secondary" @click="loadOllamaModels" title="Refresh list">↺ Refresh</button>
</div>
<!-- Installed models -->
<div v-if="ollamaModels.length" class="model-list">
<div v-for="m in ollamaModels" :key="m.name" class="model-row">
<div class="model-row-info">
<span class="model-name">{{ m.name }}</span>
<span v-if="m.loaded" class="model-badge model-badge--loaded">in VRAM</span>
<span v-if="m.name === (defaultModel || defaultChatModel)" class="model-badge model-badge--default">default</span>
</div>
<div class="model-row-right">
<span class="model-size">{{ formatBytes(m.size) }}</span>
<button
class="model-delete-btn"
:disabled="deletingModel === m.name"
@click="deleteModel(m.name)"
:title="`Remove ${m.name}`"
>{{ deletingModel === m.name ? '' : '' }}</button>
</div>
</div>
</div>
<p v-else class="field-hint">No models installed, or Ollama is unreachable.</p>
<!-- Pull a model -->
<div class="model-pull-form">
<input
v-model="pullModelName"
class="input"
placeholder="e.g. qwen3:7b"
:disabled="pulling"
@keydown.enter="pullModel"
/>
<button class="btn-secondary" @click="pullModel" :disabled="pulling || !pullModelName.trim()">
{{ pulling ? 'Pulling' : 'Pull' }}
</button>
</div>
<div class="model-suggestions">
<span class="suggestions-label">Suggestions:</span>
<button v-for="s in ['qwen3:7b','qwen3:14b','qwen3:4b','llama3.1:8b','nomic-embed-text']"
:key="s" class="suggestion-chip"
:disabled="pulling || ollamaModels.some(m => m.name === s)"
@click="pullModelName = s"
>{{ s }}</button>
</div>
<!-- Pull progress -->
<div v-if="pullProgress" class="model-pull-progress">
<div class="pull-status">{{ pullProgress.status }}</div>
<div class="pull-bar-track" v-if="pullProgress.pct !== null">
<div class="pull-bar-fill" :style="{ width: pullProgress.pct + '%' }"></div>
</div>
<div v-else class="pull-bar-indeterminate"></div>
</div>
</section>
</div>
<!-- Account -->
@@ -1946,109 +1830,6 @@ function formatUserDate(iso: string): string {
</div>
</section>
<section class="settings-section full-width">
<h2>Journal</h2>
<p class="section-desc">Controls when the daily journal prep generates and how the day is framed.</p>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" v-model="journalConfig.prep_enabled" />
Generate daily prep automatically
</label>
<p class="field-hint">When enabled, the journal generates a morning briefing at the time below. When off, the journal still works — just without the auto-generated daily prep.</p>
</div>
<div class="assistant-grid">
<div class="field">
<label>Prep generates at</label>
<div class="time-row">
<input
type="number"
min="0"
max="23"
v-model.number="journalConfig.prep_hour"
class="input time-input"
:disabled="!journalConfig.prep_enabled"
/>
<span class="time-sep">:</span>
<input
type="number"
min="0"
max="59"
step="5"
v-model.number="journalConfig.prep_minute"
class="input time-input"
:disabled="!journalConfig.prep_enabled"
/>
</div>
<p class="field-hint">24-hour. Generates each morning at this local time.</p>
</div>
<div class="field">
<label>Day rolls over at</label>
<div class="time-row">
<input
type="number"
min="0"
max="23"
v-model.number="journalConfig.day_rollover_hour"
class="input time-input"
/>
<span class="time-sep time-sep--quiet">:00</span>
</div>
<p class="field-hint">Hour when the journal switches to a new day. Default 4am — late-night entries (13am) still count as the previous day.</p>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveJournalCfg" :disabled="journalConfigSaving">{{ journalConfigSaving ? 'Saving' : 'Save' }}</button>
<span v-if="journalConfigSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>What the Assistant Has Learned</h2>
<p class="section-desc">
The assistant observes patterns from your journal and chat conversations and builds a summary over time. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you.
<span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
</p>
<label class="toggle-row" style="display:flex;align-items:center;gap:0.6rem;margin:0.5rem 0 0.75rem">
<input
type="checkbox"
:checked="journalConfig.closeout_enabled !== false"
@change="onToggleCloseout(($event.target as HTMLInputElement).checked)"
/>
<span>
<strong>Nightly closeout</strong>
<small style="display:block;color:var(--color-text-muted)">Extracts patterns from yesterday's journal at your day-rollover hour.</small>
</span>
</label>
<div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from journal and chat conversations.</div>
<div class="observations-panel" style="margin-top:0.75rem">
<button class="btn-secondary" @click="toggleObservations" :disabled="profile.observations_count === 0">
{{ observationsExpanded ? '' : '' }} Recent observations ({{ profile.observations_count }})
</button>
<div v-if="observationsExpanded" class="observations-list" style="margin-top:0.5rem;padding:0.5rem 0.75rem;border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elev-1)">
<div v-if="observationsLoading">Loading…</div>
<div v-else-if="observations.length === 0">No observations yet.</div>
<div v-else>
<div v-for="entry in observations" :key="entry.date" style="margin-bottom:0.75rem">
<div style="font-weight:600;font-size:0.875rem;color:var(--color-text-muted)">{{ entry.date }}</div>
<div style="white-space:pre-wrap;font-size:0.9rem">{{ entry.bullets }}</div>
</div>
</div>
</div>
</div>
<div class="actions" style="gap:0.5rem;flex-wrap:wrap">
<button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
{{ consolidating ? 'Consolidating' : 'Consolidate Now' }}
</button>
<button class="btn-danger-outline" @click="clearObservations" :disabled="clearingObs">
{{ clearingObs ? 'Clearing' : 'Reset Learned Data' }}
</button>
</div>
</section>
</div>
<div v-show="activeTab === 'notifications'" class="settings-grid">
@@ -2080,103 +1861,6 @@ function formatUserDate(iso: string): string {
</div>
</section>
<section class="settings-section">
<h2>Push Notifications</h2>
<p class="section-desc">
Receive browser push notifications when a response is ready. Requires HTTPS.
</p>
<template v-if="!pushStore.isSupported">
<p class="push-unsupported">Push notifications are not supported in this browser or connection (requires HTTPS).</p>
</template>
<template v-else>
<div class="push-status-row">
<span class="push-status-label">Permission:</span>
<span
:class="['push-permission-badge', {
'perm-granted': pushStore.permission === 'granted',
'perm-denied': pushStore.permission === 'denied',
'perm-default': pushStore.permission === 'default',
}]"
>
{{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
</span>
</div>
<div class="push-status-row">
<span class="push-status-label">Status:</span>
<span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
{{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
</span>
</div>
<p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
<div class="actions" style="margin-top: 0.75rem;">
<button
v-if="!pushStore.isSubscribed"
class="btn-save"
@click="pushStore.subscribe()"
:disabled="pushStore.loading || pushStore.permission === 'denied'"
>
{{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
</button>
<button
v-else
class="btn-secondary"
@click="pushStore.unsubscribe()"
:disabled="pushStore.loading"
>
{{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
</button>
</div>
<p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
Notifications are blocked. Allow them in your browser site settings to re-enable.
</p>
</template>
<template v-if="authStore.isAdmin">
<div class="field-divider" style="margin: 1rem 0;" />
<p class="section-desc">
If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
the key pair here. All existing subscriptions will be cleared — you will need to
re-enable notifications in this browser afterwards.
</p>
<div class="actions" style="margin-top: 0.5rem;">
<button class="btn-danger" :disabled="vapidResetting" @click="resetVapidKeys">
{{ vapidResetting ? 'Regenerating' : 'Regenerate VAPID Keys' }}
</button>
</div>
<p v-if="vapidResetMsg" :class="vapidResetError ? 'field-error' : 'field-hint'" style="margin-top: 0.5rem;">
{{ vapidResetMsg }}
</p>
</template>
</section>
<section class="settings-section">
<h2>Chat History</h2>
<p class="section-desc">
Conversations older than this many days are automatically deleted when you load the chat.
Set to <strong>0</strong> to keep conversations forever.
</p>
<div class="field retention-field">
<label class="field-label">Retention period (days)</label>
<div class="retention-row">
<input
v-model.number="chatRetentionDays"
type="number"
min="0"
max="3650"
class="input retention-input"
/>
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
{{ savingRetention ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</section>
<section class="settings-section full-width">
<h2>About</h2>
<p class="version-line">Fabled Scribe <span class="version-badge">{{ appVersion }}</span></p>
</section>
</div>
<!-- Integrations -->
<div v-show="activeTab === 'integrations'" class="settings-grid">
@@ -2307,198 +1991,6 @@ function formatUserDate(iso: string): string {
</div>
<div v-show="activeTab === 'voice'" class="settings-grid">
<section class="settings-section full-width">
<h2>Voice</h2>
<p class="section-desc">
Configure text-to-speech and speech-to-text for voice conversations.
Requires <code>VOICE_ENABLED=true</code> on the server.
</p>
<!-- Status indicator -->
<div v-if="voiceStatusLoading" class="field-hint">Loading voice status…</div>
<div v-else-if="voiceStatus === null" class="field-hint">Voice status unavailable.</div>
<div v-else class="voice-status-row">
<span :class="['status-badge', voiceStatus.enabled ? 'status-on' : 'status-off']">
{{ voiceStatus.enabled ? 'Enabled' : 'Disabled' }}
</span>
<span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.stt ? 'status-on' : 'status-off'">
STT {{ voiceStatus.stt ? 'ready' : 'loading' }}
</span>
<span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.tts ? 'status-on' : 'status-off'">
TTS {{ voiceStatus.tts ? 'ready' : 'loading' }}
</span>
<span v-if="voiceStatus.stt_model" class="field-hint" style="margin-left:0.5rem;">
Model: {{ voiceStatus.stt_model }}
</span>
</div>
<div v-if="!voiceStatus?.enabled" class="field-hint" style="margin-top:0.75rem;">
Voice is disabled. An administrator can enable it under
<strong>Admin → Config → Voice</strong>.
</div>
</section>
<section v-if="voiceStatus?.enabled" class="settings-section full-width">
<h2>Speech Style</h2>
<p class="section-desc">Controls how the assistant phrases spoken responses.</p>
<div class="field-group">
<label class="field-label">Style</label>
<div class="radio-group">
<label v-for="opt in [
{ value: 'conversational', label: 'Conversational', hint: 'Warm and natural, like speaking to a friend' },
{ value: 'concise', label: 'Concise', hint: 'Short and to the point' },
{ value: 'detailed', label: 'Detailed', hint: 'Thorough explanations spoken aloud' },
]" :key="opt.value" class="radio-option">
<input type="radio" v-model="voiceSpeechStyle" :value="opt.value" />
<span>
<strong>{{ opt.label }}</strong>
<span class="field-hint">{{ opt.hint }}</span>
</span>
</label>
</div>
</div>
</section>
<section v-if="voiceStatus?.enabled" class="settings-section full-width">
<h2>Voice &amp; Speed</h2>
<p class="section-desc">Choose the TTS voice and playback speed.</p>
<div class="field-group">
<label class="field-label" for="voice-select">Voice</label>
<select id="voice-select" class="input" v-model="voiceTtsVoice" :disabled="availableVoices.length === 0">
<option v-if="availableVoices.length === 0" value="af_heart">af_heart (default)</option>
<option v-for="v in availableVoices" :key="v.id" :value="v.id">{{ v.label }}</option>
</select>
<p class="field-hint">Voice character and accent. Applies to all voice conversations.</p>
</div>
<div class="field-group">
<label class="field-label" for="voice-speed">
Speed: {{ voiceTtsSpeed.toFixed(1) }}×
</label>
<input
id="voice-speed"
type="range"
min="0.7"
max="1.3"
step="0.05"
v-model.number="voiceTtsSpeed"
class="range-input"
/>
<div class="range-labels">
<span>0.7× (slow)</span>
<span>1.0× (normal)</span>
<span>1.3× (fast)</span>
</div>
</div>
<div class="form-actions">
<button class="btn-secondary" @click="previewVoice" :disabled="voicePreviewing || !voiceStatus?.tts">
{{ voicePreviewing ? 'Generating' : ' Preview' }}
</button>
<button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
{{ voiceSaved ? 'Saved ' : savingVoice ? 'Saving' : 'Save Voice Settings' }}
</button>
</div>
</section>
<!-- Voice Blend section removed with the kokoro → piper migration
(2026-05-22). Piper has no voice-blending equivalent. -->
<!-- Voice Library (admin only) — browse + install piper voices.
Voices land in /data/voices and are immediately available to
every user's voice picker. Bundled voices in /opt/piper-voices
are read-only and show a "bundled" badge instead of a delete. -->
<section v-if="authStore.isAdmin && voiceStatus?.enabled" class="settings-section full-width">
<div class="voice-library-header">
<h2>Voice Library</h2>
<button
class="btn-secondary"
type="button"
@click="voiceLibraryExpanded = !voiceLibraryExpanded; if (voiceLibraryExpanded && voiceLibrary.length === 0) loadVoiceLibrary()"
>
{{ voiceLibraryExpanded ? 'Hide' : 'Browse voices' }}
</button>
</div>
<p class="section-desc">
Download additional piper voices from the
<a href="https://huggingface.co/rhasspy/piper-voices" target="_blank" rel="noopener">piper-voices catalog</a>.
Installed voices appear in every user's voice picker after a page reload.
</p>
<template v-if="voiceLibraryExpanded">
<div class="voice-library-toolbar">
<input
v-model="voiceLibraryFilter"
type="search"
class="input"
placeholder="Filter by language, country, or name (e.g. 'en', 'fr_FR', 'amy')…"
/>
<button
class="btn-secondary"
type="button"
:disabled="voiceLibraryLoading"
@click="loadVoiceLibrary(true)"
title="Re-fetch catalog from HuggingFace"
>
{{ voiceLibraryLoading ? 'Loading' : 'Refresh' }}
</button>
</div>
<p v-if="voiceLibraryError" class="field-hint-warn">
{{ voiceLibraryError }}
</p>
<div v-if="voiceLibraryLoading && voiceLibrary.length === 0" class="voice-library-empty">
Loading catalog…
</div>
<ul v-else-if="filteredVoiceLibrary.length > 0" class="voice-library-list">
<li v-for="v in filteredVoiceLibrary" :key="v.id" class="voice-library-row">
<div class="voice-library-meta">
<div class="voice-library-id">{{ v.id }}</div>
<div class="voice-library-sub">
{{ v.language_name || v.language_code }}
<span v-if="v.country"> · {{ v.country }}</span>
· {{ v.quality }}
<span v-if="v.num_speakers > 1"> · {{ v.num_speakers }} speakers</span>
· {{ formatVoiceSize(v.size_bytes) }}
</div>
</div>
<div class="voice-library-actions">
<span v-if="v.installed && v.installed_source === 'bundled'" class="voice-badge voice-badge--bundled">
bundled
</span>
<button
v-else-if="v.installed && v.installed_source === 'user'"
class="btn-danger btn-sm"
:disabled="uninstallingVoiceIds.has(v.id)"
@click="uninstallLibraryVoice(v.id)"
>
{{ uninstallingVoiceIds.has(v.id) ? 'Removing' : 'Remove' }}
</button>
<button
v-else
class="btn-secondary btn-sm"
:disabled="installingVoiceIds.has(v.id)"
@click="installLibraryVoice(v.id)"
>
{{ installingVoiceIds.has(v.id) ? 'Installing' : 'Install' }}
</button>
</div>
</li>
</ul>
<div v-else-if="!voiceLibraryLoading" class="voice-library-empty">
No voices match the filter.
</div>
</template>
</section>
</div>
<!-- MCP Access -->
<div v-show="activeTab === 'apikeys'" class="settings-grid">