diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index e423288..d38c994 100644 --- a/cmd/verdande/main.go +++ b/cmd/verdande/main.go @@ -104,6 +104,7 @@ func run() error { runner.SyncCalendars = api.SyncCalendars runner.Push = api.PushToUser runner.SendBeacon = api.SendBeacon + runner.DailyPlans = api.DailyPlans runner.Start(jobCtx) srv := &http.Server{ diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 30a9f5b..540826c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3568,6 +3568,115 @@ paths: "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Validation" } + /api/v1/ai/plan: + get: + tags: [integrations] + summary: Whether a morning plan is sent, and at what hour. + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/DailyPlanSettings" } + put: + tags: [integrations] + summary: Turn the morning plan on or off, or move the hour. + description: | + The hour is local to the person. Moving it clears the "already sent today" + mark, so somebody who moves the plan from 07 to 16 gets it this afternoon + rather than tomorrow — a setting whose effect you have to wait a day to + see is a setting nobody dares turn on. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: { type: boolean } + hour: { type: integer, minimum: 0, maximum: 23 } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/DailyPlanSettings" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/ai/plan/now: + post: + tags: [integrations] + summary: Send today's plan now, to whoever asks. + description: | + The counts come from the database and the sentence from the model, in that + order: a model that counted for itself would sometimes count wrong, and a + plan that says four tasks where there are three is worse than no plan. + With no model configured the counts stand on their own, which is what you + needed to know at seven in the morning anyway. + + Nothing due and nothing overdue sends nothing. A morning with nothing to + do is a good morning, and a notification saying "there is nothing" teaches + people to stop opening notifications. + responses: + "200": + description: The plan, and whether it was sent. + content: + application/json: + schema: + type: object + properties: + plan: { type: string } + sent: { type: boolean } + + /api/v1/ai/ask: + post: + tags: [integrations] + summary: Ask a question of your own notes and tasks. + description: | + Search finds what contains the words. This answers the question — "what + did I promise Anders in August?" — which is not the same: the answer is + spread over three notes and a task, and none of them contains the word + "promise". + + Two halves, and the order is the whole of the safety in it. The database + finds the candidates with the search that already exists and that already + only sees what this person may see. The model is given those and nothing + else — it does not look anything up and cannot ask for more. + + The answer carries what it rests on. A model that says "you did, on the + 14th" is only useful if you can open the note it read that in. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [question] + properties: + question: { type: string } + responses: + "200": + description: | + The answer, and the passages it rests on — only those, not everything + that was looked at. An empty answer means nothing matched. + content: + application/json: + schema: + type: object + properties: + answer: { type: string } + sources: + type: array + items: + type: object + properties: + kind: { type: string, enum: [note, task] } + id: { type: string } + title: { type: string } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Validation" } + "502": { $ref: "#/components/responses/Upstream" } + /api/v1/mcp: post: tags: [integrations] @@ -4351,6 +4460,20 @@ components: note_id: { type: string } title: { type: string } + DailyPlanSettings: + type: object + description: > + One person's morning plan. Off by default — a notification at seven in the + morning that nobody asked for is not a help. + properties: + enabled: { type: boolean } + hour: + type: integer + description: Local to the person, because seven o'clock is seven different places. + last_sent: + type: string + description: The date it last went out, in that person's own timezone. + AISuggestion: type: object description: > diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 5a6299f..20c1792 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -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 { diff --git a/internal/httpapi/ai_ask_handlers.go b/internal/httpapi/ai_ask_handlers.go new file mode 100644 index 0000000..bedc049 --- /dev/null +++ b/internal/httpapi/ai_ask_handlers.go @@ -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 +} diff --git a/internal/httpapi/ai_mail_handlers.go b/internal/httpapi/ai_mail_handlers.go new file mode 100644 index 0000000..daf5f63 --- /dev/null +++ b/internal/httpapi/ai_mail_handlers.go @@ -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) + } +} diff --git a/internal/httpapi/ai_plan_handlers.go b/internal/httpapi/ai_plan_handlers.go new file mode 100644 index 0000000..4a64b86 --- /dev/null +++ b/internal/httpapi/ai_plan_handlers.go @@ -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, ", ") +} diff --git a/internal/httpapi/ai_plan_test.go b/internal/httpapi/ai_plan_test.go new file mode 100644 index 0000000..d047fe1 --- /dev/null +++ b/internal/httpapi/ai_plan_test.go @@ -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) + } +} diff --git a/internal/httpapi/ai_suggest_test.go b/internal/httpapi/ai_suggest_test.go index fa6366a..9d6f2eb 100644 --- a/internal/httpapi/ai_suggest_test.go +++ b/internal/httpapi/ai_suggest_test.go @@ -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"]) + } +} diff --git a/internal/httpapi/gmail_handlers.go b/internal/httpapi/gmail_handlers.go index 40bbc4d..592df90 100644 --- a/internal/httpapi/gmail_handlers.go +++ b/internal/httpapi/gmail_handlers.go @@ -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 diff --git a/internal/httpapi/mailbox_handlers.go b/internal/httpapi/mailbox_handlers.go index d9946db..f071e21 100644 --- a/internal/httpapi/mailbox_handlers.go +++ b/internal/httpapi/mailbox_handlers.go @@ -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 diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 0c91f4c..545da8e 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 19cd1b2..1fcaf2c 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -49,6 +49,11 @@ type Runner struct { // layer, which owns the VAPID keys and the subscription list. Push func(userID, title, body, projectID string) + // DailyPlans sender morgenens plan til dem, der har bedt om en. Sat af + // serveren af samme grund som SendBeacon: opsætningen, tidszonen og + // AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene. + DailyPlans func(ctx context.Context) error + // SendBeacon reports this installation to the collector, if it is switched on // and a day has passed. Supplied by the HTTP layer for the same reason as the // syncs: that is where the instance settings and the version live. @@ -89,6 +94,10 @@ func (r *Runner) Start(ctx context.Context) { // the same reason: a container asleep at the scheduled minute would otherwise // skip the day. SendBeacon decides whether a day has passed. r.every(ctx, "beacon", time.Hour, r.sendBeacon) + // Dagens plan. Hver time, sendt højst én gang om dagen pr. person — samme + // form som ovenstående, og af samme grund. Timen er personens egen: klokken + // syv er syv forskellige steder. + r.every(ctx, "dailyplan", time.Hour, r.dailyPlans) } func (r *Runner) Wait() { r.wg.Wait() } @@ -123,6 +132,13 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration, }() } +func (r *Runner) dailyPlans(ctx context.Context) error { + if r.DailyPlans == nil { + return nil + } + return r.DailyPlans(ctx) +} + // sendBeacon reports that this installation exists, at most once a day. // // Every decision about what is sent, and why this is on by default rather than diff --git a/web/src/lib/api.js b/web/src/lib/api.js index d2294ff..e79c952 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -485,6 +485,13 @@ export const api = { aiApplyToTask: (taskId, line) => post('/ai/inbox/tidy/apply', { task_id: taskId, line }), aiNoteActions: (noteId) => post(`/ai/notes/${noteId}/actions`), aiCreateFromNote: (noteId, line) => post(`/ai/notes/${noteId}/actions/apply`, { line }), + // Spørg om sine egne noter og opgaver. Basen finder kandidaterne, modellen + // læser dem — svaret bærer, hvad det står på. + aiAsk: (question) => post('/ai/ask', { question }), + // Dagens plan om morgenen. + planSettings: () => get('/ai/plan'), + setPlanSettings: (body) => put('/ai/plan', body), + planNow: () => post('/ai/plan/now'), version: () => get('/version'), diff --git a/web/src/lib/components/CommandPalette.svelte b/web/src/lib/components/CommandPalette.svelte index 20b5322..97505e4 100644 --- a/web/src/lib/components/CommandPalette.svelte +++ b/web/src/lib/components/CommandPalette.svelte @@ -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()} +
{answer.error}
+ {:else if answer.answer} +{answer.answer}
+ {#if answer.sources?.length} + ++ {t('ai.askSources')} + {#each answer.sources as source, i (source.kind + source.id)}{i < answer.sources.length - 1 ? ', ' : ''}{/each} +
+ {/if} + {:else} +{t('ai.askNothing')}
+ {/if} +{t('ai.planHint')}
+