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:
co-authored by
Claude Opus 5
parent
445d9de57a
commit
40102d6038
@@ -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'),
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user