2026-08-17 10:39:46 +02:00
|
|
|
<script>
|
|
|
|
|
/** The AI provider: which one, which model, and the key. */
|
|
|
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
|
|
|
import { app } from '$lib/stores.svelte.js';
|
2026-08-18 09:46:08 +02:00
|
|
|
import { t } from '$lib/i18n.svelte.js';
|
2026-08-17 10:39:46 +02:00
|
|
|
|
|
|
|
|
let settings = $state(null);
|
|
|
|
|
let apiKey = $state('');
|
|
|
|
|
let saving = $state(false);
|
|
|
|
|
let saved = $state(false);
|
|
|
|
|
let errors = $state({});
|
|
|
|
|
|
|
|
|
|
let summary = $state('');
|
|
|
|
|
let summarising = $state(false);
|
|
|
|
|
|
|
|
|
|
const PROVIDERS = [
|
2026-08-18 09:46:08 +02:00
|
|
|
{ value: '', label: t('ai.off') },
|
2026-08-17 10:39:46 +02:00
|
|
|
{ value: 'anthropic', label: 'Anthropic' },
|
|
|
|
|
{ value: 'openai', label: 'OpenAI' },
|
|
|
|
|
{ value: 'google', label: 'Google' },
|
|
|
|
|
{ value: 'compatible', label: 'OpenAI-kompatibel (Ollama, LM Studio, …)' }
|
|
|
|
|
];
|
|
|
|
|
|
2026-08-22 00:03:45 +02:00
|
|
|
// Every field error needs somewhere to appear, and this list is the check that
|
|
|
|
|
// it does. `base_url` had nowhere: a refused address set `errors`, skipped the
|
|
|
|
|
// toast because the object was not empty, and then rendered nothing — so the
|
|
|
|
|
// one provider meant for running your own model looked like a save that did
|
|
|
|
|
// nothing at all. Anything without a slot is shouted rather than dropped.
|
|
|
|
|
const SHOWN = ['provider', 'model', 'base_url'];
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
// Suggestions, not a closed list: the model field is free text, because a new
|
|
|
|
|
// model name must not require a new release of this app to be usable.
|
|
|
|
|
const SUGGESTED_MODEL = {
|
|
|
|
|
anthropic: 'claude-sonnet-5',
|
|
|
|
|
openai: 'gpt-5',
|
|
|
|
|
google: 'gemini-2.5-pro',
|
|
|
|
|
compatible: 'llama3.1'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
api
|
|
|
|
|
.getAISettings()
|
|
|
|
|
.then((s) => (settings = s))
|
|
|
|
|
.catch((e) => app.toast(humanMessage(e)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function save(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
saving = true;
|
|
|
|
|
saved = false;
|
|
|
|
|
errors = {};
|
|
|
|
|
try {
|
|
|
|
|
await api.setAISettings({
|
|
|
|
|
provider: settings.provider,
|
|
|
|
|
base_url: settings.base_url ?? '',
|
|
|
|
|
model: settings.model ?? '',
|
|
|
|
|
// An empty key means "leave the stored one alone" on the server. That
|
|
|
|
|
// is why this field starts empty rather than pre-filled with dots.
|
|
|
|
|
api_key: apiKey
|
|
|
|
|
});
|
|
|
|
|
apiKey = '';
|
|
|
|
|
settings = await api.getAISettings();
|
|
|
|
|
saved = true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
errors = e.fields ?? {};
|
2026-08-22 00:03:45 +02:00
|
|
|
const homeless = Object.keys(errors).filter((field) => !SHOWN.includes(field));
|
|
|
|
|
if (!Object.keys(errors).length || homeless.length) app.toast(humanMessage(e));
|
2026-08-17 10:39:46 +02:00
|
|
|
} finally {
|
|
|
|
|
saving = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function weeklySummary() {
|
|
|
|
|
summarising = true;
|
|
|
|
|
summary = '';
|
|
|
|
|
try {
|
|
|
|
|
summary = (await api.aiSummary()).summary;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
summarising = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="ai">
|
2026-08-17 10:39:46 +02:00
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
<h2>{t('ai.title')}</h2>
|
2026-08-17 10:39:46 +02:00
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('ai.hint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{#if settings === null}
|
|
|
|
|
<p class="empty">…</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<form onsubmit={save}>
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="provider">{t('ai.provider')}</label>
|
2026-08-17 10:39:46 +02:00
|
|
|
<select id="provider" bind:value={settings.provider}>
|
|
|
|
|
{#each PROVIDERS as provider (provider.value)}
|
|
|
|
|
<option value={provider.value}>{provider.label}</option>
|
|
|
|
|
{/each}
|
|
|
|
|
</select>
|
|
|
|
|
{#if errors.provider}<p class="error">{errors.provider}</p>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if settings.provider}
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="model">{t('ai.model')}</label>
|
2026-08-17 10:39:46 +02:00
|
|
|
<input
|
|
|
|
|
id="model"
|
|
|
|
|
bind:value={settings.model}
|
|
|
|
|
placeholder={SUGGESTED_MODEL[settings.provider] ?? ''}
|
|
|
|
|
/>
|
2026-08-22 00:03:45 +02:00
|
|
|
{#if errors.model}<p class="error">{errors.model}</p>{/if}
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if settings.provider === 'compatible'}
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="base">{t('ai.baseURL')}</label>
|
2026-08-17 10:39:46 +02:00
|
|
|
<input
|
|
|
|
|
id="base"
|
|
|
|
|
class="mono"
|
|
|
|
|
bind:value={settings.base_url}
|
|
|
|
|
placeholder="http://localhost:11434/v1"
|
|
|
|
|
/>
|
2026-08-22 00:03:45 +02:00
|
|
|
{#if errors.base_url}<p class="error">{errors.base_url}</p>{/if}
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="key">{t('ai.apiKey')}</label>
|
2026-08-17 10:39:46 +02:00
|
|
|
<input
|
|
|
|
|
id="key"
|
|
|
|
|
type="password"
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
bind:value={apiKey}
|
2026-08-18 09:46:08 +02:00
|
|
|
placeholder={settings.has_key ? t('ai.keyStored') : ''}
|
2026-08-17 10:39:46 +02:00
|
|
|
/>
|
|
|
|
|
<p class="hint">
|
|
|
|
|
{#if settings.has_key}
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('ai.keyBlankHint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
{:else}
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('ai.keyNewHint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
{/if}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
2026-08-18 09:46:08 +02:00
|
|
|
<button class="primary" type="submit" disabled={saving}>{t('ai.save')}</button>
|
|
|
|
|
{#if saved}<span class="saved">{t('ai.saved')}</span>{/if}
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{#if settings?.provider && settings?.has_key}
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="ugentlig">
|
2026-08-17 10:39:46 +02:00
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
<h2>{t('ai.weekly')}</h2>
|
|
|
|
|
<p class="hint">{t('ai.weeklyHint')}</p>
|
2026-08-17 10:39:46 +02:00
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button class="secondary" onclick={weeklySummary} disabled={summarising}>
|
|
|
|
|
{summarising ? 'Skriver …' : 'Skriv et overblik'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if summary}
|
|
|
|
|
<p class="summary">{summary}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
form {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--s4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: var(--s4);
|
|
|
|
|
background: var(--surface-sunken);
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
}
|
|
|
|
|
</style>
|