Indstillingerne er ti faner med syvogtredive afsnit, og den eneste vej til et bestemt af dem var at huske, hvilken fane det lå under. ⌘K kunne opgaver, projekter og noter — alt det, man selv har skrevet, og intet af det, programmet består af. Ét indeks, to steder det bruges: et felt øverst i indstillingerne og paletten. To lister over de samme afsnit ville være to steder at glemme et nyt et. Overskrifterne står ikke i indekset. Hver post peger på den nøgle, afsnittets egen overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af sig selv, og søgningen kan begge sprog uden at kende nogen af dem. `words` er derimod skrevet i indekset, og det er med vilje: det er søgeord, ingen får at se. Det danske afsnit hedder "Tælling af installationer", men det ord, man kender det ved, er "beacon" — og på samme måde 2fa, imap, webcal, webhook. De er de samme på begge sprog, hvilket er hele grunden til, at de virker som søgeord. Ankrene er nye id'er på afsnittene. Tre af dem stødte sammen med et felt inde i afsnittet — to ens id'er gør <label for> tvetydig og getElementById tilfældig — og hedder derfor to-faktor, kalenderfeed og caldav-server. Springet dertil kan ikke overlades til browseren: siden hentes af ruteren, og afsnittet findes først, når dens data er hjemme. Der prøves i to sekunder og gives så op. Blinket bagefter er ikke pynt — man er landet midt i en side med otte afsnit, man ikke selv rullede hen til. Prøverne læser filerne frem for at klikke: halvdelen af afsnittene vises kun under en betingelse, så en prøve, der ledte efter dem i en browser, ville melde fejl på det, der med rette var skjult. Og palettens pladsholder sagde "Søg i opgaver og projekter". Den har kunnet noter længe, og kan nu også indstillinger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
5.0 KiB
Svelte
196 lines
5.0 KiB
Svelte
<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';
|
|
import { t } from '$lib/i18n.svelte.js';
|
|
|
|
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 = [
|
|
{ value: '', label: t('ai.off') },
|
|
{ value: 'anthropic', label: 'Anthropic' },
|
|
{ value: 'openai', label: 'OpenAI' },
|
|
{ value: 'google', label: 'Google' },
|
|
{ value: 'compatible', label: 'OpenAI-kompatibel (Ollama, LM Studio, …)' }
|
|
];
|
|
|
|
// 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'];
|
|
|
|
// 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 ?? {};
|
|
const homeless = Object.keys(errors).filter((field) => !SHOWN.includes(field));
|
|
if (!Object.keys(errors).length || homeless.length) app.toast(humanMessage(e));
|
|
} 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>
|
|
|
|
<section class="panel" id="ai">
|
|
<header>
|
|
<h2>{t('ai.title')}</h2>
|
|
<p class="hint">
|
|
{t('ai.hint')}
|
|
</p>
|
|
</header>
|
|
|
|
{#if settings === null}
|
|
<p class="empty">…</p>
|
|
{:else}
|
|
<form onsubmit={save}>
|
|
<div class="field">
|
|
<label for="provider">{t('ai.provider')}</label>
|
|
<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">
|
|
<label for="model">{t('ai.model')}</label>
|
|
<input
|
|
id="model"
|
|
bind:value={settings.model}
|
|
placeholder={SUGGESTED_MODEL[settings.provider] ?? ''}
|
|
/>
|
|
{#if errors.model}<p class="error">{errors.model}</p>{/if}
|
|
</div>
|
|
|
|
{#if settings.provider === 'compatible'}
|
|
<div class="field">
|
|
<label for="base">{t('ai.baseURL')}</label>
|
|
<input
|
|
id="base"
|
|
class="mono"
|
|
bind:value={settings.base_url}
|
|
placeholder="http://localhost:11434/v1"
|
|
/>
|
|
{#if errors.base_url}<p class="error">{errors.base_url}</p>{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="field">
|
|
<label for="key">{t('ai.apiKey')}</label>
|
|
<input
|
|
id="key"
|
|
type="password"
|
|
autocomplete="off"
|
|
bind:value={apiKey}
|
|
placeholder={settings.has_key ? t('ai.keyStored') : ''}
|
|
/>
|
|
<p class="hint">
|
|
{#if settings.has_key}
|
|
{t('ai.keyBlankHint')}
|
|
{:else}
|
|
{t('ai.keyNewHint')}
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="row">
|
|
<button class="primary" type="submit" disabled={saving}>{t('ai.save')}</button>
|
|
{#if saved}<span class="saved">{t('ai.saved')}</span>{/if}
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if settings?.provider && settings?.has_key}
|
|
<section class="panel" id="ugentlig">
|
|
<header>
|
|
<h2>{t('ai.weekly')}</h2>
|
|
<p class="hint">{t('ai.weeklyHint')}</p>
|
|
</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>
|