Merge pull request #69 from kristianwind/ai-dagens-plan-2
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m57s
CI / Docker image builds (push) Successful in 2m19s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m3s

feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
This commit is contained in:
Kristian Wind
2026-09-08 15:15:48 +02:00
committed by GitHub
18 changed files with 1348 additions and 15 deletions
+7
View File
@@ -490,6 +490,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>