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:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -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>
|
||||
Reference in New Issue
Block a user