feat(ai): forslag, man siger ja til — indbakken og noternes opgaver

To steder, hvor arbejdet ligger og venter på at blive skrevet færdigt.

Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive
dem ordentligt — og derfor også der, hvor de bliver liggende. At åbne
hver enkelt og sætte projekt, dato og prioritet er ti klik pr. linje. Nu
er det ét spørgsmål og et ja pr. linje.

Og et referat er fuldt af sætninger, der ligner opgaver, og af tre, der
er det. Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig
på, og om at citere de ord, den tog dem fra — så et forkert punkt kan
gennemskues frem for bare at være forkert.

Modellen skriver ikke noget. Den foreslår en *linje* i quick-add-syntaks,
og forslaget bliver til noget, når nogen siger ja. Det er ikke
forsigtighed for forsigtighedens skyld: en model, der retter i folks
opgaver direkte, skal have ret hver gang for at være til gavn, hvor en,
der foreslår, skal have ret tit nok til at spare et par tastetryk.

Linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
findes. Der er én fortolkning af "i morgen" i dette program, og forslaget
går gennem den samme som alt andet — også når linjen er blevet rettet i
hånden, inden den blev accepteret. Og et forslag, skrevet i det sprog man
selv skriver i, kan læses og rettes af den, det bliver stillet til.

Modellen får listen over de projekter, der findes, og må ikke nævne
andre. Et `#Nyt projekt`, den fandt på, ville lave et projekt ved et
uheld, første gang nogen sagde ja — og et gæt må ikke kunne flytte en
opgave hen, hvor ejeren ikke leder efter den. Testet.

Forslagene til indbakken bærer et nummer tilbage til den opgave, de
handler om. Rækkefølgen alene ville også kunne bruges og gør det som
reserve, men en model, der springer en opgave over, ville så flytte alle
de følgende forslag én plads — og "sæt en dato på den her" ville lande på
en anden.

Noten bliver ikke rørt, når der laves opgaver ud af den. Det er
fristende at skrive linket ind i den, så de to peger på hinanden, men en
tekst, et program skriver i uden at blive bedt om det, er en tekst, man
holder op med at stole på. Opgaven bærer i stedet, hvor den kom fra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-08 14:48:56 +02:00
co-authored by Claude Opus 5
parent 445d9de57a
commit 40102d6038
11 changed files with 1106 additions and 2 deletions
+8
View File
@@ -477,6 +477,14 @@ export const api = {
setAISettings: (data) => put('/ai/settings', data),
aiSummary: () => post('/ai/summary'),
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
// Forslag frem for skrivninger: de to første spørger, de to sidste er svaret
// på et ja. Linjen sendes med, som den står i feltet — den kan være rettet i
// hånden, inden den blev accepteret, og skal læses på samme måde som en, der
// ikke blev rørt.
aiTidyInbox: () => post('/ai/inbox/tidy'),
aiApplyToTask: (taskId, line) => post('/ai/inbox/tidy/apply', { task_id: taskId, line }),
aiNoteActions: (noteId) => post(`/ai/notes/${noteId}/actions`),
aiCreateFromNote: (noteId, line) => post(`/ai/notes/${noteId}/actions/apply`, { line }),
version: () => get('/version'),
+189
View File
@@ -0,0 +1,189 @@
<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>
+16
View File
@@ -77,6 +77,22 @@ export const da = {
'notes.sortUpdated': 'Senest rørt',
'notes.sortCreated': 'Oprettet',
'notes.sortTitle': 'Navn',
// AI'ens forslag. Ordene skal gøre det klart, at der bliver foreslået og ikke
// rettet: den, der siger ja, er den, der bestemmer.
'ai.tidyInbox': 'Ryd op i indbakken',
'ai.tidyTitle': 'Forslag til indbakken',
'ai.tidyHint':
'Sådan kunne linjerne have set ud, hvis de var skrevet færdig. Ret i dem, du vil rette i — der sker først noget, når du siger ja.',
'ai.findActions': 'Find opgaver i noten',
'ai.actionsTitle': 'Opgaver i noten',
'ai.actionsHint':
'Det, nogen har lovet eller er blevet bedt om i teksten. Citatet under hver linje er de ord, den kom fra.',
'ai.thinking': 'Læser …',
'ai.nothingToSuggest': 'Der er ikke noget at foreslå.',
'ai.accept': 'Tilføj',
'ai.skip': 'Nej tak',
'ai.applied': 'Gjort.',
'ai.allDone': 'Ikke mere at tage stilling til.',
'beacon.title': 'Tælling af installationer',
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
'beacon.send': 'Send en anonym daglig melding',
+16
View File
@@ -74,6 +74,22 @@ export const en = {
'notes.sortUpdated': 'Last touched',
'notes.sortCreated': 'Created',
'notes.sortTitle': 'Name',
// The AI's suggestions. The words have to make it plain that this proposes
// rather than edits: whoever says yes is the one deciding.
'ai.tidyInbox': 'Tidy up the inbox',
'ai.tidyTitle': 'Suggestions for the inbox',
'ai.tidyHint':
'How the lines could have read if they had been finished. Edit the ones you want to edit — nothing happens until you say yes.',
'ai.findActions': 'Find tasks in the note',
'ai.actionsTitle': 'Tasks in the note',
'ai.actionsHint':
'What somebody promised or was asked to do in the text. The quote under each line is the words it came from.',
'ai.thinking': 'Reading …',
'ai.nothingToSuggest': 'There is nothing to suggest.',
'ai.accept': 'Add',
'ai.skip': 'No thanks',
'ai.applied': 'Done.',
'ai.allDone': 'Nothing left to decide on.',
'beacon.title': 'Counting installations',
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
'beacon.send': 'Send an anonymous daily report',
+23 -2
View File
@@ -19,6 +19,7 @@
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import AISuggestions from '$lib/components/AISuggestions.svelte';
let notes = $state([]);
@@ -207,6 +208,9 @@
// Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej
// frem, og det skal kunne kopieres frem for at forsvinde.
let shareInviteLink = $state('');
// Opgaver, der ligger i notens tekst. Lukket, indtil nogen spørger: det koster
// et kald til en model, og de fleste noter er ikke referater.
let showActions = $state(false);
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// Dem, et `@` kan betyde i den åbne note.
@@ -246,8 +250,9 @@
$effect(() => {
const id = selected?.id;
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
// under den nye.
// Nulstil ved hvert skift, så den forrige notes folk — og den forrige notes
// forslag — ikke står et øjeblik under den nye.
showActions = false;
shares = [];
shareCandidates = [];
shareFollows = [];
@@ -1086,6 +1091,16 @@
onsave={save}
/>
{#if showActions}
<AISuggestions
title={t('ai.actionsTitle')}
hint={t('ai.actionsHint')}
load={() => api.aiNoteActions(selected.id)}
apply={(row, line) => api.aiCreateFromNote(selected.id, line)}
onclose={() => (showActions = false)}
/>
{/if}
<footer>
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
{#if links.length}
@@ -1274,6 +1289,12 @@
{/if}
<button class="button" onclick={save}>{t('notes.save')}</button>
<!-- Det, nogen har lovet i et referat, står i teksten og ikke på
listen. Knappen finder det frem som forslag; opgaverne bliver
til noget, når man siger ja til dem. -->
<button class="button" onclick={() => (showActions = !showActions)}>
{t('ai.findActions')}
</button>
{#if ownsSelected}
<!-- Laid away, not thrown away: archive is the note leaving the list
without leaving the account, and it reads "bring it back" once it
+29
View File
@@ -10,6 +10,7 @@
import TaskList from '$lib/components/TaskList.svelte';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import AISuggestions from '$lib/components/AISuggestions.svelte';
import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
import { focusOnMount } from '$lib/focus.js';
@@ -31,6 +32,10 @@
let members = $state([]);
let showShare = $state(false);
let showLog = $state(false);
// Oprydningen i indbakken. Kun dér: det er den ene liste, der bliver fyldt med
// linjer, ingen nåede at skrive færdig, og derfor den ene, hvor det er værd at
// spørge en model om, hvordan de skulle have set ud.
let showTidy = $state(false);
let activity = $state([]);
let inviteEmail = $state('');
let inviteRole = $state('editor');
@@ -613,6 +618,18 @@
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
</button>
{#if project.is_inbox}
<button
role="menuitem"
onclick={() => {
showTidy = !showTidy;
showMenu = false;
}}
>
{t('ai.tidyInbox')}
</button>
{/if}
{#if !project.is_inbox}
<button
role="menuitem"
@@ -665,6 +682,18 @@
</div>
</header>
{#if showTidy}
<!-- Forslag, ikke rettelser. Hver linje står i et felt, man kan skrive
i, og sker først, når nogen siger ja til den. -->
<AISuggestions
title={t('ai.tidyTitle')}
hint={t('ai.tidyHint')}
load={() => api.aiTidyInbox()}
apply={(row, line) => api.aiApplyToTask(row.task_id, line)}
onclose={() => (showTidy = false)}
/>
{/if}
{#if showLog}
<div class="panel">
<ul class="log">