Compare commits

...
Author SHA1 Message Date
Kristian Wind e931ddc313 Merge pull request #70 from kristianwind/ny-version-siger-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m50s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
Release / Build and publish image (push) Failing after 5s
feat(opdatering): en ny version siger til, i stedet for at vente på at blive fundet
2026-09-08 17:05:52 +02:00
kristianwindandClaude Opus 5 2210dc082a feat(opdatering): en ny version siger til, i stedet for at vente på at
blive fundet

Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på
indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis
man i forvejen havde mistanke om, at der var noget at se efter — og "der
er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter
sig til at kigge efter.

Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der
er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder,
frem for ud af programmet.

Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
samme er den slags besked, folk slår fra — og så er den næste, der
betyder noget, også slået fra. Versionen, der er sagt til om, skrives
først, når beskederne er sendt: den anden vej rundt ville en fejl
midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.

Kun administratorer, af samme grund som versionsfeltet allerede er det:
en besked om, at serveren er bagud, er en besked om en andens arbejde,
hvis man ikke selv kan gøre noget ved den.

Og et tjek, operatøren har slået fra, bliver ikke til en besked ad
bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:55:53 +02:00
Kristian Wind 9fa47822c1 Merge pull request #69 from kristianwind/ai-dagens-plan-2
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m57s
CI / Docker image builds (push) Successful in 2m19s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m3s
feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
2026-09-08 15:15:48 +02:00
Kristian Wind 366bed3a15 Merge pull request #68 from kristianwind/ai-foreslaar-du-bestemmer
feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
2026-09-08 15:06:21 +02:00
Kristian Wind 07fc170b91 Merge pull request #67 from kristianwind/beacon-siger-det-hoejt
feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
2026-09-08 15:06:17 +02:00
kristianwindandClaude Opus 5 8139003b35 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>
2026-09-08 15:05:36 +02:00
kristianwindandClaude Opus 5 96e1fa251b fix(beacon): beskeden skubber i stedet for at dække
Den lå som en rude i hjørnet, oven på arbejdet, og dækkede knapperne i
notens fod og i delepanelet — seks e2e-prøver klikkede forgæves på noget,
der lå under den.

Det var ikke kun et prøveproblem. En besked, der er i vejen, bliver
klikket væk uden at blive læst, og så er den uopfordrede oplysning en
formalitet frem for noget, nogen har set. Nu står den øverst i indholdet
og skubber siden ned, indtil den bliver besvaret.

Og e2e beviser den nu frem for at klikke den væk: den første konto skal
møde beskeden, og der skal stå et instance_id og en version i den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:47 +02:00
kristianwindandClaude Opus 5 0cf5c8c281 feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
Beaconet er slået til som udgangspunkt. Det kan kun forsvares, hvis det
bliver sagt af sig selv — og det blev det ikke. Det stod rigtigt og
fyldestgørende under Indstillinger → Data, som er en side, der bliver
åbnet af dem, der i forvejen har en mistanke om, at der er noget at læse.
For alle andre var "du kan slå den fra" et tilbud til nogen, der ikke
vidste, der var noget at slå fra.

Nu møder en administrator beskeden første gang: hvad der bliver sendt,
med de rigtige værdier i — det rigtige id, den rigtige version, den
rigtige adresse — og to knapper. Værdierne står der frem for ordet
"anonymt", fordi et løfte om telemetri er præcis så meget værd som
læserens mulighed for at efterprøve det.

`keep` er en peger og ikke en bool. En tom krop måtte ikke kunne betyde
"slå det fra": et fravalg, ingen har truffet, er lige så meget et brudt
løfte som et tilvalg, ingen har truffet.

Og tre ting mere, som Yggdrasil har lært på den hårde måde:

Et beacon, der ikke kan nå sin collector, fejlede i fuldstændig tavshed.
Det prøvede igen i morgen, siden sagde "sidst sendt" med en dato, der
bare blev ældre, og ingen opdagede det. Grunden bliver nu skrevet ned og
vist.

En installation, der opdaterer sig selv om natten, meldte gårsdagens
version i op til et døgn, fordi døgnet blev talt fra sidste vellykkede
melding. En version, der er skiftet, sender nu med det samme.

Og det offentliggjorte tal har fået et gulv. Et tal, der falder, kan ikke
offentliggøres som en kendsgerning — en dårlig udrulning, en
DNS-ændring, der stille stopper meldingerne, og rækker, der er væk, ser
alle sammen ud som "færre bruger det nu".

Adressen bliver stående: Verdande melder til verdande.yggdrasilpanel.com,
ikke til panelets egen collector. De to tæller hver sin slags
installation, og en fælles tabel har intet felt til at holde dem ude fra
hinanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:39:14 +02:00
26 changed files with 2226 additions and 29 deletions
+2
View File
@@ -104,6 +104,8 @@ func run() error {
runner.SyncCalendars = api.SyncCalendars
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.NotifyOfUpdates = api.NotifyOfUpdates
runner.Start(jobCtx)
srv := &http.Server{
+206 -1
View File
@@ -1564,7 +1564,15 @@ paths:
schema:
type: object
properties:
installs: { type: integer }
installs:
type: [integer, "null"]
description: |
Null below the collector's floor (`publish_min`, 25 by
default). A number that *falls* cannot be published as a
fact — a bad release, a DNS change that quietly stops the
pings, or lost rows all look like "fewer people use this
now" — and a small number mostly says how little it takes
to move it.
window_days: { type: integer }
"404":
description: Not a collector, or not published.
@@ -1590,7 +1598,19 @@ paths:
version: { type: string }
is_collector: { type: boolean }
publish_count: { type: boolean }
publish_min:
type: integer
description: The floor under the published count.
last_ping_at: { type: string, format: date-time }
last_error:
type: string
description: |
Why the last attempt did not arrive; absent when the last
one did. Shown rather than only logged: a beacon that
cannot reach its collector otherwise fails in complete
silence, and the only trace is a "last sent" date that
quietly gets older.
last_error_at: { type: string, format: date-time }
put:
tags: [auth]
summary: Turn the beacon off, or point it somewhere else. Administrators only.
@@ -1610,12 +1630,74 @@ paths:
make the server reach inside its own network.
is_collector: { type: boolean }
publish_count: { type: boolean }
publish_min: { type: integer, minimum: 0 }
responses:
"200":
description: The configuration as it now stands.
"422":
description: The collector address is not an http or https URL.
/api/v1/beacon/notice:
get:
tags: [auth]
summary: Whether the disclosure is still owed, and what it says. Administrators only.
description: |
The beacon is on by default, and that is only defensible if it is said
unprompted. A settings page where it is stated fully is not the same as
saying it: that page is opened by people who already suspect there is
something to read.
The three values are in the response — the real id, the real version, the
real collector address — so the notice can show what is sent rather than
say "anonymous data" and ask to be believed.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
pending:
type: boolean
description: False once anybody has answered, panel-wide.
enabled: { type: boolean }
instance_id: { type: string }
version: { type: string }
collector_url: { type: string }
post:
tags: [auth]
summary: Answer the notice. Administrators only.
description: |
One call for both answers — "keep it on" and "turn it off" are the same
act, somebody deciding, and two endpoints would mean one of them could
forget to record that a decision was made.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [keep]
properties:
keep:
type: boolean
description: |
Required, and rejected when absent. An empty body must not be
able to mean "turn it off": an opt-out nobody chose is as much
a broken promise as an opt-in nobody chose.
responses:
"200":
description: Recorded.
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
"422":
description: No answer was given.
/api/v1/panel/restart:
post:
tags: [auth]
@@ -3568,6 +3650,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 +4542,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"])
}
}
+191 -13
View File
@@ -20,6 +20,11 @@ import (
const beaconScope = "beacon"
// defaultPublishMin er, hvor mange installationer der skal være, før tallet
// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
const defaultPublishMin = 25
// beaconConfig is what this instance has been told to do. The zero value is what
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
// not here — a zero struct must never mean "silently on".
@@ -30,6 +35,33 @@ type beaconConfig struct {
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt int64 `json:"last_ping_at,omitempty"`
// LastVersion er den version, der sidst nåede frem. Gemt, fordi en
// installation, der opdaterer sig selv om natten, ellers står som gårsdagens
// version i op til et døgn — og en tælling efter version, der er en dag bagud,
// kan man ikke se en udrulning i.
LastVersion string `json:"last_version,omitempty"`
// LastError er grunden til, at det sidste forsøg ikke nåede frem.
//
// Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
// collector, prøver igen i morgen og siger ingenting, og det opdages først ved
// at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
// der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
// alt er sendt.
LastError string `json:"last_error,omitempty"`
LastErrorAt int64 `json:"last_error_at,omitempty"`
// NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
//
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
// uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
// det, der husker, at det er blevet sagt.
NoticeAck bool `json:"notice_ack"`
// PublishMin er gulvet under det offentliggjorte tal.
//
// Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
// udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
// er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
// der ingenting frem for et lille tal, ingen kan tolke.
PublishMin int `json:"publish_min"`
}
// beaconSettings reads the configuration, filling in the defaults and minting the
@@ -40,7 +72,9 @@ type beaconConfig struct {
// also be a value the collector could compare against a guess — which is the
// difference between anonymous and pseudonymous, and it is the whole point.
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
cfg := beaconConfig{
Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
}
stored, err := s.db.InstanceSettings(ctx, beaconScope)
if err != nil {
@@ -65,6 +99,21 @@ func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
if v, ok := stored["last_ping_at"].(float64); ok {
cfg.LastPingAt = int64(v)
}
if v, ok := stored["last_version"].(string); ok {
cfg.LastVersion = v
}
if v, ok := stored["last_error"].(string); ok {
cfg.LastError = v
}
if v, ok := stored["last_error_at"].(float64); ok {
cfg.LastErrorAt = int64(v)
}
if v, ok := stored["notice_ack"].(bool); ok {
cfg.NoticeAck = v
}
if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
cfg.PublishMin = int(v)
}
if cfg.InstanceID == "" {
cfg.InstanceID = store.NewID()
@@ -83,20 +132,31 @@ func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error
"is_collector": cfg.IsCollector,
"publish_count": cfg.PublishCount,
"last_ping_at": cfg.LastPingAt,
"last_version": cfg.LastVersion,
"last_error": cfg.LastError,
"last_error_at": cfg.LastErrorAt,
"notice_ack": cfg.NoticeAck,
"publish_min": cfg.PublishMin,
})
}
// --- what the settings page shows ------------------------------------------------
type beaconStatusJSON struct {
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
Version string `json:"version"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt string `json:"last_ping_at,omitempty"`
Stats *store.BeaconStats `json:"stats,omitempty"`
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
Version string `json:"version"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
PublishMin int `json:"publish_min"`
LastPingAt string `json:"last_ping_at,omitempty"`
// Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
// frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
// det eneste spor, et tavst beacon ellers efterlader.
LastError string `json:"last_error,omitempty"`
LastErrorAt string `json:"last_error_at,omitempty"`
Stats *store.BeaconStats `json:"stats,omitempty"`
}
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
@@ -109,10 +169,15 @@ func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
out := beaconStatusJSON{
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
PublishMin: cfg.PublishMin,
}
if cfg.LastPingAt > 0 {
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
}
if cfg.LastError != "" {
out.LastError = cfg.LastError
out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
}
if cfg.IsCollector {
stats, err := s.db.BeaconSummary(r.Context())
if err != nil {
@@ -129,6 +194,7 @@ type beaconRequest struct {
Collector *string `json:"collector_url"`
IsCollector *bool `json:"is_collector"`
PublishCount *bool `json:"publish_count"`
PublishMin *int `json:"publish_min"`
}
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
@@ -151,6 +217,13 @@ func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
if req.PublishCount != nil {
cfg.PublishCount = *req.PublishCount
}
if req.PublishMin != nil {
if *req.PublishMin < 0 {
writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
return
}
cfg.PublishMin = *req.PublishMin
}
if req.Collector != nil {
url := strings.TrimSpace(*req.Collector)
if url == "" {
@@ -237,6 +310,16 @@ func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
}
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
if err == nil && n < cfg.PublishMin {
// Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
// hvor lidt der skal til at flytte det — og et, der falder, ville blive
// læst som "færre bruger det nu", også når det i virkeligheden var en
// udrulning, der gik galt, eller en adresse, der stille holdt op med at
// blive ramt.
w.Header().Set("Cache-Control", "public, max-age=60")
writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
return
}
if err != nil {
s.internal(w, r, "beacon count", err)
return
@@ -269,11 +352,15 @@ func (s *Server) SendBeacon(ctx context.Context) error {
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
return err
}
cfg.LastPingAt = time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
return s.beaconSent(ctx, cfg)
}
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
// Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
// opdaterer sig selv om natten og først melder ind igen et døgn senere, står
// som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
// tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
// et døgn efter at være opdateret.
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
return nil
}
@@ -289,9 +376,100 @@ func (s *Server) SendBeacon(ctx context.Context) error {
// installation's problem and must never look like one — it is somebody
// else's server, and the only consequence is a number being one lower.
s.log.Info("beacon not delivered", "err", err)
return nil
// Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
// collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
// "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
// Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
// savne en.
cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
}
return s.beaconSent(ctx, cfg)
}
// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
// siden om noget, der virker.
func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
cfg.LastPingAt = time.Now().Unix()
cfg.LastVersion = Version
cfg.LastError, cfg.LastErrorAt = "", 0
return s.saveBeaconSettings(ctx, cfg)
}
// --- beskeden, der bliver sagt uopfordret ------------------------------------------
// handleBeaconNotice says whether the disclosure is still owed, and what it would
// say.
//
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
// dem, der ved, at der er noget at slå fra.
//
// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
// løfte, der er noget værd: en, læseren selv kan kontrollere.
//
// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
// ikke kan svare på det.
func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"pending": !cfg.NoticeAck,
"enabled": cfg.Enabled,
"instance_id": cfg.InstanceID,
"version": Version,
"collector_url": cfg.Collector,
})
}
type beaconNoticeRequest struct {
// Keep er en peger og ikke en bool, og det er hele pointen.
//
// En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
// aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
// løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
// tilsvarende ende og er blevet bidt af den.
Keep *bool `json:"keep"`
}
// handleAckBeaconNotice records that the notice has been seen, and turns the
// beacon off if that was the answer.
//
// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
var req beaconNoticeRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Keep == nil {
writeFieldErrors(w, map[string]string{"keep": "required"})
return
}
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
cfg.NoticeAck = true
cfg.Enabled = *req.Keep
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
s.internal(w, r, "save beacon settings", err)
return
}
s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
}
+132
View File
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"testing"
)
// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent besked: %d", resp.StatusCode)
}
if notice["pending"] != true {
t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
}
if notice["enabled"] != true {
t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
}
// De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
settings := beaconSettingsOf(t, ts)
if notice["instance_id"] != settings["instance_id"] {
t.Errorf("id i beskeden = %v, i indstillingerne = %v",
notice["instance_id"], settings["instance_id"])
}
if notice["collector_url"] != settings["collector_url"] {
t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
notice["collector_url"], settings["collector_url"])
}
if notice["version"] == "" || notice["version"] == nil {
t.Error("beskeden siger ikke, hvilken version der bliver sendt")
}
}
// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
// ved næste login.
func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
if resp.StatusCode != http.StatusOK || out["enabled"] != true {
t.Fatalf("behold: %d %v", resp.StatusCode, out)
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
t.Errorf("beskeden kommer igen efter et svar: %v", notice)
}
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
}
}
// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
if resp.StatusCode != http.StatusOK || out["enabled"] != false {
t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
}
s := beaconSettingsOf(t, ts)
if s["enabled"] != false {
t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
t.Errorf("beskeden kommer igen efter et nej: %v", notice)
}
}
// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
}
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
t.Errorf("en tom krop talte som et svar: %v", notice)
}
}
// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
// svares der ingenting frem for et lille tal, ingen kan tolke.
func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
"is_collector": true, "publish_count": true,
}); resp.StatusCode != http.StatusOK {
t.Fatal("bliv collector")
}
// Instansen tæller sig selv, så der er én. Gulvet er højere.
resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("tælling: %d", resp.StatusCode)
}
if out["installs"] != nil {
t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
}
// Sættes gulvet ned, står tallet der.
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
t.Fatal("sænk gulvet")
}
if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
}
}
func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
}
return out
}
+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
+16
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)
@@ -519,6 +529,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// installation and it either phones home or it does not.
r.Get("/beacon/settings", s.handleBeaconStatus)
r.Put("/beacon/settings", s.handleSetBeacon)
// Beskeden om, hvad der bliver sendt, og svaret på den. I samme
// gruppe som resten: den, der kan slå beaconet fra, er den, der
// skal have beskeden, og at spørge alle andre ville være at
// stille et spørgsmål, de ikke kan svare på.
r.Get("/beacon/notice", s.handleBeaconNotice)
r.Post("/beacon/notice", s.handleAckBeaconNotice)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"context"
"strings"
"github.com/kristianwind/verdande/internal/store"
"github.com/kristianwind/verdande/internal/update"
)
// Besked til administratorerne, når der er kommet en ny version.
//
// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en
// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde
// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af
// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter.
//
// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om,
// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan
// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder.
const updateScope = "update"
// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer,
// så en time mellem opslagene her koster ikke noget — og en instans, der har været
// slukket, opdager det ved den første time, den er vågen.
func (s *Server) NotifyOfUpdates(ctx context.Context) error {
return s.notifyOfUpdate(ctx, s.updates.Status(ctx))
}
// notifyOfUpdate tager status som argument frem for at hente den, så den kan
// prøves uden at spørge GitHub om noget.
func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error {
// Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke
// blive til en besked ad bagvejen.
if status.Disabled || !status.Available || status.Latest == "" {
return nil
}
stored, err := s.db.InstanceSettings(ctx, updateScope)
if err != nil {
return err
}
// Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if last, _ := stored["notified_version"].(string); last == status.Latest {
return nil
}
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
title := updateTitle(status.Latest)
body := firstLineOf(status.Notes)
for _, u := range users {
if !u.IsAdmin {
continue
}
s.notifyDirect(ctx, &store.Notification{
UserID: u.ID, Kind: "update.available", Title: title, Body: body,
})
}
// Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en
// fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.
return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{
"notified_version": status.Latest,
})
}
// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og
// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra
// `kind` — se KINDS i Notifications.svelte.
func updateTitle(version string) string {
return "verdande " + version + " er klar"
}
// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop.
//
// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det,
// en notifikation er til: den skal sige, at der er noget, og hvor man læser det.
func firstLineOf(notes string) string {
for _, line := range strings.Split(notes, "\n") {
if line = strings.TrimSpace(line); line != "" {
return firstWords(line, 30)
}
}
return ""
}
+127
View File
@@ -0,0 +1,127 @@
package httpapi
import (
"testing"
"github.com/kristianwind/verdande/internal/update"
)
// En ny version siger til af sig selv. Uden det stod svaret nederst på en
// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at
// der var noget at se efter.
func TestANewVersionTellsTheAdministrator(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
status := update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0",
}
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("besked om ny version: %v", err)
}
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "update.available" {
t.Errorf("besked = %v", n)
}
// Titlen bærer versionen, fordi den også bliver til en push-besked, og push
// har ingen ordbog at slå sætningen op i.
if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" {
t.Errorf("titel = %q", title)
}
// Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("den samme version gav %d beskeder, want 1", len(list))
}
// Og den næste version er en ny ting at få at vide.
status.Latest = "v0.46.0"
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 2 {
t.Errorf("en ny version gav %d beskeder, want 2", len(list))
}
}
// Kun administratorer. En besked om, at serveren er bagud, er en besked om en
// andens arbejde, hvis man ikke selv kan gøre noget ved den.
func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "sofie@example.dk", "Sofie")
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list))
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("administratoren fik %d beskeder, want 1", len(list))
}
}
// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen.
func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, status := range []update.Status{
{Current: "v0.44.0", Disabled: true},
{Current: "v0.44.0", Available: false},
{Current: "v0.44.0", Available: true, Latest: ""},
} {
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("%v: %v", status, err)
}
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list))
}
}
// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det,
// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne
// klikkes.
func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
n := firstOf(t, mustNotifications(t, ts))
for _, key := range []string{"note_id", "task_id", "project_id"} {
if v, ok := n[key]; ok && v != "" {
t.Errorf("%s = %v, want ingenting", key, v)
}
}
}
func mustNotifications(t *testing.T, ts *testServer) []any {
t.Helper()
list, _ := notifications(t, ts)
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
return list
}
+29
View File
@@ -49,6 +49,14 @@ 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
// NotifyOfUpdates tells the administrators when a newer version is out.
NotifyOfUpdates 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 +97,13 @@ 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)
// Nye versioner. Hver time er rigeligt: selve tjekket mellemlagrer sit svar i
// seks timer, og beskeden sendes én gang pr. version.
r.every(ctx, "updates", time.Hour, r.updateNotices)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -123,6 +138,20 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
}()
}
func (r *Runner) updateNotices(ctx context.Context) error {
if r.NotifyOfUpdates == nil {
return nil
}
return r.NotifyOfUpdates(ctx)
}
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
+15
View File
@@ -28,5 +28,20 @@ setup('opret den første konto og log ind', async ({ page }) => {
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
// Beaconet er slået til som udgangspunkt, og det bliver sagt uopfordret her —
// det er hele grunden til, at et default-on er til at forsvare. Prøvet her,
// fordi det er det eneste sted, en helt frisk installation bliver logget ind
// på for første gang, og fordi beskeden ellers ville stå i vejen for hver
// eneste prøve nedenfor og skulle klikkes væk uden at nogen havde set efter,
// om den overhovedet sagde det rigtige.
const notice = page.getByRole('region', { name: /melder, at den findes/ });
await expect(notice).toBeVisible();
// Værdierne står i beskeden, ikke ordet "anonymt": et løfte om telemetri er
// præcis så meget værd som læserens mulighed for at efterprøve det.
await expect(notice.getByText('instance_id')).toBeVisible();
await expect(notice.getByText('version')).toBeVisible();
await notice.getByRole('button', { name: 'Behold den' }).click();
await expect(notice).toBeHidden();
await page.context().storageState({ path: AUTH_FILE });
});
+12
View File
@@ -280,6 +280,11 @@ export const api = {
beacon: () => get('/beacon/settings'),
setBeacon: (body) => put('/beacon/settings', body),
// Beskeden om, hvad installationen melder ind, og svaret på den. `keep` sendes
// altid — en tom krop måtte ikke kunne betyde "slå det fra", og serveren
// afviser den derfor.
beaconNotice: () => get('/beacon/notice'),
answerBeaconNotice: (keep) => post('/beacon/notice', { keep }),
listBackups: () => get('/backups'),
runBackup: () => post('/backups'),
@@ -485,6 +490,13 @@ export const api = {
aiApplyToTask: (taskId, line) => post('/ai/inbox/tidy/apply', { task_id: taskId, line }),
aiNoteActions: (noteId) => post(`/ai/notes/${noteId}/actions`),
aiCreateFromNote: (noteId, line) => post(`/ai/notes/${noteId}/actions/apply`, { line }),
// Spørg om sine egne noter og opgaver. Basen finder kandidaterne, modellen
// læser dem — svaret bærer, hvad det står på.
aiAsk: (question) => post('/ai/ask', { question }),
// Dagens plan om morgenen.
planSettings: () => get('/ai/plan'),
setPlanSettings: (body) => put('/ai/plan', body),
planNow: () => post('/ai/plan/now'),
version: () => get('/version'),
+142
View File
@@ -0,0 +1,142 @@
<script>
/**
* Beskeden om, at denne installation melder ind — sagt uopfordret, første gang
* en administrator er logget ind.
*
* Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
* bliver sagt af sig selv. Siden under Indstillinger → Data siger det rigtigt
* og fyldestgørende, og den bliver åbnet af dem, der i forvejen har en
* mistanke om, at der er noget at læse; for alle andre er "du kan slå den fra"
* et tilbud til nogen, der ikke ved, der er noget at slå fra.
*
* De tre værdier står i beskeden — det rigtige id, den rigtige version, den
* rigtige adresse — frem for ordene "anonyme data". Et løfte om telemetri er
* præcis så meget værd som læserens mulighed for at efterprøve det.
*
* Kun til administratorer, og hentet på `app.user` frem for i onMount: en
* indlæsning, der kører, mens login-siden står, kører aldrig for den, der lige
* er logget ind — og så er beskeden aldrig sagt til dem, den er skrevet til.
*/
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { t } from '$lib/i18n.svelte.js';
import { humanMessage } from '$lib/api.js';
let notice = $state(null);
let busy = $state(false);
$effect(() => {
const me = app.user;
if (!me?.is_admin) {
notice = null;
return;
}
let alive = true;
api
.beaconNotice()
.then((r) => {
if (alive && r?.pending) notice = r;
})
.catch(() => {
// En besked, der ikke kunne hentes, må ikke stå i vejen for appen.
// Den er stadig ubesvaret næste gang, og bliver stillet igen.
});
return () => {
alive = false;
};
});
async function answer(keep) {
if (busy) return;
busy = true;
try {
await api.answerBeaconNotice(keep);
notice = null;
app.toast(keep ? t('beacon.noticeKept') : t('beacon.noticeStopped'));
} catch (e) {
app.toast(humanMessage(e));
} finally {
busy = false;
}
}
</script>
{#if notice}
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
<h2>{t('beacon.noticeTitle')}</h2>
<p>{t('beacon.noticeBody')}</p>
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
<pre>{JSON.stringify(
{ instance_id: notice.instance_id, version: notice.version },
null,
2
)}</pre>
<p class="to">{t('beacon.noticeTo', { url: notice.collector_url })}</p>
<div class="row">
<button class="button" disabled={busy} onclick={() => answer(true)}>
{t('beacon.noticeKeep')}
</button>
<button class="secondary" disabled={busy} onclick={() => answer(false)}>
{t('beacon.noticeStop')}
</button>
<a href="/indstillinger/data">{t('beacon.noticeMore')}</a>
</div>
</aside>
{/if}
<style>
/* Øverst i indholdet, i flowet — ikke svævende over det. En rude, der ligger
oven på arbejdet, dækker knapper, og en besked, der er i vejen, bliver
klikket væk uden at blive læst. Den her skubber i stedet, står til den
bliver besvaret, og forsvinder så for altid. */
.notice {
margin: 0 0 var(--s3);
max-width: 44rem;
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius-lg, var(--radius));
background: var(--surface-raised, var(--surface));
box-shadow: var(--shadow-lg, 0 10px 30px rgb(0 0 0 / 0.25));
font-size: var(--text-sm);
}
.notice h2 {
margin: 0 0 var(--s1);
font-size: var(--text-sm);
}
.notice p {
margin: 0 0 var(--s2);
color: var(--ink-muted);
line-height: 1.5;
}
.notice pre {
margin: 0 0 var(--s1);
padding: var(--s2);
border-radius: var(--radius);
background: var(--surface-sunken, var(--surface));
font-size: var(--text-xs);
overflow-x: auto;
}
.notice .to {
font-size: var(--text-xs);
word-break: break-all;
}
.row {
display: flex;
align-items: center;
gap: var(--s2);
flex-wrap: wrap;
}
.row a {
font-size: var(--text-xs);
color: var(--ink-muted);
}
</style>
@@ -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,8 @@
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
'daily.plan': 'notif.dailyPlan',
'update.available': 'notif.update',
comment: 'notif.comment'
};
@@ -51,6 +53,9 @@
function follow(n) {
open = false;
if (!n.read) app.markRead(n.id);
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
// til at gøre noget ved den sidder.
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
if (n.note_id) goto(`/noter?note=${n.note_id}`);
else if (n.task_id) app.openDetail(n.task_id);
else if (n.project_id) goto(`/projekt/${n.project_id}`);
+28
View File
@@ -32,6 +32,8 @@ 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.update': 'Der er kommet en ny version',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
@@ -87,6 +89,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',
@@ -106,6 +118,22 @@ export const da = {
'beacon.active7': 'Aktive 7 dage',
'beacon.active30': 'Aktive 30 dage',
'beacon.byVersion': 'Efter version (30 dage):',
// Beskeden, der bliver sagt uopfordret første gang. Værdierne står i den, ikke
// ordet 'anonymt': et løfte om telemetri er præcis så meget værd som
// læserens mulighed for at efterprøve det.
'beacon.noticeTitle': 'Denne installation melder, at den findes',
'beacon.noticeBody':
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'beacon.noticeTo': 'Sendes til {url}',
'beacon.noticeKeep': 'Behold den',
'beacon.noticeStop': 'Slå den fra',
'beacon.noticeMore': 'Læs mere',
'beacon.noticeKept': 'Tak. Du kan altid slå den fra under Indstillinger → Data.',
'beacon.noticeStopped': 'Meldingen er slået fra. Der bliver ikke sendt noget.',
'beacon.lastError': 'Sidste forsøg nåede ikke frem ({when}): {why}',
'beacon.publishMin': 'Offentliggør først fra',
'beacon.publishMinHint':
'Under det tal svares der ingenting. Et lille tal fortæller mest om, hvor lidt der skal til at flytte det — og et, der falder, ville blive læst som “færre bruger det nu”, også når det var en udrulning, der gik galt.',
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
'notes.newHere': 'Ny note',
+28
View File
@@ -29,6 +29,8 @@ 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.update': 'A new version is out',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
@@ -84,6 +86,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',
@@ -103,6 +115,22 @@ export const en = {
'beacon.active7': 'Active 7 days',
'beacon.active30': 'Active 30 days',
'beacon.byVersion': 'By version (30 days):',
// The notice said unprompted the first time. The values are in it rather than
// the word 'anonymous': a promise about telemetry is worth exactly as much as
// the reader's ability to check it.
'beacon.noticeTitle': 'This installation reports that it exists',
'beacon.noticeBody':
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'beacon.noticeTo': 'Sent to {url}',
'beacon.noticeKeep': 'Keep it on',
'beacon.noticeStop': 'Turn it off',
'beacon.noticeMore': 'Read more',
'beacon.noticeKept': 'Thank you. You can always turn it off under Settings → Data.',
'beacon.noticeStopped': 'Reporting is off. Nothing is being sent.',
'beacon.lastError': 'The last attempt did not arrive ({when}): {why}',
'beacon.publishMin': 'Publish only from',
'beacon.publishMinHint':
'Below that number nothing is answered. A small number mostly says how little it takes to move it — and one that falls would read as “fewer people use this now”, even when it was a release that went wrong.',
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
'notes.newHere': 'New note',
+8
View File
@@ -11,6 +11,7 @@
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
let { children } = $props();
@@ -192,6 +193,13 @@
</header>
<div class="content">
<!-- Sagt uopfordret, første gang en administrator er logget ind: hvad
denne installation melder ind, og hvordan man siger nej.
Øverst i indholdet og ikke svævende over det — en rude, der
ligger oven på arbejdet, dækker knapper, og en besked, der er i
vejen, bliver klikket væk uden at blive læst. Komponenten tegner
ingenting, når der ikke er noget at fortælle. -->
<BeaconNotice />
{@render children()}
</div>
</main>
@@ -534,6 +534,18 @@
<pre class="payload">{t('beacon.exactly')}
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}</pre>
{#if beacon.last_error}
<!-- Et beacon, der ikke kan nå sin collector, fejler ellers i fuldstændig
tavshed: det prøver igen i morgen, og "sidst sendt" bliver bare ved
med at blive ældre. Ikke en fejl, der skal afbryde nogen — en linje,
hvor siden ellers påstår, at alt er sendt. -->
<p class="hint">
{t('beacon.lastError', {
when: new Date(beacon.last_error_at).toLocaleString(tag()),
why: beacon.last_error
})}
</p>
{/if}
{#if beacon.last_ping_at}
<p class="hint">{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}</p>
{/if}
@@ -592,6 +604,20 @@
/>
<span>{t('beacon.publish')}</span>
</label>
{#if beacon.publish_count}
<div class="field">
<label for="publishmin">{t('beacon.publishMin')}</label>
<input
id="publishmin"
type="number"
min="0"
value={beacon.publish_min}
onchange={(e) => saveBeacon({ publish_min: Number(e.currentTarget.value) })}
/>
<p class="hint">{t('beacon.publishMinHint')}</p>
</div>
{/if}
<p class="hint">{t('beacon.publishHint')}</p>
{/if}
{/if}
@@ -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>