190 lines
4.3 KiB
Svelte
190 lines
4.3 KiB
Svelte
<script>
|
||||
|
|
/**
|
|||
|
|
* En liste af forslag, man siger ja eller nej til, ét ad gangen.
|
|||
|
|
*
|
|||
|
|
* Den samme rude to steder — oprydning i indbakken og opgaver ud af en note —
|
|||
|
|
* fordi det er den samme handling: en model har foreslået en linje, og et
|
|||
|
|
* menneske bestemmer. To ruder ville betyde to svar på "hvad sker der, når
|
|||
|
|
* modellen tager fejl", og det spørgsmål har kun ét rigtigt svar.
|
|||
|
|
*
|
|||
|
|
* Forslaget står i et felt, man kan skrive i, frem for i en etiket. Det er
|
|||
|
|
* hele forskellen mellem at tage imod og at bestemme: et forslag, der er tæt
|
|||
|
|
* på, bliver rettet på to tegn i stedet for at blive kasseret — og linjen
|
|||
|
|
* læses af den samme parser bagefter, uanset om den blev rørt.
|
|||
|
|
*
|
|||
|
|
* `why` står under linjen, fordi et forkert gæt skal kunne gennemskues. "Fordi
|
|||
|
|
* der står 'inden fredag' i noten" er til at være uenig i; en dato, der bare
|
|||
|
|
* dukkede op, er ikke.
|
|||
|
|
*/
|
|||
|
|
import { t } from '$lib/i18n.svelte.js';
|
|||
|
|
import { app } from '$lib/stores.svelte.js';
|
|||
|
|
import { humanMessage } from '$lib/api.js';
|
|||
|
|
|
|||
|
|
let { title, hint, load, apply, onclose } = $props();
|
|||
|
|
|
|||
|
|
let state = $state('loading'); // loading | ready | failed
|
|||
|
|
let error = $state('');
|
|||
|
|
let rows = $state([]);
|
|||
|
|
let busy = $state(null);
|
|||
|
|
|
|||
|
|
$effect(() => {
|
|||
|
|
let alive = true;
|
|||
|
|
state = 'loading';
|
|||
|
|
load()
|
|||
|
|
.then((r) => {
|
|||
|
|
if (!alive) return;
|
|||
|
|
rows = (r?.suggestions ?? []).map((s, i) => ({ ...s, key: i, line: s.line, done: false }));
|
|||
|
|
state = 'ready';
|
|||
|
|
})
|
|||
|
|
.catch((e) => {
|
|||
|
|
if (!alive) return;
|
|||
|
|
error = humanMessage(e);
|
|||
|
|
state = 'failed';
|
|||
|
|
});
|
|||
|
|
return () => {
|
|||
|
|
alive = false;
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function accept(row) {
|
|||
|
|
if (busy !== null) return;
|
|||
|
|
busy = row.key;
|
|||
|
|
try {
|
|||
|
|
await apply(row, row.line);
|
|||
|
|
rows = rows.map((r) => (r.key === row.key ? { ...r, done: true } : r));
|
|||
|
|
} catch (e) {
|
|||
|
|
app.toast(humanMessage(e));
|
|||
|
|
} finally {
|
|||
|
|
busy = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function skip(row) {
|
|||
|
|
rows = rows.filter((r) => r.key !== row.key);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const left = $derived(rows.filter((r) => !r.done).length);
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<section class="suggest">
|
|||
|
|
<header>
|
|||
|
|
<h3>{title}</h3>
|
|||
|
|
<button class="close" onclick={onclose} aria-label={t('detail.close')}>×</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
{#if state === 'loading'}
|
|||
|
|
<p class="hint">{t('ai.thinking')}</p>
|
|||
|
|
{:else if state === 'failed'}
|
|||
|
|
<p class="hint bad">{error}</p>
|
|||
|
|
{:else if !rows.length}
|
|||
|
|
<p class="hint">{t('ai.nothingToSuggest')}</p>
|
|||
|
|
{:else}
|
|||
|
|
{#if hint}<p class="hint">{hint}</p>{/if}
|
|||
|
|
<ul>
|
|||
|
|
{#each rows as row (row.key)}
|
|||
|
|
<li class:done={row.done}>
|
|||
|
|
<input
|
|||
|
|
value={row.line}
|
|||
|
|
oninput={(e) => (row.line = e.currentTarget.value)}
|
|||
|
|
disabled={row.done}
|
|||
|
|
aria-label={title}
|
|||
|
|
/>
|
|||
|
|
{#if row.why}<p class="why">{row.why}</p>{/if}
|
|||
|
|
{#if !row.done}
|
|||
|
|
<div class="row">
|
|||
|
|
<button class="button" disabled={busy !== null} onclick={() => accept(row)}>
|
|||
|
|
{t('ai.accept')}
|
|||
|
|
</button>
|
|||
|
|
<button class="secondary" onclick={() => skip(row)}>{t('ai.skip')}</button>
|
|||
|
|
</div>
|
|||
|
|
{:else}
|
|||
|
|
<p class="why ok">{t('ai.applied')}</p>
|
|||
|
|
{/if}
|
|||
|
|
</li>
|
|||
|
|
{/each}
|
|||
|
|
</ul>
|
|||
|
|
{#if !left}
|
|||
|
|
<p class="hint">{t('ai.allDone')}</p>
|
|||
|
|
{/if}
|
|||
|
|
{/if}
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
.suggest {
|
|||
|
|
margin: var(--s2) 0;
|
|||
|
|
padding: var(--s3);
|
|||
|
|
border: 1px solid var(--line);
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
background: var(--surface-raised, var(--surface));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: baseline;
|
|||
|
|
gap: var(--s2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h3 {
|
|||
|
|
margin: 0 0 var(--s2);
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.close {
|
|||
|
|
margin-left: auto;
|
|||
|
|
border: none;
|
|||
|
|
background: none;
|
|||
|
|
color: var(--ink-faint);
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: var(--text-md);
|
|||
|
|
line-height: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hint {
|
|||
|
|
margin: 0 0 var(--s2);
|
|||
|
|
font-size: var(--text-xs);
|
|||
|
|
color: var(--ink-muted);
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hint.bad {
|
|||
|
|
color: var(--danger, var(--ink));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ul {
|
|||
|
|
list-style: none;
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--s2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
li input {
|
|||
|
|
width: 100%;
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
padding: var(--s1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
li.done input {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Grunden til forslaget, under det. Mindre end linjen selv: det er baggrund
|
|||
|
|
for en beslutning, ikke selve beslutningen. */
|
|||
|
|
.why {
|
|||
|
|
margin: 2px 0 var(--s1);
|
|||
|
|
font-size: var(--text-xs);
|
|||
|
|
color: var(--ink-faint);
|
|||
|
|
line-height: 1.4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.why.ok {
|
|||
|
|
color: var(--ok, var(--ink-muted));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.row {
|
|||
|
|
display: flex;
|
|||
|
|
gap: var(--s2);
|
|||
|
|
}
|
|||
|
|
</style>
|