refactor(ui): purge SettingsView dead JS left over from Phase 7

The Phase 7 template strip left script-level state, functions, and
imports that no template ever referenced. TypeScript strict mode
(noUnusedLocals + noUnusedParameters) caught all of them on CI.

Removed from the script:
  - chat retention: chatRetentionDays, savingRetention, saveRetention
  - VAPID/push: vapidResetting/Msg/Error, resetVapidKeys, usePushStore,
    pushStore.checkSubscription() call in onMounted
  - admin voice block: adminVoiceEnabled, adminVoiceSttModel,
    savingAdminVoice, adminVoiceSaved, voiceLoadingModels,
    saveAdminVoice, reloadVoiceModels (plus the matching admin
    template block — that was still present and referenced these)
  - user voice block: voiceStatus, voiceStatusLoading, availableVoices,
    voiceTtsVoice, voiceTtsSpeed, voiceSpeechStyle, savingVoice,
    voiceSaved, voiceTabLoaded, the whole voice library
    (voiceLibrary, voiceLibraryLoading/Error/Filter/Expanded,
    installingVoiceIds, uninstallingVoiceIds, filteredVoiceLibrary,
    formatVoiceSize, loadVoiceLibrary, refreshInstalledVoices,
    installLibraryVoice, uninstallLibraryVoice, loadVoiceTab,
    voicePreviewing, previewVoice, saveVoiceSettings)
  - observations / consolidation: consolidating, clearingObs,
    observations, observationsExpanded/Loading/Loaded,
    toggleObservations, onToggleCloseout, runConsolidate,
    clearObservations
  - assistant / model management: assistantName, defaultModel,
    backgroundModel, installedModels, defaultChatModel, OllamaModel
    interface, ollamaModels, pullModelName, pullProgress, pulling,
    deletingModel, formatBytes, loadOllamaModels, pullModel,
    deleteModel, saveAssistant, saving, saved
  - api/client imports: getVoiceStatus, getVoiceList, getVoiceLibrary,
    installVoice, uninstallVoice, synthesiseSpeech, consolidateProfile,
    clearProfileObservations, listProfileObservations,
    VoiceStatusResult, VoiceEntry, VoiceLibraryEntry,
    ProfileObservationEntry

Surviving: journalConfig + locations editor + temp_unit selector
(Profile→Locations section still uses these to manage home/work
addresses for any future feature; the journal-specific prep/closeout
fields on the same object are dead but harmless as object members).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:26:51 -04:00
parent 18eb1e7ab2
commit ba6f2c7614
+3 -489
View File
@@ -3,8 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice, uninstallVoice, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceLibraryEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client";
import { usePushStore } from "@/stores/push";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, type JournalConfig } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -12,9 +11,6 @@ import TagInput from "@/components/TagInput.vue";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const pushStore = usePushStore();
const assistantName = ref("");
const defaultModel = ref("");
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
@@ -56,16 +52,6 @@ async function saveTimezone() {
savingTimezone.value = false;
}
}
const backgroundModel = ref("");
const installedModels = ref<string[]>([]);
const defaultChatModel = ref("");
interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
const ollamaModels = ref<OllamaModel[]>([]);
const pullModelName = ref("");
const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
const pulling = ref(false);
const deletingModel = ref<string | null>(null);
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
@@ -74,8 +60,6 @@ const newPassword = ref("");
const confirmNewPassword = ref("");
const changingPassword = ref(false);
const invalidatingSessions = ref(false);
const saving = ref(false);
const saved = ref(false);
const exporting = ref(false);
const restoring = ref(false);
const appVersion = ref('dev');
@@ -296,47 +280,12 @@ async function removeMemberFromGroup(groupId: number, userId: number) {
}
// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);
async function saveRetention() {
savingRetention.value = true;
try {
await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
} finally {
savingRetention.value = false;
}
}
// Notification preferences
// Notification preferences (email notifications only — push surface removed)
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
// VAPID key reset (admin)
const vapidResetting = ref(false);
const vapidResetMsg = ref("");
const vapidResetError = ref(false);
async function resetVapidKeys() {
if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
vapidResetting.value = true;
vapidResetMsg.value = "";
vapidResetError.value = false;
try {
await apiPost("/api/push/reset-vapid", {});
vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
pushStore.checkSubscription();
} catch {
vapidResetError.value = true;
vapidResetMsg.value = "Reset failed — check server logs.";
} finally {
vapidResetting.value = false;
}
}
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
@@ -369,59 +318,6 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
// Voice config (admin only)
const adminVoiceEnabled = ref(false);
const adminVoiceSttModel = ref("base.en");
const savingAdminVoice = ref(false);
const adminVoiceSaved = ref(false);
const voiceLoadingModels = ref(false);
async function saveAdminVoice() {
savingAdminVoice.value = true;
adminVoiceSaved.value = false;
try {
await apiPut("/api/admin/voice", {
voice_enabled: adminVoiceEnabled.value,
voice_stt_model: adminVoiceSttModel.value,
});
adminVoiceSaved.value = true;
setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
voiceTabLoaded.value = false;
// Always update global voice state so mic buttons appear/disappear immediately
await store.checkVoiceStatus();
if (adminVoiceEnabled.value) {
await reloadVoiceModels();
}
} finally {
savingAdminVoice.value = false;
}
}
async function reloadVoiceModels() {
voiceLoadingModels.value = true;
try {
await apiPost("/api/admin/voice/reload", {});
// Poll /api/voice/status until both STT and TTS are ready (max 3 min)
const deadline = Date.now() + 3 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2500));
try {
const status = await apiGet<{ enabled: boolean; stt: boolean; tts: boolean }>("/api/voice/status");
if (status.stt && status.tts) {
voiceStatus.value = status;
voiceTabLoaded.value = true;
// Propagate to global store so mic buttons appear everywhere
store.checkVoiceStatus();
break;
}
} catch { /* keep polling */ }
}
} catch {
toastStore.show("Failed to trigger model reload", "error");
} finally {
voiceLoadingModels.value = false;
}
}
// Search test (SearXNG)
const searxngConfigured = ref(false);
@@ -431,156 +327,6 @@ const searchResults = ref<{ url: string; title: string; snippet: string }[]>([])
const searchLoading = ref(false);
const searchError = ref("");
// Voice settings. Default voice id matches the bundled piper voice
// (en_US-amy-medium); legacy kokoro IDs in user settings were cleared
// by alembic migration 0047, so the first read after upgrade returns "".
const voiceStatus = ref<VoiceStatusResult | null>(null);
const voiceStatusLoading = ref(false);
const availableVoices = ref<VoiceEntry[]>([]);
const voiceTtsVoice = ref("en_US-amy-medium");
const voiceTtsSpeed = ref(1.0);
const voiceSpeechStyle = ref("conversational");
const savingVoice = ref(false);
const voiceSaved = ref(false);
const voiceTabLoaded = ref(false);
// Voice library (admin-only): browse + install piper voices from HF
const voiceLibrary = ref<VoiceLibraryEntry[]>([]);
const voiceLibraryLoading = ref(false);
const voiceLibraryError = ref<string | null>(null);
const voiceLibraryFilter = ref(""); // free-text language/name filter
const voiceLibraryExpanded = ref(false);
const installingVoiceIds = ref<Set<string>>(new Set());
const uninstallingVoiceIds = ref<Set<string>>(new Set());
const filteredVoiceLibrary = computed(() => {
const q = voiceLibraryFilter.value.trim().toLowerCase();
if (!q) return voiceLibrary.value;
return voiceLibrary.value.filter(v =>
v.id.toLowerCase().includes(q)
|| v.language_code.toLowerCase().includes(q)
|| v.language_name.toLowerCase().includes(q)
|| v.country.toLowerCase().includes(q)
|| v.name.toLowerCase().includes(q)
);
});
function formatVoiceSize(bytes: number): string {
if (!bytes) return "—";
const mb = bytes / (1024 * 1024);
return mb >= 10 ? `${Math.round(mb)} MB` : `${mb.toFixed(1)} MB`;
}
async function loadVoiceLibrary(refresh = false) {
if (!authStore.isAdmin) return;
voiceLibraryLoading.value = true;
voiceLibraryError.value = null;
try {
const res = await getVoiceLibrary(refresh);
voiceLibrary.value = res.voices;
} catch (e) {
voiceLibraryError.value = e instanceof Error ? e.message : "Failed to load voice library";
} finally {
voiceLibraryLoading.value = false;
}
}
async function refreshInstalledVoices() {
// Reload the active voice list (used after install/uninstall) so the
// user's voice picker sees the change without a full page reload.
try {
availableVoices.value = await getVoiceList();
} catch {
/* voice feature may be disabled — ignore */
}
}
async function installLibraryVoice(voiceId: string) {
if (installingVoiceIds.value.has(voiceId)) return;
installingVoiceIds.value.add(voiceId);
try {
await installVoice(voiceId);
toastStore.show(`Installed ${voiceId}`, "success");
await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
} catch (e) {
const msg = e instanceof Error ? e.message : "Install failed";
toastStore.show(`Failed to install ${voiceId}: ${msg}`, "error");
} finally {
installingVoiceIds.value.delete(voiceId);
}
}
async function uninstallLibraryVoice(voiceId: string) {
if (uninstallingVoiceIds.value.has(voiceId)) return;
uninstallingVoiceIds.value.add(voiceId);
try {
await uninstallVoice(voiceId);
toastStore.show(`Removed ${voiceId}`, "success");
await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
} catch (e) {
const msg = e instanceof Error ? e.message : "Uninstall failed";
toastStore.show(`Failed to remove ${voiceId}: ${msg}`, "error");
} finally {
uninstallingVoiceIds.value.delete(voiceId);
}
}
async function loadVoiceTab() {
if (voiceTabLoaded.value) return;
voiceTabLoaded.value = true;
voiceStatusLoading.value = true;
try {
voiceStatus.value = await getVoiceStatus();
if (voiceStatus.value.tts) {
availableVoices.value = await getVoiceList();
}
} catch {
// Voice feature may be disabled
} finally {
voiceStatusLoading.value = false;
}
}
const voicePreviewing = ref(false);
async function previewVoice() {
if (voicePreviewing.value) return;
voicePreviewing.value = true;
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
const ctx = new AudioContext();
try {
const blob = await synthesiseSpeech(
"Hello! I'm your assistant. How can I help you today?",
voiceTtsVoice.value,
voiceTtsSpeed.value,
);
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
} catch {
toastStore.show("Preview failed — TTS may not be ready", "error");
} finally {
voicePreviewing.value = false;
}
}
async function saveVoiceSettings() {
savingVoice.value = true;
voiceSaved.value = false;
try {
await apiPut("/api/settings", {
voice_tts_voice: voiceTtsVoice.value,
voice_tts_speed: String(voiceTtsSpeed.value),
voice_speech_style: voiceSpeechStyle.value,
});
voiceSaved.value = true;
setTimeout(() => { voiceSaved.value = false; }, 2000);
} finally {
savingVoice.value = false;
}
}
// ── Profile ──────────────────────────────────────────────────────────────────
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
@@ -592,28 +338,6 @@ const profile = ref<UserProfile>({
})
const profileSaving = ref(false)
const profileSaved = ref(false)
const consolidating = ref(false)
const clearingObs = ref(false)
const observations = ref<ProfileObservationEntry[]>([])
const observationsExpanded = ref(false)
const observationsLoading = ref(false)
const observationsLoaded = ref(false)
async function toggleObservations() {
observationsExpanded.value = !observationsExpanded.value
if (observationsExpanded.value && !observationsLoaded.value) {
observationsLoading.value = true
try {
const res = await listProfileObservations()
observations.value = res.observations
observationsLoaded.value = true
} catch {
toastStore.show('Failed to load observations', 'error')
} finally {
observationsLoading.value = false
}
}
}
async function loadProfile() {
try { profile.value = await getProfile() } catch { /* non-critical */ }
@@ -647,27 +371,6 @@ function toggleProfileWorkDay(day: string) {
profile.value.work_schedule = { ...profile.value.work_schedule, days }
}
async function onToggleCloseout(enabled: boolean) {
journalConfig.value.closeout_enabled = enabled
try {
await saveJournalConfig(journalConfig.value)
toastStore.show(enabled ? 'Nightly closeout enabled' : 'Nightly closeout disabled')
} catch {
journalConfig.value.closeout_enabled = !enabled // revert UI on failure
toastStore.show('Failed to update closeout setting', 'error')
}
}
async function runConsolidate() {
consolidating.value = true
try {
const result = await consolidateProfile()
profile.value.learned_summary = result.learned_summary
toastStore.show('Profile updated from observations')
} catch { toastStore.show('Consolidation failed', 'error') }
finally { consolidating.value = false }
}
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
// ── Journal config (locations, temp unit, prep schedule) ────────────────────
@@ -759,51 +462,19 @@ async function saveJournalCfg() {
finally { journalConfigSaving.value = false }
}
async function clearObservations() {
if (!confirm('Clear all learned observations and the generated summary? This cannot be undone.')) return
clearingObs.value = true
try {
await clearProfileObservations()
profile.value.learned_summary = ''
profile.value.observations_count = 0
profile.value.observations_updated_at = null
observations.value = []
observationsLoaded.value = false
toastStore.show('Learned data cleared')
} catch { toastStore.show('Failed to clear observations', 'error') }
finally { clearingObs.value = false }
}
onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
appVersion.value = v.version
} catch { /* non-critical */ }
await store.fetchSettings();
pushStore.checkSubscription();
assistantName.value = store.assistantName;
newEmail.value = authStore.user?.email ?? "";
// Load installed models and configured defaults
try {
const modelsData = await apiGet<{ models: string[]; default_chat_model: string }>("/api/settings/models");
installedModels.value = modelsData.models;
defaultChatModel.value = modelsData.default_chat_model;
} catch {
// Ollama unreachable — dropdowns will be empty
}
await loadOllamaModels();
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
defaultModel.value = allSettings.default_model ?? "";
backgroundModel.value = allSettings.background_model ?? "";
userTimezone.value = allSettings.user_timezone ?? "";
// Default true if unset; explicit "false" disables auto-consolidation.
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
? Number(allSettings.chat_retention_days)
: 90;
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -814,16 +485,9 @@ onMounted(async () => {
// Load user profile
await loadProfile();
// Load journal config (locations, temp unit, prep schedule)
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
await loadJournalConfig();
// Load voice settings. Voice blending was removed with the kokoro→piper
// migration (piper has no blend equivalent); legacy voice_tts_blend rows
// were dropped in migration 0047.
if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
if (allSettings.voice_speech_style) voiceSpeechStyle.value = allSettings.voice_speech_style;
// Load CalDAV settings
try {
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
@@ -855,13 +519,6 @@ onMounted(async () => {
} catch {
// base URL not configured yet
}
try {
const vc = await apiGet<Record<string, string>>("/api/admin/voice");
adminVoiceEnabled.value = vc.voice_enabled === "true";
adminVoiceSttModel.value = vc.voice_stt_model ?? "base.en";
} catch {
// voice not configured yet
}
}
_loadTabContent(activeTab.value);
});
@@ -928,105 +585,6 @@ async function changePassword() {
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const gb = bytes / 1e9;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
return `${(bytes / 1e6).toFixed(0)} MB`;
}
async function loadOllamaModels() {
try {
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
ollamaModels.value = data.models ?? [];
installedModels.value = ollamaModels.value.map((m) => m.name);
} catch {
// Ollama unreachable
}
}
async function pullModel() {
const name = pullModelName.value.trim();
if (!name || pulling.value) return;
pulling.value = true;
pullProgress.value = { status: "Connecting…", pct: null };
try {
const resp = await fetch("/api/chat/models/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: name }),
credentials: "include",
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/^data: /, "").trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed);
if (msg.error) throw new Error(msg.error);
if (msg.status === "success") {
pullProgress.value = { status: "Complete", pct: 100 };
pullModelName.value = "";
} else if (msg.total && msg.completed) {
const pct = Math.round((msg.completed / msg.total) * 100);
pullProgress.value = { status: msg.status || "Downloading…", pct };
} else {
pullProgress.value = { status: msg.status || "Working…", pct: null };
}
} catch (parseErr: any) {
if (parseErr.message !== "JSON parse error") throw parseErr;
}
}
}
await loadOllamaModels();
toastStore.show(`Model ${name} ready`);
} catch (err: any) {
toastStore.show(`Pull failed: ${err.message}`, "error");
} finally {
pulling.value = false;
setTimeout(() => { pullProgress.value = null; }, 3000);
}
}
async function deleteModel(name: string) {
deletingModel.value = name;
try {
await apiPost("/api/chat/models/delete", { model: name });
await loadOllamaModels();
if (defaultModel.value === name) defaultModel.value = "";
toastStore.show(`Deleted ${name}`);
} catch {
toastStore.show(`Failed to delete ${name}`, "error");
} finally {
deletingModel.value = null;
}
}
async function saveAssistant() {
saving.value = true;
saved.value = false;
try {
await store.updateSettings({
assistant_name: assistantName.value.trim() || "Fable",
default_model: defaultModel.value,
background_model: backgroundModel.value,
});
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} finally {
saving.value = false;
}
}
async function exportData(scope: "user" | "full") {
exporting.value = true;
try {
@@ -2250,50 +1808,6 @@ function formatUserDate(iso: string): string {
</div>
</section>
<section class="settings-section full-width">
<h2>Voice (Speech-to-Speech)</h2>
<p class="section-desc">
Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS).
Save settings then click "Reload models" to apply without restarting the server.
Install dependencies first: <code>pip install faster-whisper kokoro soundfile</code>
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="adminVoiceEnabled" />
Enable Voice
</label>
<p class="field-hint">Shows the PTT button and voice settings to all users.</p>
</div>
<div v-if="adminVoiceEnabled" class="field" style="max-width:280px; margin-top:0.75rem;">
<label for="stt-model">STT Model</label>
<select id="stt-model" v-model="adminVoiceSttModel" class="input">
<option value="tiny.en">tiny.en fastest, lowest accuracy (~75 MB)</option>
<option value="base.en">base.en balanced (recommended, ~145 MB)</option>
<option value="small.en">small.en better accuracy (~465 MB)</option>
<option value="medium.en">medium.en best accuracy (~1.5 GB)</option>
</select>
<p class="field-hint">Larger models are more accurate but use more RAM and take longer to load.</p>
</div>
<div class="actions" style="margin-top:0.85rem;">
<button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice || voiceLoadingModels">
{{ savingAdminVoice ? 'Saving…' : adminVoiceSaved ? 'Saved ✓' : 'Save' }}
</button>
<button
v-if="!voiceLoadingModels && adminVoiceEnabled"
class="btn-secondary"
@click="reloadVoiceModels"
:disabled="voiceLoadingModels"
title="Reload models without restarting the server"
>Reload models</button>
<span v-if="voiceLoadingModels" class="field-hint">
Loading models this may take a minute.
<span class="voice-admin-spinner">
<span></span><span></span><span></span>
</span>
</span>
</div>
</section>
</div>
<!-- Users -->