Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
293 lines
9.5 KiB
Svelte
293 lines
9.5 KiB
Svelte
<script lang="ts">
|
|
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
|
import Search from '@lucide/svelte/icons/search';
|
|
import { api } from '$lib/api/client';
|
|
import { errorMessage } from '$lib/api/errors';
|
|
import type { components } from '$lib/api/schema';
|
|
import Badge from '$lib/components/Badge.svelte';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import FormField from '$lib/components/FormField.svelte';
|
|
import Input from '$lib/components/Input.svelte';
|
|
import Select from '$lib/components/Select.svelte';
|
|
import Tooltip from '$lib/components/Tooltip.svelte';
|
|
import { m } from '$lib/paraglide/messages';
|
|
|
|
type Setting = components['schemas']['LLMSettingOut'];
|
|
type Role = Setting['role'];
|
|
type Status = components['schemas']['LLMRoleStatus'];
|
|
|
|
type Draft = { base_url: string; model: string; api_key: string };
|
|
type Discovery = { models: string[]; supported: boolean; loading: boolean };
|
|
|
|
let settings = $state<Setting[]>([]);
|
|
// Per-role form state, seeded with the stored values so the fields show
|
|
// what is actually configured rather than an empty box over a placeholder.
|
|
let draft = $state<Record<string, Draft>>({});
|
|
let tested = $state<Record<string, Status | undefined>>({});
|
|
let discovered = $state<Record<string, Discovery | undefined>>({});
|
|
let busy = $state<string | null>(null);
|
|
let error = $state<string | null>(null);
|
|
|
|
async function refresh() {
|
|
const { data } = await api.GET('/api/admin/llm/settings');
|
|
settings = data ?? [];
|
|
for (const setting of settings) {
|
|
draft[setting.role] = {
|
|
base_url: setting.base_url,
|
|
model: setting.model,
|
|
// Never round-trips: the key is write-only, so the field stays
|
|
// empty and an empty field means "keep the stored one".
|
|
api_key: ''
|
|
};
|
|
}
|
|
}
|
|
|
|
$effect(() => void refresh());
|
|
|
|
function candidate(role: Role) {
|
|
const form = draft[role];
|
|
return {
|
|
role,
|
|
base_url: form.base_url || undefined,
|
|
model: form.model || undefined,
|
|
api_key: form.api_key || undefined
|
|
};
|
|
}
|
|
|
|
/** Validate before storing: a wrong endpoint should fail here, not on
|
|
* the next user question. */
|
|
async function test(role: Role) {
|
|
busy = role;
|
|
error = null;
|
|
const { data } = await api.POST('/api/admin/llm/test', { body: candidate(role) });
|
|
tested[role] = data?.roles?.[0];
|
|
busy = null;
|
|
}
|
|
|
|
/** Ask the endpoint what it serves. Runs server-side, so the key never
|
|
* leaves the backend. */
|
|
async function discover(role: Role) {
|
|
const form = draft[role];
|
|
discovered[role] = { models: [], supported: true, loading: true };
|
|
const { data } = await api.POST('/api/admin/llm/models/{role}', {
|
|
params: { path: { role } },
|
|
body: { base_url: form.base_url || undefined, api_key: form.api_key || undefined }
|
|
});
|
|
discovered[role] = {
|
|
models: data?.models ?? [],
|
|
supported: data?.supported ?? false,
|
|
loading: false
|
|
};
|
|
}
|
|
|
|
async function save(role: Role) {
|
|
busy = role;
|
|
error = null;
|
|
const form = draft[role];
|
|
const stored = settings.find((setting) => setting.role === role);
|
|
// Only send what actually changed. Submitting every field would mark
|
|
// the untouched ones as hand-edited, so fixing a URL would quietly
|
|
// claim the model no longer comes from .env.
|
|
const { error: failed } = await api.PUT('/api/admin/llm/settings/{role}', {
|
|
params: { path: { role } },
|
|
body: {
|
|
base_url: form.base_url === stored?.base_url ? undefined : form.base_url,
|
|
model: form.model === stored?.model ? undefined : form.model,
|
|
// Empty means "keep the stored key", not "delete it".
|
|
api_key: form.api_key || undefined
|
|
}
|
|
});
|
|
busy = null;
|
|
if (failed) {
|
|
error = m.admin_llm_save_failed();
|
|
return;
|
|
}
|
|
tested[role] = undefined;
|
|
await refresh();
|
|
}
|
|
|
|
/** Put one field back to what .env says. Per field, because an admin who
|
|
* fixed the model has no reason to lose their URL. */
|
|
async function reset(role: Role, field: 'base_url' | 'model' | 'api_key') {
|
|
busy = role;
|
|
await api.PUT('/api/admin/llm/settings/{role}', {
|
|
params: { path: { role } },
|
|
body: { [`reset_${field}`]: true }
|
|
});
|
|
busy = null;
|
|
tested[role] = undefined;
|
|
await refresh();
|
|
}
|
|
|
|
function sourceLabel(fromEnv: boolean): string {
|
|
return fromEnv ? m.admin_llm_source_env() : m.admin_llm_source_ui();
|
|
}
|
|
</script>
|
|
|
|
{#snippet source(setting: Setting, field: 'base_url' | 'model' | 'api_key', fromEnv: boolean)}
|
|
<span class="flex items-center gap-1 text-xs font-normal text-ink-muted">
|
|
{sourceLabel(fromEnv)}
|
|
{#if !fromEnv}
|
|
<!-- Long sentence as the tooltip, short name as the accessible
|
|
label: a screen reader announcing a whole sentence for a small
|
|
icon button is noise. -->
|
|
<Tooltip
|
|
text={m.admin_llm_reset_field()}
|
|
label={m.admin_llm_reset_field_short()}
|
|
onclick={() => reset(setting.role, field)}
|
|
>
|
|
<span class="text-ink-muted transition-colors hover:text-ink">
|
|
<RotateCcw size={12} />
|
|
</span>
|
|
</Tooltip>
|
|
{/if}
|
|
</span>
|
|
{/snippet}
|
|
|
|
<div class="flex flex-col gap-4" data-testid="llm-settings">
|
|
<p class="text-xs text-ink-muted">
|
|
{m.admin_llm_intro()}
|
|
</p>
|
|
|
|
{#each settings as setting (setting.role)}
|
|
<div class="rounded-xl border border-border p-4">
|
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
|
<span class="font-medium">{setting.role}</span>
|
|
{#if tested[setting.role]}
|
|
<Badge variant={tested[setting.role]?.ok ? 'success' : 'danger'}>
|
|
{tested[setting.role]?.ok
|
|
? `ok · ${tested[setting.role]?.latency_ms} ms`
|
|
: m.admin_llm_endpoint_failed()}
|
|
</Badge>
|
|
{#if !tested[setting.role]?.ok}
|
|
<!-- Why it failed, in words, plus the sanitized technical detail
|
|
the admin needs to fix it. -->
|
|
<span class="text-xs text-ink-muted">
|
|
{errorMessage(tested[setting.role]?.code)}
|
|
{#if tested[setting.role]?.error}
|
|
<code class="ml-1">{tested[setting.role]?.error}</code>
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="grid gap-3 md:grid-cols-2">
|
|
<div class="min-w-0">
|
|
<FormField label={m.admin_llm_base_url()} for="{setting.role}-url">
|
|
{#snippet hint()}{@render source(
|
|
setting,
|
|
'base_url',
|
|
setting.base_url_from_env
|
|
)}{/snippet}
|
|
<Input
|
|
id="{setting.role}-url"
|
|
placeholder={m.admin_llm_base_url_placeholder()}
|
|
bind:value={draft[setting.role].base_url}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
<div class="min-w-0">
|
|
<FormField label={m.admin_llm_model()} for="{setting.role}-model">
|
|
{#snippet hint()}{@render source(setting, 'model', setting.model_from_env)}{/snippet}
|
|
<div class="flex items-center gap-1">
|
|
{#if discovered[setting.role]?.models?.length}
|
|
<!-- A dropdown once we know what the endpoint serves; the
|
|
free-text field stays reachable via "Enter manually". -->
|
|
<Select
|
|
id="{setting.role}-model"
|
|
class="min-w-0 flex-1"
|
|
bind:value={draft[setting.role].model}
|
|
data-testid="model-select-{setting.role}"
|
|
>
|
|
{#each discovered[setting.role]?.models ?? [] as name (name)}
|
|
<option value={name}>{name}</option>
|
|
{/each}
|
|
</Select>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="shrink-0 whitespace-nowrap"
|
|
onclick={() => (discovered[setting.role] = undefined)}
|
|
>
|
|
{m.admin_llm_enter_manually()}
|
|
</Button>
|
|
{:else}
|
|
<div class="min-w-0 flex-1">
|
|
<Input
|
|
id="{setting.role}-model"
|
|
placeholder={m.admin_llm_model_placeholder()}
|
|
bind:value={draft[setting.role].model}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="shrink-0 whitespace-nowrap"
|
|
disabled={discovered[setting.role]?.loading}
|
|
onclick={() => discover(setting.role)}
|
|
data-testid="discover-{setting.role}"
|
|
>
|
|
<Search size={14} />
|
|
{discovered[setting.role]?.loading
|
|
? m.admin_llm_checking()
|
|
: m.admin_llm_check_models()}
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</FormField>
|
|
{#if discovered[setting.role] && !discovered[setting.role]?.loading && !discovered[setting.role]?.supported}
|
|
<p class="mt-1 text-xs text-ink-muted" data-testid="no-model-list-{setting.role}">
|
|
{m.admin_llm_no_model_list()}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
<div class="min-w-0 md:col-span-2">
|
|
<FormField label={m.admin_llm_api_key()} for="{setting.role}-key">
|
|
{#snippet hint()}{@render source(
|
|
setting,
|
|
'api_key',
|
|
setting.api_key_from_env
|
|
)}{/snippet}
|
|
<Input
|
|
id="{setting.role}-key"
|
|
type="password"
|
|
placeholder={setting.api_key_set ? '••••••••' : m.admin_llm_api_key_unset()}
|
|
bind:value={draft[setting.role].api_key}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
|
|
{#if tested[setting.role]?.error}
|
|
<p class="mt-2 text-xs text-danger">{tested[setting.role]?.error}</p>
|
|
{/if}
|
|
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={busy !== null}
|
|
onclick={() => test(setting.role)}
|
|
data-testid="test-{setting.role}"
|
|
>
|
|
{busy === setting.role ? m.admin_llm_testing_role() : m.admin_llm_test_role()}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={busy !== null || !tested[setting.role]?.ok}
|
|
onclick={() => save(setting.role)}
|
|
data-testid="save-{setting.role}"
|
|
>
|
|
{m.admin_llm_save()}
|
|
</Button>
|
|
</div>
|
|
<p class="mt-2 text-xs text-ink-muted">{m.admin_llm_api_key_note()}</p>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if error}
|
|
<p role="alert" class="text-sm text-danger">{error}</p>
|
|
{/if}
|
|
</div>
|