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:
co-authored by
Claude Opus 5
parent
40102d6038
commit
8139003b35
@@ -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{
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user