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
+1
View File
@@ -104,6 +104,7 @@ func run() error {
runner.SyncCalendars = api.SyncCalendars
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.Start(jobCtx)
srv := &http.Server{
+123
View File
@@ -3568,6 +3568,115 @@ paths:
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/ai/plan:
get:
tags: [integrations]
summary: Whether a morning plan is sent, and at what hour.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
put:
tags: [integrations]
summary: Turn the morning plan on or off, or move the hour.
description: |
The hour is local to the person. Moving it clears the "already sent today"
mark, so somebody who moves the plan from 07 to 16 gets it this afternoon
rather than tomorrow — a setting whose effect you have to wait a day to
see is a setting nobody dares turn on.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
hour: { type: integer, minimum: 0, maximum: 23 }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/ai/plan/now:
post:
tags: [integrations]
summary: Send today's plan now, to whoever asks.
description: |
The counts come from the database and the sentence from the model, in that
order: a model that counted for itself would sometimes count wrong, and a
plan that says four tasks where there are three is worse than no plan.
With no model configured the counts stand on their own, which is what you
needed to know at seven in the morning anyway.
Nothing due and nothing overdue sends nothing. A morning with nothing to
do is a good morning, and a notification saying "there is nothing" teaches
people to stop opening notifications.
responses:
"200":
description: The plan, and whether it was sent.
content:
application/json:
schema:
type: object
properties:
plan: { type: string }
sent: { type: boolean }
/api/v1/ai/ask:
post:
tags: [integrations]
summary: Ask a question of your own notes and tasks.
description: |
Search finds what contains the words. This answers the question — "what
did I promise Anders in August?" — which is not the same: the answer is
spread over three notes and a task, and none of them contains the word
"promise".
Two halves, and the order is the whole of the safety in it. The database
finds the candidates with the search that already exists and that already
only sees what this person may see. The model is given those and nothing
else — it does not look anything up and cannot ask for more.
The answer carries what it rests on. A model that says "you did, on the
14th" is only useful if you can open the note it read that in.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [question]
properties:
question: { type: string }
responses:
"200":
description: |
The answer, and the passages it rests on — only those, not everything
that was looked at. An empty answer means nothing matched.
content:
application/json:
schema:
type: object
properties:
answer: { type: string }
sources:
type: array
items:
type: object
properties:
kind: { type: string, enum: [note, task] }
id: { type: string }
title: { type: string }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/mcp:
post:
tags: [integrations]
@@ -4351,6 +4460,20 @@ components:
note_id: { type: string }
title: { type: string }
DailyPlanSettings:
type: object
description: >
One person's morning plan. Off by default — a notification at seven in the
morning that nobody asked for is not a help.
properties:
enabled: { type: boolean }
hour:
type: integer
description: Local to the person, because seven o'clock is seven different places.
last_sent:
type: string
description: The date it last went out, in that person's own timezone.
AISuggestion:
type: object
description: >
+130 -12
View File
@@ -484,6 +484,104 @@ the task came from, so it can be checked.`, language, known, language, today)
return c.suggestions(ctx, system, "Note:\n"+note)
}
// DailyPlan skriver morgenens to-tre linjer.
//
// Tallene er talt i basen og gives med som kendsgerninger, modellen ikke må regne
// om. En plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan —
// og en model, der tæller en liste, tæller den forkert af og til.
//
// Det ene sted, den må mene noget, er hvad man skulle begynde med, og hvad der
// kunne vente. Det er dét, der gør det til en plan frem for en optælling.
func (c *Client) DailyPlan(ctx context.Context, tasks []string, due, overdue int, locale string) (string, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You write somebody's morning note about their own day.
Write in %s. At most three short lines, no greeting, no sign-off, no
encouragement. Say what to start with and why. Name at most one thing that could
wait or be dropped, and only if something obviously can.
These numbers are counted for you and are facts: %d due today, %d overdue. Do not
recount them, do not contradict them, and do not list every task back.`,
language, due, overdue)
return c.Complete(ctx, system, []Message{
{Role: "user", Content: "The tasks:\n" + strings.Join(tasks, "\n")},
})
}
// Ask svarer på et spørgsmål ud fra de tekststykker, den får med — og kun dem.
//
// Svaret skal bære sine kilder, og det er derfor svaret er JSON og ikke prosa: en
// model, der skriver "[3]" midt i en sætning, gør det af og til, og en, der
// afleverer nummeret i et felt, gør det hver gang. Fladen skal kunne lave et link,
// ikke lede efter kantede parenteser i en tekst.
//
// "Det står der ikke" er et rigtigt svar. En model, der hellere gætter end
// skuffer, er ubrugelig til netop det her: man spørger om sine egne noter, fordi
// man ikke selv kan huske det, og har derfor ingen mulighed for at se, at svaret
// var opfundet.
func (c *Client) Ask(ctx context.Context, question string, passages []string, locale string) (string, []int, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You answer a question from somebody's own notes and tasks.
Use only the passages given. If they do not answer the question, say so plainly —
that is a useful answer, and a guess is not: the person is asking because they
cannot remember, so they cannot tell an invented answer from a real one.
Write in %s, at most four short lines. Reply with a JSON object and nothing else:
{"answer": "...", "sources": [1, 3]} where sources are the numbers of the
passages the answer actually rests on, and nothing else.`, language)
prompt := "Question: " + question + "\n\nPassages:\n" + strings.Join(passages, "\n\n")
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
if err != nil {
return "", nil, err
}
var out struct {
Answer string `json:"answer"`
Sources []int `json:"sources"`
}
if err := json.Unmarshal([]byte(extractObject(reply)), &out); err != nil {
// Et svar, der ikke er JSON, er stadig et svar. Teksten er det, personen
// spurgte om; henvisningerne er det, der går tabt, og det er en dårligere
// dag end en fejlmeddelelse ville være.
return strings.TrimSpace(reply), nil, nil
}
return strings.TrimSpace(out.Answer), out.Sources, nil
}
// TasksFromMail skriver én opgavelinje pr. mail.
//
// Emnefeltet er skrevet til at blive genkendt i en indbakke, ikke til at blive
// gjort noget ved: "SV: SV: Vedr. levering uge 12" siger, hvilken tråd det er, og
// intet om, hvad man skal. Modellen skriver den handling, mailen beder om — og
// lader være, hvis den ikke beder om nogen.
func (c *Client) TasksFromMail(ctx context.Context, mails []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You turn emails into the tasks they ask for.
For each numbered email, write the line the recipient would have typed for
themselves — what they have to DO, in %s, starting with a verb. Keep the sender's
name in it when it matters who it is about. Not a summary of the email: the
action it asks for.
The line may carry a date in plain %s, and p1..p4 for priority, but only when the
email itself says so — "before friday" in the text is a date, an email that merely
sounds urgent is not. Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "N. ..."} keeping the number the email was given, so the line can be
put back on the right one. Leave an email out entirely if it asks for nothing.`,
language, language, today)
return c.suggestions(ctx, system, "Emails:\n"+strings.Join(mails, "\n"))
}
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
// der ikke er en linje.
//
@@ -521,22 +619,42 @@ func languageOf(locale string) string {
return "Danish"
}
// unfence tager svaret ud af den kodeblok, modeller pakker det ind i, uanset hvor
// omhyggeligt de bliver bedt om at lade være.
func unfence(reply string) string {
reply = strings.TrimSpace(reply)
fence := strings.Index(reply, "```")
if fence < 0 {
return reply
}
rest := reply[fence+3:]
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
rest = rest[newline+1:]
}
if end := strings.Index(rest, "```"); end >= 0 {
return strings.TrimSpace(rest[:end])
}
return reply
}
// extractObject er extractJSON for et objekt frem for en liste. Samme problem,
// samme løsning: modeller pakker svar ind i en kodeblok, hvor omhyggeligt de end
// bliver bedt om at lade være.
func extractObject(reply string) string {
reply = unfence(reply)
start := strings.IndexByte(reply, '{')
end := strings.LastIndexByte(reply, '}')
if start >= 0 && end > start {
return reply[start : end+1]
}
return reply
}
// extractJSON pulls an array out of a reply that may have been wrapped in a code
// fence or preceded by a sentence, which models do however firmly they are asked
// not to.
func extractJSON(reply string) string {
reply = strings.TrimSpace(reply)
if fence := strings.Index(reply, "```"); fence >= 0 {
rest := reply[fence+3:]
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
rest = rest[newline+1:]
}
if end := strings.Index(rest, "```"); end >= 0 {
reply = strings.TrimSpace(rest[:end])
}
}
reply = unfence(reply)
start := strings.IndexByte(reply, '[')
end := strings.LastIndexByte(reply, ']')
if start >= 0 && end > start {
+203
View File
@@ -0,0 +1,203 @@
package httpapi
import (
"net/http"
"sort"
"strings"
"time"
"github.com/kristianwind/verdande/internal/store"
)
// Spørg om sine egne noter og opgaver i almindeligt sprog.
//
// Søgningen finder det, der indeholder ordene. Det her svarer på spørgsmålet —
// "hvad lovede jeg Anders i august?" — og det er ikke det samme: svaret står
// spredt over tre noter og en opgave, og ingen af dem indeholder ordet "lovede".
//
// To halvdele, og rækkefølgen er hele sikkerheden i det. Basen finder kandidaterne
// med den søgning, der allerede findes, og som allerede kun kan se det, personen
// selv må se. Modellen får kun dem — den slår ikke selv noget op og kan ikke bede
// om mere. Et svar, der ikke står i det udleverede, kan derfor ikke handle om
// nogen andens noter.
//
// Og svaret bærer, hvad det er bygget på. En model, der svarer "det gjorde du den
// 14.", er kun brugbar, hvis man kan slå op i den note, den læste det i.
type askRequest struct {
Question string `json:"question"`
}
type askSourceJSON struct {
Kind string `json:"kind"` // "note" eller "task"
ID string `json:"id"`
Title string `json:"title"`
}
// handleAIAsk svarer på et spørgsmål ud fra personens egne noter og opgaver.
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
var req askRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
question := strings.TrimSpace(req.Question)
if question == "" {
writeFieldErrors(w, map[string]string{"question": "required"})
return
}
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
// Ord for ord, ikke hele spørgsmålet på én gang.
//
// Søgningen kræver, at *alle* ord optræder, og det er den rigtige regel for et
// søgefelt: skriver man to ord, mener man begge. Men et spørgsmål er ikke en
// søgning — "hvad lovede jeg Anders i august" indeholder ét ord, der står i
// noten, og fem, der ikke gør, så et krav om dem alle finder ingenting hver
// eneste gang.
//
// Så: et groft net her, og modellen læser fangsten. Alternativet — at lade
// modellen skrive søgningen — lægger et led mere ind, der kan tage fejl,
// mellem spørgsmålet og de noter, der faktisk findes.
notes, tasks, err := s.candidatesFor(r, user.ID, question)
if err != nil {
s.internal(w, r, "search for the question", err)
return
}
if len(notes) == 0 && len(tasks) == 0 {
writeJSON(w, http.StatusOK, map[string]any{
"answer": "", "sources": []askSourceJSON{},
})
return
}
// Nummereret, fordi svaret skal kunne pege tilbage. En henvisning til "noten
// om leveringen" er ikke noget, fladen kan lave et link ud af; et nummer er.
sources := make([]askSourceJSON, 0, len(notes)+len(tasks))
passages := make([]string, 0, len(notes)+len(tasks))
for _, n := range notes {
sources = append(sources, askSourceJSON{Kind: "note", ID: n.ID, Title: n.Title})
passages = append(passages, "["+itoaSource(len(sources))+"] Note: "+n.Title+"\n"+
firstWords(n.Body, 300))
}
for _, t := range tasks {
sources = append(sources, askSourceJSON{Kind: "task", ID: t.ID, Title: t.Content})
line := "[" + itoaSource(len(sources)) + "] Task: " + t.Content
if t.DueDate != "" {
line += " (" + t.DueDate + ")"
}
if t.Description != "" {
line += "\n" + firstWords(t.Description, 60)
}
passages = append(passages, line)
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
answer, used, err := client.Ask(ctx, question, passages, user.Locale)
if err != nil {
s.log.Warn("ai ask", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
// Kun de kilder, svaret faktisk brugte. Alle tolv ville gøre henvisningen
// værdiløs: en liste over alt, der blev kigget i, er ikke det samme som det,
// svaret står på.
cited := make([]askSourceJSON, 0, len(used))
for _, n := range used {
if n >= 1 && n <= len(sources) {
cited = append(cited, sources[n-1])
}
}
writeJSON(w, http.StatusOK, map[string]any{"answer": answer, "sources": cited})
}
func itoaSource(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
// candidatesFor henter det, spørgsmålet kunne handle om.
//
// Ét opslag pr. ord, lagt sammen og med det, der rammes af flere ord, øverst: et
// spørgsmål har ét eller to bærende ord og en håndfuld, der bare er dansk, og den
// note, der indeholder begge de bærende, er den, det handler om.
//
// Korte ord springes over. "i", "på" og "the" står i alt, og en note, der blev
// hentet, fordi den indeholder "i", fortrænger en, der faktisk svarer.
func (s *Server) candidatesFor(r *http.Request, userID, question string) ([]store.Note, []store.Task, error) {
terms := store.SearchTerms(question)
type scored struct {
note store.Note
task store.Task
hits int
}
noteHits := map[string]*scored{}
taskHits := map[string]*scored{}
for _, term := range terms {
if len([]rune(term)) < 3 {
continue
}
found, err := s.db.SearchNotes(r.Context(), term, 10)
if err != nil {
return nil, nil, err
}
for _, n := range found {
if at, ok := noteHits[n.ID]; ok {
at.hits++
continue
}
noteHits[n.ID] = &scored{note: n, hits: 1}
}
tasks, err := s.db.ListTasks(r.Context(), userID, store.TaskFilter{
Search: term, Limit: 10, IncludeCompleted: true,
})
if err != nil {
return nil, nil, err
}
for _, t := range tasks {
if at, ok := taskHits[t.ID]; ok {
at.hits++
continue
}
taskHits[t.ID] = &scored{task: t, hits: 1}
}
}
notes := make([]store.Note, 0, len(noteHits))
for _, at := range noteHits {
notes = append(notes, at.note)
}
sort.SliceStable(notes, func(i, j int) bool {
return noteHits[notes[i].ID].hits > noteHits[notes[j].ID].hits
})
tasks := make([]store.Task, 0, len(taskHits))
for _, at := range taskHits {
tasks = append(tasks, at.task)
}
sort.SliceStable(tasks, func(i, j int) bool {
return taskHits[tasks[i].ID].hits > taskHits[tasks[j].ID].hits
})
// Loftet er, hvad der kan stå i én prompt uden at koste mere, end svaret er
// værd — og hvad et menneske ville have gidet læse igennem selv.
return capped(notes, 12), capped(tasks, 20), nil
}
func capped[T any](in []T, n int) []T {
if len(in) > n {
return in[:n]
}
return in
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"context"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
)
// En mail, der bliver til en brugbar opgave.
//
// Uden det her hedder opgaven, hvad mailen hed: "Anders Jensen: SV: SV: Vedr.
// levering uge 12". Det er nok til at genkende den og ikke nok til at gøre noget
// ved den — og en indbakke af den slags linjer er præcis den bunke, man havde
// mail for at slippe for.
//
// Ét kald pr. gennemløb frem for ét pr. mail. Femogtyve mails er femogtyve gange
// ventetid og femogtyve gange betaling for det samme spørgsmål, og et gennemløb,
// der tager fem minutter, er et, der overlapper med det næste.
//
// Falder det ud, er der ikke sket noget: opgaven hedder afsender og emne, som den
// altid har gjort. En model, der er nede, må ikke kunne stoppe posten.
// mailForAI er én mail, som modellen får den at se.
type mailForAI struct {
From string
Subject string
Snippet string
}
// mailTaskLines beder modellen om én linje pr. mail, i quick-add-syntaks.
//
// Returnerer en tabel fra mailens plads i listen til linjen. Ikke en liste, fordi
// en model, der springer en mail over, ellers ville flytte alle de følgende
// linjer én plads — og så ville en opgave hedde noget, der stod i en anden mail.
// Det er den samme forsigtighed som ved oprydningen i indbakken, og den samme
// grund: en forskydning på én er den slags fejl, ingen opdager.
func (s *Server) mailTaskLines(ctx context.Context, user *store.User, mails []mailForAI) map[int]string {
if len(mails) == 0 {
return nil
}
cfg, err := s.aiConfigFor(ctx, user)
if err != nil || !cfg.Configured() {
return nil
}
lines := make([]string, 0, len(mails))
for i, m := range mails {
line := strconv.Itoa(i+1) + ". " + m.From + " — " + m.Subject
if m.Snippet != "" {
line += "\n " + firstWords(m.Snippet, 40)
}
lines = append(lines, line)
}
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
out, err := ai.New(cfg).TasksFromMail(ctx, lines, today, user.Locale)
if err != nil {
s.log.Info("mail tasks without the model", "err", err, "user", user.ID)
return nil
}
byIndex := map[int]string{}
for i, sug := range out {
n, line := numberedLine(sug.Line)
if n == 0 {
n = i + 1
}
if n < 1 || n > len(mails) || strings.TrimSpace(line) == "" {
continue
}
byIndex[n-1] = line
}
return byIndex
}
// applyMailLine lægger modellens linje ind i opgaven, hvis der er en.
//
// Linjen går gennem quick-add-parseren som alt andet, så en mail, hvor der står
// "inden fredag", får en dato — og "#Firma" i en linje, modellen fandt på, flytter
// ingenting, for projektet slås op blandt dem, der findes.
//
// Emnet bliver stående i beskrivelsen. Modellen har skrevet en titel ud fra det,
// og hvis den har misforstået mailen, skal det oprindelige stadig kunne læses ved
// siden af — ellers er den eneste vej tilbage at finde mailen frem igen.
func (s *Server) applyMailLine(ctx context.Context, user *store.User, task *store.Task, line, subject string) {
if strings.TrimSpace(line) == "" {
return
}
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
return
}
task.Content = parsed.Content
if parsed.Priority != 0 {
task.Priority = parsed.Priority
}
if parsed.DueDate != "" {
task.DueDate = parsed.DueDate
}
if parsed.Project != "" {
if id, err := s.db.ProjectByName(ctx, user.ID, parsed.Project); err == nil {
if _, err := store.RequireProjectRole(ctx, s.db, id, user.ID, store.RoleEditor); err == nil {
task.ProjectID = id
}
}
}
if subject != "" {
task.Description = strings.TrimSpace(subject + "\n\n" + task.Description)
}
}
+327
View File
@@ -0,0 +1,327 @@
package httpapi
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/store"
)
// Dagens plan: én besked om morgenen om det, der venter i dag.
//
// Klokken siger, hvad andre har gjort. Det her siger, hvad der ligger foran en —
// og det er en anden slags besked: den kommer, fordi det er blevet morgen, ikke
// fordi nogen har rørt noget. Uden den skal man selv åbne listen for at opdage,
// at der lå tre ting og forfaldt i går.
//
// Sendt som en besked frem for en mail, fordi røret allerede findes: klokken,
// push til telefonen og mærket på ikonet er ét kald, og en mail mere i indbakken
// er præcis det, dette program findes for at slippe for.
const planScope = "dailyplan"
// planSettings er, hvad én person har bedt om.
//
// Slået fra som udgangspunkt. En besked, man ikke har bedt om, klokken syv om
// morgenen, er ikke en hjælp — og modsat beaconet er der ingen, der har brug for,
// at det her er tændt for at kunne gøre sit arbejde.
type planSettings struct {
Enabled bool `json:"enabled"`
// Hour er lokal tid hos personen selv. Et tal og ikke et klokkeslæt: planen
// sendes på timen, og et minuttal ville love en præcision, en time-tikkende
// baggrundsjob ikke har.
Hour int `json:"hour"`
// LastSent er datoen, den sidst blev sendt, som "2006-01-02" i personens egen
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
LastSent string `json:"last_sent,omitempty"`
}
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
out := planSettings{Hour: 7}
stored, err := s.db.UserSettings(ctx, userID, planScope)
if err != nil {
return out, err
}
if v, ok := stored["enabled"].(bool); ok {
out.Enabled = v
}
if v, ok := stored["hour"].(float64); ok && v >= 0 && v <= 23 {
out.Hour = int(v)
}
if v, ok := stored["last_sent"].(string); ok {
out.LastSent = v
}
return out, nil
}
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
})
}
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
writeJSON(w, http.StatusOK, p)
}
type planRequest struct {
Enabled *bool `json:"enabled"`
Hour *int `json:"hour"`
}
func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
var req planRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
p, err := s.planSettings(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
if req.Enabled != nil {
p.Enabled = *req.Enabled
}
if req.Hour != nil {
if *req.Hour < 0 || *req.Hour > 23 {
writeFieldErrors(w, map[string]string{"hour": "must be between 0 and 23"})
return
}
p.Hour = *req.Hour
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu.
// Uden det her ville en, der flytter planen fra 07 til 16, først få den i
// morgen — hvilket ligner, at indstillingen ikke virkede.
p.LastSent = ""
}
if err := s.savePlanSettings(r.Context(), user.ID, p); err != nil {
s.internal(w, r, "save plan settings", err)
return
}
writeJSON(w, http.StatusOK, p)
}
// handlePlanNow sender dagens plan med det samme, til den der beder om det.
//
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
// af, er en indstilling, ingen tør slå til.
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
plan, err := s.buildPlan(r.Context(), user)
if err != nil {
s.internal(w, r, "build plan", err)
return
}
if plan == "" {
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
return
}
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true})
}
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
// endnu i dag.
//
// Kaldt hver time frem for lagt på et klokkeslæt — samme form som backup og
// beacon, og af samme grund: en instans, der sov klokken syv, ville ellers
// springe dagen over. Og fordi klokken syv er syv forskellige steder: personens
// egen tidszone afgør, hvornår det er morgen hos dem.
func (s *Server) DailyPlans(ctx context.Context) error {
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
for _, u := range users {
user, err := s.db.UserByID(ctx, u.ID)
if err != nil {
continue
}
p, err := s.planSettings(ctx, user.ID)
if err != nil || !p.Enabled {
continue
}
now := time.Now().In(userLocation(user.Timezone))
today := now.Format("2006-01-02")
if p.LastSent == today || now.Hour() < p.Hour {
continue
}
// Kun samme dag. En instans, der har været slukket i tre dage, skal ikke
// sende tre planer, når den kommer op — de to af dem er om dage, der er
// gået, og en plan for i forgårs er ikke en plan.
if now.Hour() > p.Hour+3 {
p.LastSent = today
_ = s.savePlanSettings(ctx, user.ID, p)
continue
}
plan, err := s.buildPlan(ctx, user)
if err != nil {
s.log.Warn("build daily plan", "err", err, "user", user.ID)
continue
}
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god
// morgen, og en besked, der siger "der er ingenting", lærer folk at lade
// være med at åbne beskeder.
if plan != "" {
s.notifyDirect(ctx, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
}
p.LastSent = today
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
s.log.Warn("save plan settings", "err", err, "user", user.ID)
}
}
return nil
}
// buildPlan skriver dagens plan.
//
// Tallene kommer fra basen og sætningen fra modellen — i den rækkefølge. En model,
// der selv skulle tælle, ville af og til tælle forkert, og en plan, der siger fire
// opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model sat op,
// står tallene alene, og det er stadig det, man havde brug for at vide.
func (s *Server) buildPlan(ctx context.Context, user *store.User) (string, error) {
loc := userLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format("2006-01-02")
// I går, ikke i dag: grænserne i filteret er inklusive, så "før i dag" skrevet
// som DueBefore: today ville tælle dagens egne opgaver med som sprunget over —
// og en plan, der klokken syv om morgenen siger, at dagens opgaver allerede er
// overskredet, er en plan, man holder op med at læse.
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
overdue, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{DueBefore: yesterday, Limit: 50})
if err != nil {
return "", err
}
due, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
DueFrom: today, DueBefore: today, Limit: 50,
})
if err != nil {
return "", err
}
if len(overdue) == 0 && len(due) == 0 {
return "", nil
}
plain := plainPlan(len(due), len(overdue), user.Locale)
cfg, err := s.aiConfigFor(ctx, user)
if err != nil || !cfg.Configured() {
return plain, nil
}
lines := make([]string, 0, len(due)+len(overdue))
for _, t := range overdue {
lines = append(lines, "- "+t.Content+" ("+overdueWord(user.Locale)+" "+t.DueDate+")")
}
for _, t := range due {
lines = append(lines, "- "+t.Content)
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
written, err := ai.New(cfg).DailyPlan(ctx, lines, len(due), len(overdue), user.Locale)
if err != nil {
// Modellen er ikke det, planen står og falder med. Tallene er rigtige uden
// den, og en morgen uden besked, fordi en API-nøgle er udløbet, er en
// dårligere handel end en besked uden en velskrevet sætning.
s.log.Info("daily plan without the model", "err", err, "user", user.ID)
return plain, nil
}
return strings.TrimSpace(written), nil
}
// aiConfigFor er aiConfig uden en request bag sig — baggrundsjobbet har ingen.
//
// AllowPrivate er falsk her, og det er med vilje: retten til at nå det private
// net blev givet til en administrator, der sad og skrev adressen ind, og et job,
// der kører klokken syv om morgenen, er ikke nogen, der sidder og skriver. En
// model på maskinen selv skal derfor bruges gennem fladen, ikke af planen.
func (s *Server) aiConfigFor(ctx context.Context, user *store.User) (ai.Config, error) {
values, err := s.db.UserSettings(ctx, user.ID, "ai")
if err != nil {
return ai.Config{}, err
}
str := func(key string) string {
v, _ := values[key].(string)
return v
}
return ai.Config{
Provider: ai.Provider(str("provider")),
APIKey: str("api_key"),
BaseURL: str("base_url"),
Model: str("model"),
}, nil
}
// notifyDirect er notify uden en request. Baggrundsjobbet har ingen bruger i
// konteksten og ingen actor — planen er ikke noget, nogen har gjort ved en.
func (s *Server) notifyDirect(ctx context.Context, n *store.Notification) {
if err := s.db.CreateNotification(ctx, n); err != nil {
s.log.Warn("create notification", "err", err)
return
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, Kind: n.Kind, Title: n.Title, Body: n.Body,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
go s.pushToUser(n.UserID, n.Title, n.Body, "")
}
func planTitle(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "Today"
}
return "I dag"
}
func overdueWord(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "was due"
}
return "forfaldt"
}
// plainPlan er planen uden en model: to tal, og ikke et ord mere.
//
// Det er ikke en nødløsning, det er bunden. Det, man har brug for at vide klokken
// syv, er om der er noget, der er sprunget over — og det tal er rigtigt, uanset
// om nogen har lagt en API-nøgle ind.
func plainPlan(due, overdue int, locale string) string {
english := strings.HasPrefix(strings.ToLower(locale), "en")
var parts []string
if due > 0 {
if english {
parts = append(parts, strconv.Itoa(due)+" due today")
} else {
parts = append(parts, strconv.Itoa(due)+" forfalder i dag")
}
}
if overdue > 0 {
if english {
parts = append(parts, strconv.Itoa(overdue)+" overdue")
} else {
parts = append(parts, strconv.Itoa(overdue)+" er sprunget over")
}
}
return strings.Join(parts, ", ")
}
+96
View File
@@ -0,0 +1,96 @@
package httpapi
import (
"net/http"
"testing"
)
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
// hvor der er tre, er værre end ingen plan — så tallene tælles her, og modellen
// får dem udleveret som kendsgerninger.
func TestTheDailyPlanCountsForItself(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "ring til anders i dag")
ts.quickTask(t, "køb mælk i dag")
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("send planen: %d %v", resp.StatusCode, out)
}
if out["sent"] != true {
t.Fatalf("planen blev ikke sendt: %v", out)
}
// Uden en model står tallene alene, og det er stadig det, man havde brug for
// at vide klokken syv.
if plan, _ := out["plan"].(string); plan != "2 forfalder i dag" {
t.Errorf("plan = %q", plan)
}
// Og den lander i klokken som en besked.
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
if n := firstOf(t, list); n["kind"] != "daily.plan" {
t.Errorf("besked = %v", n)
}
}
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god morgen, og
// en besked, der siger "der er ingenting", lærer folk at lade være med at åbne
// beskeder.
func TestAnEmptyDaySendsNoPlan(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("send planen: %d", resp.StatusCode)
}
if out["sent"] != false {
t.Errorf("der blev sendt en plan om ingenting: %v", out)
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("beskeder = %d, want 0", len(list))
}
}
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu. Uden det
// ville en, der flytter planen fra 07 til 16, først få den i morgen — hvilket
// ligner, at indstillingen ikke virkede.
func TestMovingThePlanHourClearsTodaysMark(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{
"enabled": true, "hour": 7,
}); resp.StatusCode != http.StatusOK {
t.Fatal("slå planen til")
}
if _, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil); out["sent"] != true {
t.Fatalf("planen blev ikke sendt: %v", out)
}
resp, out := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 16})
if resp.StatusCode != http.StatusOK {
t.Fatalf("flyt timen: %d", resp.StatusCode)
}
if out["hour"] != float64(16) {
t.Errorf("time = %v", out["hour"])
}
if out["last_sent"] != nil && out["last_sent"] != "" {
t.Errorf("mærket for i dag blev stående: %v", out["last_sent"])
}
}
// En time, der ikke findes på et ur, er en fejl i feltet og ikke en 500.
func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 25}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
}
}
+45
View File
@@ -187,3 +187,48 @@ func (ts *testServer) quickTask(t *testing.T, text string) string {
}
return id
}
// Svaret på et spørgsmål bærer, hvad det står på — og kun det, det faktisk brugte.
// En liste over alt, der blev kigget i, er ikke en henvisning.
func TestAskingAnswersFromYourOwnNotes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `{"answer": "Du lovede Anders prislisten fredag.", "sources": [1]}`))
ts.createNote(t, "# Møde med Anders\njeg sender prislisten fredag")
ts.createNote(t, "# Noget helt andet\nanders er ikke nævnt her")
resp, out := ts.do(t, "POST", "/api/v1/ai/ask", map[string]any{"question": "hvad lovede jeg Anders?"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("spørg: %d %v", resp.StatusCode, out)
}
if out["answer"] != "Du lovede Anders prislisten fredag." {
t.Errorf("svar = %v", out["answer"])
}
sources, _ := out["sources"].([]any)
if len(sources) != 1 {
t.Fatalf("kilder = %v, want én", sources)
}
src := sources[0].(map[string]any)
if src["kind"] != "note" || src["title"] != "Møde med Anders" {
t.Errorf("kilde = %v", src)
}
}
// Et spørgsmål, der ikke rammer noget, får ikke et opfundet svar. Man spørger om
// sine egne noter, fordi man ikke selv kan huske det, og kan derfor ikke se, at
// svaret var gættet.
func TestAskingAboutNothingAnswersNothing(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `{"answer": "det burde du huske", "sources": []}`))
resp, out := ts.do(t, "POST", "/api/v1/ai/ask",
map[string]any{"question": "kvadratrodenafenbanan"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("spørg: %d", resp.StatusCode)
}
if out["answer"] != "" {
t.Errorf("der blev svaret uden noget at svare ud fra: %v", out["answer"])
}
}
+28 -2
View File
@@ -332,7 +332,16 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
projectID = inbox
}
created := 0
// Hentet først, skrevet bagefter. To gennemløb frem for ét, fordi modellen
// skal se hele bunken på én gang: ét spørgsmål for femogtyve mails frem for
// femogtyve spørgsmål er den samme oplysning til en femogtyvendedel af
// ventetiden — og et gennemløb, der tager fem minutter, overlapper med det
// næste.
type fetched struct {
id string
msg gmail.Message
}
var pending []fetched
for _, id := range ids {
if seen[id] {
continue
@@ -340,13 +349,27 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// A spent budget ends the run rather than turning into twenty-five failed
// fetches logged one at a time.
if ctx.Err() != nil {
return created, ctx.Err()
return 0, ctx.Err()
}
msg, err := client.Get(ctx, id)
if err != nil {
s.log.Warn("gmail get message", "err", err, "id", id)
continue
}
pending = append(pending, fetched{id: id, msg: msg})
}
forAI := make([]mailForAI, 0, len(pending))
for _, f := range pending {
forAI = append(forAI, mailForAI{
From: gmail.SenderName(f.msg.From), Subject: f.msg.Subject, Snippet: f.msg.Snippet,
})
}
written := s.mailTaskLines(ctx, user, forAI)
created := 0
for i, f := range pending {
id, msg := f.id, f.msg
subject := strings.TrimSpace(msg.Subject)
if subject == "" {
@@ -367,6 +390,9 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// key exists to prevent.
SourceKey: "gmail:thread:" + msg.ThreadID,
}
// Og modellens linje, hvis den skrev en til netop den her mail.
s.applyMailLine(ctx, user, task, written[i], sender+": "+subject)
if err := s.db.CreateTask(ctx, task, nil); err != nil {
if errors.Is(err, store.ErrDuplicate) {
// Somebody got there first — an earlier sweep, or another
+15 -1
View File
@@ -173,13 +173,22 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
return 0, err
}
// Ét spørgsmål til modellen for hele gennemløbet, inden der bliver skrevet
// noget. Er der ingen model, er kortet tomt, og hver opgave hedder afsender og
// emne, som den altid har gjort.
forAI := make([]mailForAI, 0, len(messages))
for _, msg := range messages {
forAI = append(forAI, mailForAI{From: msg.From, Subject: msg.Subject, Snippet: msg.Snippet})
}
written := s.mailTaskLines(ctx, user, forAI)
created := 0
highest := m.LastUID
// The uids of the mails that actually became tasks. Only those get unflagged,
// and only after the task is committed: a flag removed from a mail whose task
// was never written is a mail nobody will ever see again.
done := make([]uint32, 0, len(messages))
for _, msg := range messages {
for i, msg := range messages {
if ctx.Err() != nil {
// What was made so far is already committed and counted, and the marker
// below has to be written for it: a run that created tasks and forgot how
@@ -206,6 +215,11 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
// one server, and two accounts will hand out the same small numbers.
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
}
// Og modellens linje, hvis den skrev en til netop den her. Emnet bliver
// stående i beskrivelsen: har den misforstået mailen, skal det oprindelige
// kunne læses ved siden af.
s.applyMailLine(ctx, user, task, written[i], content)
if err := s.db.CreateTask(ctx, task, nil); err != nil {
if errors.Is(err, store.ErrDuplicate) {
// Already a task. Still count it as read, or last_uid never moves
+10
View File
@@ -359,6 +359,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
// Dagens plan: indstillingen, og en knap til at se den nu. En
// indstilling, man skal vente til i morgen for at se virkningen af,
// er en indstilling, ingen tør slå til.
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
// det er bygget på.
r.Post("/ask", s.handleAIAsk)
})
r.Get("/version", s.handleVersion)
+16
View File
@@ -49,6 +49,11 @@ type Runner struct {
// layer, which owns the VAPID keys and the subscription list.
Push func(userID, title, body, projectID string)
// DailyPlans sender morgenens plan til dem, der har bedt om en. Sat af
// serveren af samme grund som SendBeacon: opsætningen, tidszonen og
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
DailyPlans func(ctx context.Context) error
// SendBeacon reports this installation to the collector, if it is switched on
// and a day has passed. Supplied by the HTTP layer for the same reason as the
// syncs: that is where the instance settings and the version live.
@@ -89,6 +94,10 @@ func (r *Runner) Start(ctx context.Context) {
// the same reason: a container asleep at the scheduled minute would otherwise
// skip the day. SendBeacon decides whether a day has passed.
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
// Dagens plan. Hver time, sendt højst én gang om dagen pr. person — samme
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
// syv er syv forskellige steder.
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -123,6 +132,13 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
}()
}
func (r *Runner) dailyPlans(ctx context.Context) error {
if r.DailyPlans == nil {
return nil
}
return r.DailyPlans(ctx)
}
// sendBeacon reports that this installation exists, at most once a day.
//
// Every decision about what is sent, and why this is on by default rather than
+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>