Pablan, as it stands

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
This commit is contained in:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
<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>