feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål

De tre sidste af de fem. Alle tre samme sted hen: det, man ellers selv
skulle sidde og gøre klokken syv om morgenen.

Dagens plan. Én besked om morgenen om det, der forfalder i dag, og det,
der er sprunget over. Tallene tælles i basen og gives til modellen som
kendsgerninger, den ikke må regne om — en plan, der siger fire opgaver,
hvor der er tre, er værre end ingen plan. Er der ingen model, står
tallene alene, og det er stadig det, man havde brug for at vide. Er der
hverken noget, der forfalder, eller noget, der er sprunget over, kommer
der ingen besked: en morgen uden noget at gøre er en god morgen, og en
besked om ingenting lærer folk at lade være med at åbne beskeder.

Timen er personens egen — klokken syv er syv forskellige steder — og
jobbet kører hver time frem for på et klokkeslæt, så en instans, der sov,
ikke springer dagen over. Flytter man timen, ryger dagens mærke, så man
kan se virkningen i dag i stedet for i morgen.

Mails, der giver mening. Opgaven hed, hvad mailen hed: "Anders Jensen:
SV: SV: Vedr. levering uge 12". Nok til at genkende, ikke nok til at
gøre noget ved. Nu skriver modellen den handling, mailen beder om, og
lader være, hvis den ikke beder om nogen. Ét kald pr. gennemløb frem for
ét pr. mail — femogtyve mails er ellers femogtyve gange ventetid — og
emnet bliver stående i beskrivelsen, så en misforstået mail stadig kan
læses. Falder modellen ud, hedder opgaven afsender og emne som før: en
model, der er nede, må ikke kunne stoppe posten.

Og svar på et spørgsmål. "Hvad lovede jeg Anders i august?" — svaret står
spredt over tre noter, og ingen af dem indeholder ordet "lovede".
Søgningen kræver, at alle ord optræder, hvilket er rigtigt for et
søgefelt og forkert for et spørgsmål, så kandidaterne hentes ord for ord
og de noter, flere ord rammer, kommer øverst. Modellen får kun dem: den
slår ikke selv noget op, så et svar kan ikke handle om nogen andens
noter. Og svaret bærer, hvad det står på — en model, der siger "det
gjorde du den 14.", er kun brugbar, hvis man kan slå op i det, den læste
det i.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-08 15:05:36 +02:00
co-authored by Claude Opus 5
parent 40102d6038
commit 8139003b35
18 changed files with 1348 additions and 15 deletions
+7
View File
@@ -485,6 +485,13 @@ export const api = {
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 }),
// Spørg om sine egne noter og opgaver. Basen finder kandidaterne, modellen
// læser dem — svaret bærer, hvad det står på.
aiAsk: (question) => post('/ai/ask', { question }),
// Dagens plan om morgenen.
planSettings: () => get('/ai/plan'),
setPlanSettings: (body) => put('/ai/plan', body),
planNow: () => post('/ai/plan/now'),
version: () => get('/version'),
@@ -3,6 +3,8 @@
import { api } from '$lib/api.js';
import { t } from '$lib/i18n.svelte.js';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
import { humanMessage } from '$lib/api.js';
let { open = $bindable(false) } = $props();
@@ -11,6 +13,11 @@
let projects = $state([]);
let notes = $state([]);
let selected = $state(0);
// Svaret på et spørgsmål, når nogen har stillet et. Søgningen finder det, der
// indeholder ordene; det her svarer på spørgsmålet — og de to bor i det samme
// felt, fordi man ikke skal vide på forhånd, hvilken slags spørgsmål man har.
let answer = $state(null);
let asking = $state(false);
let input;
let controller = null;
let timer = null;
@@ -20,6 +27,7 @@
query = '';
tasks = [];
projects = [];
answer = null;
selected = 0;
queueMicrotask(() => input?.focus());
}
@@ -34,8 +42,12 @@
tasks = [];
projects = [];
notes = [];
answer = null;
return;
}
// Et nyt bogstav gør det forrige svar forældet. Et svar, der bliver
// stående, mens spørgsmålet ændrer sig, er et svar på noget andet.
answer = null;
timer = setTimeout(async () => {
try {
// Noter er sit eget endepunkt, så de hentes ved siden af. Uden dem
@@ -68,6 +80,32 @@
}))
]);
/**
* Spørg om sine egne noter og opgaver.
*
* Bag en tast frem for automatisk: det koster et kald til en model, og de
* fleste ⌘K er nogen, der leder efter en note, de kender navnet på.
*/
async function ask() {
const question = query.trim();
if (!question || asking) return;
asking = true;
answer = null;
try {
answer = await api.aiAsk(question);
} catch (e) {
answer = { answer: '', error: humanMessage(e), sources: [] };
} finally {
asking = false;
}
}
function openSource(source) {
open = false;
if (source.kind === 'note') return goto(`/noter?note=${source.id}`);
app.openDetail(source.id);
}
function choose(item) {
open = false;
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
@@ -89,6 +127,12 @@
break;
case 'Enter':
event.preventDefault();
// ⌘⏎ spørger, ⏎ åbner det valgte. Den, der leder efter en note, skal
// ikke vente på en model for at komme hen til den.
if (event.metaKey || event.ctrlKey) {
ask();
break;
}
if (results[selected]) choose(results[selected]);
break;
}
@@ -110,6 +154,39 @@
spellcheck="false"
/>
{#if query.trim()}
<div class="askrow">
<button class="ask" onclick={ask} disabled={asking}>
{asking ? t('ai.thinking') : t('ai.askThis')}
</button>
<kbd>⌘⏎</kbd>
</div>
{/if}
{#if answer}
<div class="answer">
{#if answer.error}
<p class="bad">{answer.error}</p>
{:else if answer.answer}
<p>{answer.answer}</p>
{#if answer.sources?.length}
<!-- Hvad svaret står på. En model, der siger "det gjorde du
den 14.", er kun brugbar, hvis man kan slå op i det, den
læste det i. -->
<p class="sources">
{t('ai.askSources')}
{#each answer.sources as source, i (source.kind + source.id)}<button
class="source"
onclick={() => openSource(source)}>{source.title}</button
>{i < answer.sources.length - 1 ? ', ' : ''}{/each}
</p>
{/if}
{:else}
<p class="bad">{t('ai.askNothing')}</p>
{/if}
</div>
{/if}
{#if results.length}
<ul>
{#each results as item, i (item.kind + item.id)}
@@ -140,6 +217,62 @@
background: rgb(0 0 0 / 0.45);
display: flex;
justify-content: center;
/* Spørgsmålsknappen står under feltet og fylder ikke: de fleste ⌘K er nogen,
der leder efter en note, de kender navnet på, og skal ikke forbi en model
for at komme derhen. */
.askrow {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s1) var(--s2);
border-top: 1px solid var(--line);
}
.ask {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--text-xs);
color: var(--accent);
cursor: pointer;
}
.ask:disabled {
color: var(--ink-faint);
cursor: default;
}
.answer {
padding: var(--s2);
border-top: 1px solid var(--line);
font-size: var(--text-sm);
line-height: 1.5;
}
.answer p {
margin: 0 0 var(--s1);
}
.answer .bad {
color: var(--ink-muted);
}
.answer .sources {
margin: 0;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.source {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
/* Not centred: a palette pinned near the top does not jump as results
appear, and lands where the eye already is after ⌘K. */
align-items: flex-start;
@@ -25,6 +25,7 @@
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
'daily.plan': 'notif.dailyPlan',
comment: 'notif.comment'
};
+11
View File
@@ -32,6 +32,7 @@ export const da = {
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.dailyPlan': 'Dagens plan',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
@@ -87,6 +88,16 @@ export const da = {
'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.askThis': 'Spørg om det her',
'ai.askSources': 'Fra:',
'ai.askNothing': 'Der er ikke noget i dine noter eller opgaver, der svarer på det.',
'ai.plan': 'Dagens plan',
'ai.planHint':
'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.',
'ai.planHour': 'Klokken',
'ai.planNow': 'Send den nu',
'ai.planSent': 'Sendt — kig i klokken.',
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
'ai.thinking': 'Læser …',
'ai.nothingToSuggest': 'Der er ikke noget at foreslå.',
'ai.accept': 'Tilføj',
+11
View File
@@ -29,6 +29,7 @@ export const en = {
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.mention': '{name} mentioned you in a note',
'notif.dailyPlan': "Today's plan",
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
@@ -84,6 +85,16 @@ export const en = {
'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.askThis': 'Ask about this',
'ai.askSources': 'From:',
'ai.askNothing': 'There is nothing in your notes or tasks that answers that.',
'ai.plan': "Today's plan",
'ai.planHint':
'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.',
'ai.planHour': 'At',
'ai.planNow': 'Send it now',
'ai.planSent': 'Sent — look in the bell.',
'ai.planNothing': 'There is nothing to plan for today.',
'ai.thinking': 'Reading …',
'ai.nothingToSuggest': 'There is nothing to suggest.',
'ai.accept': 'Add',
@@ -86,6 +86,39 @@
// --- the list -----------------------------------------------------------------
// Dagens plan: én besked om morgenen om det, der venter. Slået fra som
// udgangspunkt — en besked klokken syv, man ikke har bedt om, er ikke en hjælp.
let plan = $state(null);
let planBusy = $state(false);
$effect(() => {
api
.planSettings()
.then((p) => (plan = p))
.catch(() => {});
});
async function savePlan(change) {
try {
plan = await api.setPlanSettings(change);
} catch (e) {
app.toast(humanMessage(e));
}
}
async function sendPlanNow() {
if (planBusy) return;
planBusy = true;
try {
const r = await api.planNow();
app.toast(r.sent ? t('ai.planSent') : t('ai.planNothing'));
} catch (e) {
app.toast(humanMessage(e));
} finally {
planBusy = false;
}
}
let notifications = $state([]);
let unread = $state(0);
let loading = $state(true);
@@ -180,6 +213,45 @@
});
</script>
<section class="panel">
<header>
<h2>{t('ai.plan')}</h2>
<p class="hint">{t('ai.planHint')}</p>
</header>
{#if plan}
<label class="check">
<input
type="checkbox"
checked={plan.enabled}
onchange={(e) => savePlan({ enabled: e.currentTarget.checked })}
/>
<span>{t('ai.plan')}</span>
</label>
{#if plan.enabled}
<div class="field">
<label for="planhour">{t('ai.planHour')}</label>
<select
id="planhour"
value={plan.hour}
onchange={(e) => savePlan({ hour: Number(e.currentTarget.value) })}
>
{#each Array.from({ length: 24 }, (_, i) => i) as hour (hour)}
<option value={hour}>{String(hour).padStart(2, '0')}:00</option>
{/each}
</select>
</div>
<!-- En indstilling, man skal vente til i morgen for at se virkningen af,
er en indstilling, ingen tør slå til. -->
<button class="secondary" disabled={planBusy} onclick={sendPlanNow}>
{t('ai.planNow')}
</button>
{/if}
{/if}
</section>
<section class="panel">
<header>
<h2>{t('push.title')}</h2>