The separate intent model (OLLAMA_INTENT_MODEL / qwen2.5:7b) is removed from every part of the system. All classification now uses the primary model. Changes: - config.py: remove OLLAMA_INTENT_MODEL - intent.py: remove classify_intent() and all supporting infrastructure (_SYSTEM_PROMPT_TEMPLATE, _RESEARCH_PREFIX, _PRIOR_WORK_REFS); file now only contains the quick-capture classifier - quick_capture.py: classify_capture_intent() now called with Config.OLLAMA_MODEL - generation_task.py: remove intent_model_setting DB lookup and get_setting import; history summarization and research pipeline use the primary model directly - research.py: remove intent_model parameter from run_research_pipeline() and _generate_sub_queries(); both use the model param throughout - routes/settings.py: remove intent_model from model-key validation and response - app.py: remove intent model pre-warming at startup - SettingsView.vue: remove Intent Model selector and related refs/state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1039 lines
32 KiB
Vue
1039 lines
32 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from "vue";
|
|
import { useSettingsStore } from "@/stores/settings";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import { apiGet, apiPost, apiPut } from "@/api/client";
|
|
|
|
const store = useSettingsStore();
|
|
const authStore = useAuthStore();
|
|
const toastStore = useToastStore();
|
|
const assistantName = ref("");
|
|
const defaultModel = ref("");
|
|
const installedModels = ref<string[]>([]);
|
|
const defaultChatModel = ref("");
|
|
const newEmail = ref("");
|
|
const emailPassword = ref("");
|
|
const changingEmail = ref(false);
|
|
const currentPassword = ref("");
|
|
const newPassword = ref("");
|
|
const confirmNewPassword = ref("");
|
|
const changingPassword = ref(false);
|
|
const saving = ref(false);
|
|
const saved = ref(false);
|
|
const exporting = ref(false);
|
|
const restoring = ref(false);
|
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
|
|
|
// Notification preferences
|
|
const notifyTaskReminders = ref(true);
|
|
const notifySecurityAlerts = ref(true);
|
|
const savingNotifications = ref(false);
|
|
const notificationsSaved = ref(false);
|
|
|
|
// CalDAV settings (per-user)
|
|
const caldav = ref({
|
|
caldav_url: "",
|
|
caldav_username: "",
|
|
caldav_password: "",
|
|
caldav_calendar_name: "",
|
|
});
|
|
const savingCaldav = ref(false);
|
|
const caldavSaved = ref(false);
|
|
const testingCaldav = ref(false);
|
|
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);
|
|
|
|
// SMTP settings (admin only)
|
|
const smtp = ref({
|
|
smtp_host: "",
|
|
smtp_port: "587",
|
|
smtp_username: "",
|
|
smtp_password: "",
|
|
smtp_from_address: "",
|
|
smtp_from_name: "Fabled Assistant",
|
|
smtp_use_tls: "true",
|
|
});
|
|
const savingSmtp = ref(false);
|
|
const smtpSaved = ref(false);
|
|
const testRecipient = ref("");
|
|
const sendingTest = ref(false);
|
|
|
|
// Base URL setting (admin only)
|
|
const baseUrl = ref("");
|
|
const savingBaseUrl = ref(false);
|
|
const baseUrlSaved = ref(false);
|
|
|
|
// Search test (SearXNG)
|
|
const searxngConfigured = ref(false);
|
|
const searxngUrl = ref("");
|
|
const searchQuery = ref("");
|
|
const searchResults = ref<{ url: string; title: string; snippet: string }[]>([]);
|
|
const searchLoading = ref(false);
|
|
const searchError = ref("");
|
|
|
|
onMounted(async () => {
|
|
await store.fetchSettings();
|
|
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
|
|
}
|
|
|
|
// Load notification preferences from user settings
|
|
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
|
defaultModel.value = allSettings.default_model ?? "";
|
|
if (allSettings.notify_task_reminders !== undefined) {
|
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
|
}
|
|
if (allSettings.notify_security_alerts !== undefined) {
|
|
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
|
}
|
|
|
|
// Load CalDAV settings
|
|
try {
|
|
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
|
|
caldav.value = { ...caldav.value, ...caldavConfig };
|
|
} catch {
|
|
// CalDAV not configured yet
|
|
}
|
|
|
|
// Check SearXNG status
|
|
try {
|
|
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
|
searxngConfigured.value = sr.configured;
|
|
searxngUrl.value = sr.searxng_url;
|
|
} catch {
|
|
searxngConfigured.value = false;
|
|
}
|
|
|
|
// Load admin settings
|
|
if (authStore.isAdmin) {
|
|
try {
|
|
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
|
|
smtp.value = { ...smtp.value, ...smtpConfig };
|
|
} catch {
|
|
// SMTP not configured yet
|
|
}
|
|
try {
|
|
const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url");
|
|
baseUrl.value = urlConfig.base_url;
|
|
} catch {
|
|
// base URL not configured yet
|
|
}
|
|
}
|
|
});
|
|
|
|
async function changeEmail() {
|
|
changingEmail.value = true;
|
|
try {
|
|
const body: Record<string, string> = { email: newEmail.value.trim() };
|
|
if (authStore.user?.has_password) {
|
|
body.password = emailPassword.value;
|
|
}
|
|
const updated = await apiPut<import("@/types/auth").User>("/api/auth/email", body);
|
|
authStore.user = updated;
|
|
emailPassword.value = "";
|
|
toastStore.show("Email updated successfully");
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const b = (e as { body?: { error?: string } }).body;
|
|
toastStore.show(b?.error || "Failed to update email", "error");
|
|
} else {
|
|
toastStore.show("Failed to update email", "error");
|
|
}
|
|
} finally {
|
|
changingEmail.value = false;
|
|
}
|
|
}
|
|
|
|
async function changePassword() {
|
|
if (newPassword.value !== confirmNewPassword.value) {
|
|
toastStore.show("New passwords do not match", "error");
|
|
return;
|
|
}
|
|
changingPassword.value = true;
|
|
try {
|
|
await apiPut("/api/auth/password", {
|
|
current_password: currentPassword.value,
|
|
new_password: newPassword.value,
|
|
});
|
|
toastStore.show("Password changed successfully");
|
|
currentPassword.value = "";
|
|
newPassword.value = "";
|
|
confirmNewPassword.value = "";
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const body = (e as { body?: { error?: string } }).body;
|
|
toastStore.show(body?.error || "Failed to change password", "error");
|
|
} else {
|
|
toastStore.show("Failed to change password", "error");
|
|
}
|
|
} finally {
|
|
changingPassword.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveAssistant() {
|
|
saving.value = true;
|
|
saved.value = false;
|
|
try {
|
|
await store.updateSettings({
|
|
assistant_name: assistantName.value.trim() || "Fable",
|
|
default_model: defaultModel.value,
|
|
});
|
|
saved.value = true;
|
|
setTimeout(() => (saved.value = false), 2000);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function exportData(scope: "user" | "full") {
|
|
exporting.value = true;
|
|
try {
|
|
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
|
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
|
}
|
|
const data = await res.json();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
toastStore.show("Backup downloaded");
|
|
} catch (e) {
|
|
toastStore.show("Export failed: " + (e as Error).message, "error");
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
function triggerRestoreUpload() {
|
|
restoreFileInput.value?.click();
|
|
}
|
|
|
|
async function saveNotifications() {
|
|
savingNotifications.value = true;
|
|
notificationsSaved.value = false;
|
|
try {
|
|
await apiPut("/api/settings", {
|
|
notify_task_reminders: notifyTaskReminders.value ? "true" : "false",
|
|
notify_security_alerts: notifySecurityAlerts.value ? "true" : "false",
|
|
});
|
|
notificationsSaved.value = true;
|
|
setTimeout(() => (notificationsSaved.value = false), 2000);
|
|
} catch {
|
|
toastStore.show("Failed to save notification preferences", "error");
|
|
} finally {
|
|
savingNotifications.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveCaldav() {
|
|
savingCaldav.value = true;
|
|
caldavSaved.value = false;
|
|
try {
|
|
await apiPut("/api/settings/caldav", caldav.value);
|
|
caldavSaved.value = true;
|
|
setTimeout(() => (caldavSaved.value = false), 2000);
|
|
} catch {
|
|
toastStore.show("Failed to save CalDAV settings", "error");
|
|
} finally {
|
|
savingCaldav.value = false;
|
|
}
|
|
}
|
|
|
|
async function testCaldav() {
|
|
testingCaldav.value = true;
|
|
caldavTestResult.value = null;
|
|
try {
|
|
const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
|
|
caldavTestResult.value = result;
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const body = (e as { body?: { error?: string } }).body;
|
|
caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
|
|
} else {
|
|
caldavTestResult.value = { success: false, error: "Connection test failed" };
|
|
}
|
|
} finally {
|
|
testingCaldav.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveSmtp() {
|
|
savingSmtp.value = true;
|
|
smtpSaved.value = false;
|
|
try {
|
|
await apiPut("/api/admin/smtp", smtp.value);
|
|
smtpSaved.value = true;
|
|
setTimeout(() => (smtpSaved.value = false), 2000);
|
|
} catch {
|
|
toastStore.show("Failed to save SMTP settings", "error");
|
|
} finally {
|
|
savingSmtp.value = false;
|
|
}
|
|
}
|
|
|
|
async function sendTestEmail() {
|
|
if (!testRecipient.value.trim()) {
|
|
toastStore.show("Enter a recipient email address", "error");
|
|
return;
|
|
}
|
|
sendingTest.value = true;
|
|
try {
|
|
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
|
|
toastStore.show("Test email sent successfully");
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const body = (e as { body?: { error?: string } }).body;
|
|
toastStore.show(body?.error || "Failed to send test email", "error");
|
|
} else {
|
|
toastStore.show("Failed to send test email", "error");
|
|
}
|
|
} finally {
|
|
sendingTest.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveBaseUrl() {
|
|
savingBaseUrl.value = true;
|
|
baseUrlSaved.value = false;
|
|
try {
|
|
await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
|
|
baseUrlSaved.value = true;
|
|
setTimeout(() => (baseUrlSaved.value = false), 2000);
|
|
} catch {
|
|
toastStore.show("Failed to save application URL", "error");
|
|
} finally {
|
|
savingBaseUrl.value = false;
|
|
}
|
|
}
|
|
|
|
async function handleRestoreFile(event: Event) {
|
|
const file = (event.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
restoring.value = true;
|
|
try {
|
|
const text = await file.text();
|
|
const data = JSON.parse(text);
|
|
const res = await fetch("/api/admin/restore", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
|
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
|
}
|
|
const result = await res.json();
|
|
toastStore.show(
|
|
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
|
|
);
|
|
} catch (e) {
|
|
toastStore.show("Restore failed: " + (e as Error).message, "error");
|
|
} finally {
|
|
restoring.value = false;
|
|
if (restoreFileInput.value) restoreFileInput.value.value = "";
|
|
}
|
|
}
|
|
|
|
async function testSearch() {
|
|
const q = searchQuery.value.trim();
|
|
if (!q) return;
|
|
searchLoading.value = true;
|
|
searchError.value = "";
|
|
searchResults.value = [];
|
|
try {
|
|
const data = await apiGet<{ configured: boolean; results: { url: string; title: string; snippet: string }[]; error?: string }>(
|
|
`/api/settings/search?q=${encodeURIComponent(q)}`
|
|
);
|
|
if (!data.configured) {
|
|
searchError.value = "SearXNG is not configured — set SEARXNG_URL in docker-compose.";
|
|
} else {
|
|
searchResults.value = data.results;
|
|
if (!data.results.length) searchError.value = "No results found.";
|
|
}
|
|
} catch {
|
|
searchError.value = "Search request failed.";
|
|
} finally {
|
|
searchLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function onSearchKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Enter") testSearch();
|
|
}
|
|
|
|
function hostname(url: string): string {
|
|
try { return new URL(url).hostname; } catch { return url; }
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="settings-page">
|
|
<h1>Settings</h1>
|
|
|
|
<div class="settings-grid">
|
|
|
|
<!-- ── Assistant ── full width ── -->
|
|
<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 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">Model used for new conversations.</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>
|
|
|
|
<!-- ── Email Address ── half width ── -->
|
|
<section class="settings-section">
|
|
<h2>Email Address</h2>
|
|
<p class="section-desc">Used for password resets and notifications.</p>
|
|
<div class="field">
|
|
<label for="new-email">Email</label>
|
|
<input
|
|
id="new-email"
|
|
v-model="newEmail"
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div v-if="authStore.user?.has_password" class="field">
|
|
<label for="email-password">Current Password</label>
|
|
<input
|
|
id="email-password"
|
|
v-model="emailPassword"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
class="input"
|
|
/>
|
|
<p class="field-hint">Required to confirm the change.</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button
|
|
class="btn-save"
|
|
@click="changeEmail"
|
|
:disabled="changingEmail || (authStore.user?.has_password && !emailPassword)"
|
|
>
|
|
{{ changingEmail ? "Saving..." : "Save Email" }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Change Password ── half width ── -->
|
|
<section class="settings-section">
|
|
<h2>Change Password</h2>
|
|
<div class="field">
|
|
<label for="current-password">Current Password</label>
|
|
<input
|
|
id="current-password"
|
|
v-model="currentPassword"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div class="field">
|
|
<label for="new-password">New Password</label>
|
|
<input
|
|
id="new-password"
|
|
v-model="newPassword"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
class="input"
|
|
/>
|
|
<p class="field-hint">Must be at least 8 characters</p>
|
|
</div>
|
|
<div class="field">
|
|
<label for="confirm-new-password">Confirm New Password</label>
|
|
<input
|
|
id="confirm-new-password"
|
|
v-model="confirmNewPassword"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
class="input"
|
|
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
|
|
/>
|
|
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
|
|
Passwords do not match
|
|
</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button
|
|
class="btn-save"
|
|
@click="changePassword"
|
|
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
|
>
|
|
{{ changingPassword ? "Changing..." : "Change Password" }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Notifications ── half width ── -->
|
|
<section class="settings-section">
|
|
<h2>Notifications</h2>
|
|
<p class="section-desc">
|
|
Email notifications when SMTP is configured by an admin.
|
|
</p>
|
|
<div class="checkbox-field">
|
|
<label>
|
|
<input type="checkbox" v-model="notifyTaskReminders" />
|
|
Task due date reminders
|
|
</label>
|
|
<p class="field-hint">Daily email for tasks due or overdue.</p>
|
|
</div>
|
|
<div class="checkbox-field">
|
|
<label>
|
|
<input type="checkbox" v-model="notifySecurityAlerts" />
|
|
Security alerts
|
|
</label>
|
|
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn-save" @click="saveNotifications" :disabled="savingNotifications">
|
|
{{ savingNotifications ? "Saving..." : "Save" }}
|
|
</button>
|
|
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Data ── half width ── -->
|
|
<section class="settings-section">
|
|
<h2>Data</h2>
|
|
<p class="section-desc">Export your data or restore from a backup.</p>
|
|
<div class="data-actions">
|
|
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
|
|
{{ exporting ? "Exporting..." : "Export My Data" }}
|
|
</button>
|
|
<template v-if="authStore.isAdmin">
|
|
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
|
|
{{ exporting ? "Exporting..." : "Full Backup" }}
|
|
</button>
|
|
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
|
|
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
|
</button>
|
|
<input
|
|
ref="restoreFileInput"
|
|
type="file"
|
|
accept=".json"
|
|
class="hidden-file-input"
|
|
@change="handleRestoreFile"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── CalDAV ── full width ── -->
|
|
<section class="settings-section full-width">
|
|
<h2>Calendar (CalDAV)</h2>
|
|
<p class="section-desc">
|
|
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
|
|
</p>
|
|
<div class="caldav-grid">
|
|
<div class="field">
|
|
<label for="caldav-url">CalDAV URL</label>
|
|
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="caldav-username">Username</label>
|
|
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="caldav-password">Password</label>
|
|
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="caldav-calendar-name">Calendar Name</label>
|
|
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
|
|
<p class="field-hint">Optional. Exact calendar name to use.</p>
|
|
</div>
|
|
</div>
|
|
<div class="actions" style="margin-bottom: 1rem;">
|
|
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
|
|
{{ savingCaldav ? "Saving..." : "Save" }}
|
|
</button>
|
|
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
|
|
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
|
|
{{ testingCaldav ? "Testing..." : "Test Connection" }}
|
|
</button>
|
|
</div>
|
|
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
|
|
<template v-if="caldavTestResult.success">
|
|
{{ caldavTestResult.message }}
|
|
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
|
|
Calendars: {{ caldavTestResult.calendars.join(", ") }}
|
|
</div>
|
|
</template>
|
|
<template v-else>{{ caldavTestResult.error }}</template>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Web Search ── full width ── -->
|
|
<section class="settings-section full-width">
|
|
<h2>Web Search (SearXNG)</h2>
|
|
<template v-if="searxngConfigured">
|
|
<p class="section-desc">
|
|
Connected to <code class="url-chip">{{ searxngUrl }}</code>.
|
|
Test a query below to verify results and rate limiting.
|
|
</p>
|
|
<div class="search-row">
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
class="input"
|
|
placeholder="Enter a search query..."
|
|
@keydown="onSearchKeydown"
|
|
/>
|
|
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
|
{{ searchLoading ? "Searching..." : "Search" }}
|
|
</button>
|
|
</div>
|
|
<p v-if="searchError" class="search-error">{{ searchError }}</p>
|
|
<ul v-if="searchResults.length" class="search-results">
|
|
<li v-for="(r, i) in searchResults" :key="i" class="search-result">
|
|
<div class="result-header">
|
|
<a :href="r.url" target="_blank" rel="noopener noreferrer" class="result-title">{{ r.title || r.url }}</a>
|
|
<span class="result-host">{{ hostname(r.url) }}</span>
|
|
</div>
|
|
<p v-if="r.snippet" class="result-snippet">{{ r.snippet }}</p>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
<template v-else>
|
|
<p class="section-desc not-configured">
|
|
Not configured. Set <code>SEARXNG_URL</code> in docker-compose to enable web research from chat.
|
|
</p>
|
|
</template>
|
|
</section>
|
|
|
|
<!-- ── Application URL (admin) ── full width ── -->
|
|
<section v-if="authStore.isAdmin" class="settings-section full-width">
|
|
<h2>Application URL</h2>
|
|
<p class="section-desc">
|
|
Public URL used in email links (invitations, password resets). Example: https://notes.example.com
|
|
</p>
|
|
<div class="field url-field">
|
|
<label for="base-url">Base URL</label>
|
|
<input
|
|
id="base-url"
|
|
v-model="baseUrl"
|
|
type="url"
|
|
placeholder="https://notes.example.com"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
|
|
{{ savingBaseUrl ? "Saving..." : "Save" }}
|
|
</button>
|
|
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── SMTP (admin) ── full width ── -->
|
|
<section v-if="authStore.isAdmin" class="settings-section full-width">
|
|
<h2>Email / SMTP</h2>
|
|
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
|
|
<div class="smtp-grid">
|
|
<div class="field">
|
|
<label for="smtp-host">SMTP Host</label>
|
|
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="smtp-port">Port</label>
|
|
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="smtp-username">Username</label>
|
|
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="smtp-password">Password</label>
|
|
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="smtp-from-address">From Address</label>
|
|
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="input" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="smtp-from-name">From Name</label>
|
|
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Assistant" class="input" />
|
|
</div>
|
|
</div>
|
|
<div class="checkbox-field">
|
|
<label>
|
|
<input type="checkbox" :checked="smtp.smtp_use_tls === 'true'" @change="smtp.smtp_use_tls = ($event.target as HTMLInputElement).checked ? 'true' : 'false'" />
|
|
Use STARTTLS
|
|
</label>
|
|
<p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
|
|
</div>
|
|
<div class="actions" style="margin-bottom: 1.25rem;">
|
|
<button class="btn-save" @click="saveSmtp" :disabled="savingSmtp">
|
|
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
|
|
</button>
|
|
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
|
|
</div>
|
|
<div class="test-email-section">
|
|
<h3 class="subsection-label">Test Email</h3>
|
|
<div class="test-email-row">
|
|
<input
|
|
v-model="testRecipient"
|
|
type="email"
|
|
placeholder="test@example.com"
|
|
class="input"
|
|
/>
|
|
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
|
{{ sendingTest ? "Sending..." : "Send Test" }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.settings-page {
|
|
max-width: 1100px;
|
|
margin: 2rem auto;
|
|
padding: 0 1.5rem;
|
|
}
|
|
.settings-page h1 {
|
|
margin: 0 0 1.5rem;
|
|
}
|
|
|
|
/* Two-column grid — small sections pair up, full-width sections span both */
|
|
.settings-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.25rem;
|
|
align-items: start;
|
|
}
|
|
.settings-section {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1.25rem;
|
|
}
|
|
.settings-section.full-width {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.settings-section h2 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1.05rem;
|
|
}
|
|
.section-desc {
|
|
margin: 0 0 1rem;
|
|
font-size: 0.875rem;
|
|
color: var(--color-text-secondary);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Assistant — 3-col internal grid */
|
|
.assistant-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 0 1rem;
|
|
}
|
|
@media (max-width: 700px) {
|
|
.assistant-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.field {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.field label {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.35rem;
|
|
color: var(--color-text);
|
|
}
|
|
.input {
|
|
width: 100%;
|
|
padding: 0.45rem 0.7rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.9rem;
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
box-sizing: border-box;
|
|
font-family: inherit;
|
|
}
|
|
.input:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
}
|
|
.field-hint {
|
|
margin: 0.3rem 0 0;
|
|
font-size: 0.78rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.btn-save {
|
|
padding: 0.4rem 0.9rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
white-space: nowrap;
|
|
}
|
|
.btn-save:disabled { opacity: 0.6; cursor: default; }
|
|
.btn-save:hover:not(:disabled) { opacity: 0.9; }
|
|
|
|
.btn-secondary {
|
|
padding: 0.4rem 0.9rem;
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
white-space: nowrap;
|
|
}
|
|
.btn-secondary:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
|
|
.btn-warn:hover:not(:disabled) {
|
|
border-color: var(--color-warning);
|
|
color: var(--color-warning);
|
|
}
|
|
|
|
.saved-msg {
|
|
color: var(--color-success);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
.input-error { border-color: var(--color-danger); }
|
|
.input-error:focus { border-color: var(--color-danger); }
|
|
.error-hint {
|
|
margin: 0.3rem 0 0;
|
|
font-size: 0.78rem;
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
/* Data buttons */
|
|
.data-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.hidden-file-input { display: none; }
|
|
|
|
/* Checkboxes */
|
|
.checkbox-field { margin-bottom: 1rem; }
|
|
.checkbox-field label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.9rem;
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
}
|
|
.checkbox-field input[type="checkbox"] {
|
|
width: 16px;
|
|
height: 16px;
|
|
accent-color: var(--color-primary);
|
|
}
|
|
|
|
/* CalDAV grid */
|
|
.caldav-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
gap: 0 1rem;
|
|
}
|
|
@media (max-width: 700px) {
|
|
.caldav-grid { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.caldav-grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
.test-result {
|
|
padding: 0.65rem 0.9rem;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.875rem;
|
|
line-height: 1.4;
|
|
}
|
|
.test-result.success {
|
|
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
|
|
border: 1px solid var(--color-success);
|
|
color: var(--color-success);
|
|
}
|
|
.test-result.error {
|
|
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
|
|
border: 1px solid var(--color-danger);
|
|
color: var(--color-danger);
|
|
}
|
|
.test-calendars {
|
|
margin-top: 0.3rem;
|
|
font-size: 0.82rem;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* Search test */
|
|
.url-chip {
|
|
font-size: 0.8rem;
|
|
padding: 0.1rem 0.4rem;
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 4px;
|
|
}
|
|
.not-configured {
|
|
font-style: italic;
|
|
opacity: 0.75;
|
|
}
|
|
.search-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.search-row .input { flex: 1; }
|
|
.search-error {
|
|
font-size: 0.875rem;
|
|
color: var(--color-danger);
|
|
margin: 0 0 0.5rem;
|
|
}
|
|
.search-results {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
.search-result {
|
|
padding: 0.65rem 0.85rem;
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.result-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.result-title {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: var(--color-primary);
|
|
text-decoration: none;
|
|
word-break: break-word;
|
|
}
|
|
.result-title:hover { text-decoration: underline; }
|
|
.result-host {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
.result-snippet {
|
|
margin: 0;
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-secondary);
|
|
line-height: 1.45;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Application URL — constrain input width */
|
|
.url-field { max-width: 480px; }
|
|
|
|
/* SMTP grid */
|
|
.smtp-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 0 1rem;
|
|
}
|
|
@media (max-width: 700px) {
|
|
.smtp-grid { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.smtp-grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
/* Test email */
|
|
.subsection-label {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.test-email-section {
|
|
border-top: 1px solid var(--color-border);
|
|
padding-top: 1rem;
|
|
}
|
|
.test-email-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
max-width: 480px;
|
|
}
|
|
|
|
/* Responsive — collapse to 1 column on narrow screens */
|
|
@media (max-width: 640px) {
|
|
.settings-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.settings-section.full-width {
|
|
grid-column: 1;
|
|
}
|
|
.settings-page {
|
|
padding: 0 1rem;
|
|
margin: 1rem auto;
|
|
}
|
|
}
|
|
</style>
|