fix(svelte): replace $effect with onMount for side effects
$effect runs during SSR and re-runs on every reactive dependency change, causing polling loops and URL reads to fire at the wrong time. onMount runs once on the client after first render, which is the correct lifecycle for polling, URL param reads, and async data loads. - IndexingProgress: polling loop now starts on mount, not on reactive trigger - search/+page.svelte: URL param init moved to onMount; use window.location directly instead of the page store to avoid reactive re-runs - settings/+page.svelte: config load and local provider probe moved to onMount Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import StatBadge from '$lib/components/StatBadge.svelte';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -43,15 +44,16 @@
|
||||
let saving = $state(false);
|
||||
let saveStatus = $state<'idle' | 'ok' | 'error'>('idle');
|
||||
let saveError = $state<string | null>(null);
|
||||
let saveStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let localAvailable = $state<boolean | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load current config + probe local provider on mount (Svelte 5 $effect)
|
||||
// Load current config + probe local provider on mount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
@@ -75,12 +77,11 @@
|
||||
|
||||
// Probe whether the local provider is available
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings/embedding/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'local' })
|
||||
});
|
||||
if (!cancelled) localAvailable = res.ok;
|
||||
const res = await fetch('/api/v1/settings/embedding/test');
|
||||
if (!cancelled && res.ok) {
|
||||
const data = await res.json();
|
||||
localAvailable = data.available ?? false;
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) localAvailable = false;
|
||||
}
|
||||
@@ -88,19 +89,10 @@
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (saveStatusTimer) clearTimeout(saveStatusTimer);
|
||||
};
|
||||
});
|
||||
|
||||
// Auto-dismiss save success banner after 3 seconds
|
||||
$effect(() => {
|
||||
if (saveStatus === 'ok') {
|
||||
const timer = setTimeout(() => {
|
||||
saveStatus = 'idle';
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,6 +143,11 @@
|
||||
});
|
||||
if (res.ok) {
|
||||
saveStatus = 'ok';
|
||||
if (saveStatusTimer) clearTimeout(saveStatusTimer);
|
||||
saveStatusTimer = setTimeout(() => {
|
||||
saveStatus = 'idle';
|
||||
saveStatusTimer = null;
|
||||
}, 3000);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
saveStatus = 'error';
|
||||
@@ -163,6 +160,11 @@
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -184,182 +186,199 @@
|
||||
{#if loading}
|
||||
<p class="text-sm text-gray-400">Loading current configuration…</p>
|
||||
{:else}
|
||||
<!-- Provider selector -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each ['none', 'openai', 'local'] as p}
|
||||
<button
|
||||
onclick={() => {
|
||||
provider = p as 'none' | 'openai' | 'local';
|
||||
testStatus = 'idle';
|
||||
testError = null;
|
||||
}}
|
||||
class={[
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
provider === p
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
].join(' ')}
|
||||
>
|
||||
{p === 'none' ? 'None (FTS5 only)' : p === 'openai' ? 'OpenAI-compatible' : 'Local Model'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- None warning -->
|
||||
{#if provider === 'none'}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Search will use keyword matching only. Results may be less relevant for complex questions.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- OpenAI-compatible form -->
|
||||
{#if provider === 'openai'}
|
||||
<div class="space-y-3">
|
||||
<!-- Preset buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each PROVIDER_PRESETS as preset}
|
||||
<button
|
||||
onclick={() => applyPreset(preset)}
|
||||
class="rounded border border-gray-200 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700">Base URL</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={baseUrl}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700">API Key</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={apiKey}
|
||||
placeholder="sk-…"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700">Model</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={model}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700">Dimensions (optional override)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={dimensions}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Test connection row -->
|
||||
<div class="flex items-center gap-3">
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<!-- Provider selector -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each ['none', 'openai', 'local'] as p}
|
||||
<button
|
||||
onclick={testConnection}
|
||||
disabled={testStatus === 'testing'}
|
||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
provider = p as 'none' | 'openai' | 'local';
|
||||
testStatus = 'idle';
|
||||
testError = null;
|
||||
}}
|
||||
class={[
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
provider === p
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
].join(' ')}
|
||||
>
|
||||
{testStatus === 'testing' ? 'Testing…' : 'Test Connection'}
|
||||
{p === 'none' ? 'None (FTS5 only)' : p === 'openai' ? 'OpenAI-compatible' : 'Local Model'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if testStatus === 'ok'}
|
||||
<span class="text-sm text-green-600">
|
||||
Connection successful
|
||||
{#if testDimensions}— {testDimensions} dimensions{/if}
|
||||
</span>
|
||||
{:else if testStatus === 'error'}
|
||||
<span class="text-sm text-red-600">
|
||||
{testError}
|
||||
</span>
|
||||
<!-- None warning -->
|
||||
{#if provider === 'none'}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Search will use keyword matching only. Results may be less relevant for complex questions.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- OpenAI-compatible form -->
|
||||
{#if provider === 'openai'}
|
||||
<div class="space-y-3">
|
||||
<!-- Preset buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each PROVIDER_PRESETS as preset}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => applyPreset(preset)}
|
||||
class="rounded border border-gray-200 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="block" for="embedding-base-url">
|
||||
<span class="text-sm font-medium text-gray-700">Base URL</span>
|
||||
<input
|
||||
id="embedding-base-url"
|
||||
name="baseUrl"
|
||||
type="text"
|
||||
autocomplete="url"
|
||||
bind:value={baseUrl}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-api-key">
|
||||
<span class="text-sm font-medium text-gray-700">API Key</span>
|
||||
<input
|
||||
id="embedding-api-key"
|
||||
name="apiKey"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
bind:value={apiKey}
|
||||
placeholder="sk-…"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-model">
|
||||
<span class="text-sm font-medium text-gray-700">Model</span>
|
||||
<input
|
||||
id="embedding-model"
|
||||
name="model"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={model}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-dimensions">
|
||||
<span class="text-sm font-medium text-gray-700">Dimensions (optional override)</span>
|
||||
<input
|
||||
id="embedding-dimensions"
|
||||
name="dimensions"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
bind:value={dimensions}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Test connection row -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={testConnection}
|
||||
disabled={testStatus === 'testing'}
|
||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{testStatus === 'testing' ? 'Testing…' : 'Test Connection'}
|
||||
</button>
|
||||
|
||||
{#if testStatus === 'ok'}
|
||||
<span class="text-sm text-green-600">
|
||||
Connection successful
|
||||
{#if testDimensions}— {testDimensions} dimensions{/if}
|
||||
</span>
|
||||
{:else if testStatus === 'error'}
|
||||
<span class="text-sm text-red-600">
|
||||
{testError}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Local model section -->
|
||||
{#if provider === 'local'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm">
|
||||
<p class="font-medium text-gray-800">Local ONNX model via @xenova/transformers</p>
|
||||
<p class="mt-1 text-gray-500">Model: Xenova/all-MiniLM-L6-v2 · 384 dimensions</p>
|
||||
{#if localAvailable === null}
|
||||
<p class="mt-2 text-gray-400">Checking availability…</p>
|
||||
{:else if localAvailable}
|
||||
<p class="mt-2 text-green-600">@xenova/transformers is installed and ready.</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-amber-700">
|
||||
@xenova/transformers is not installed. Run
|
||||
<code class="rounded bg-amber-100 px-1 py-0.5 font-mono text-xs"
|
||||
>npm install @xenova/transformers</code
|
||||
>
|
||||
to enable local embeddings.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Local model section -->
|
||||
{#if provider === 'local'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm">
|
||||
<p class="font-medium text-gray-800">Local ONNX model via @xenova/transformers</p>
|
||||
<p class="mt-1 text-gray-500">Model: Xenova/all-MiniLM-L6-v2 · 384 dimensions</p>
|
||||
{#if localAvailable === null}
|
||||
<p class="mt-2 text-gray-400">Checking availability…</p>
|
||||
{:else if localAvailable}
|
||||
<p class="mt-2 text-green-600">@xenova/transformers is installed and ready.</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-amber-700">
|
||||
@xenova/transformers is not installed. Run
|
||||
<code class="rounded bg-amber-100 px-1 py-0.5 font-mono text-xs"
|
||||
>npm install @xenova/transformers</code
|
||||
>
|
||||
to enable local embeddings.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Save feedback banners -->
|
||||
{#if saveStatus === 'ok'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
<!-- Save feedback banners -->
|
||||
{#if saveStatus === 'ok'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully.
|
||||
</div>
|
||||
{:else if saveStatus === 'error'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully.
|
||||
</div>
|
||||
{:else if saveStatus === 'error'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{saveError}
|
||||
</div>
|
||||
{/if}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{saveError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Save row -->
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<button
|
||||
onclick={save}
|
||||
disabled={saving}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Save row -->
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user