Merge pull request #68 from kristianwind/ai-foreslaar-du-bestemmer
feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
This commit is contained in:
@@ -3532,6 +3532,124 @@ paths:
|
|||||||
$ref: "#/components/responses/Conflict"
|
$ref: "#/components/responses/Conflict"
|
||||||
"502": { $ref: "#/components/responses/Upstream" }
|
"502": { $ref: "#/components/responses/Upstream" }
|
||||||
|
|
||||||
|
/api/v1/ai/inbox/tidy:
|
||||||
|
post:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: Suggest how the loose tasks in the inbox could have been written.
|
||||||
|
description: |
|
||||||
|
Suggests, never writes. Each suggestion is the line the person would have
|
||||||
|
typed — quick-add syntax, the same one parser as everywhere else — so it
|
||||||
|
can be read and edited by whoever it is put to, rather than being a set of
|
||||||
|
fields a model filled in that you take or leave.
|
||||||
|
|
||||||
|
The model is given the projects that exist and may name no other: a
|
||||||
|
`#Project` it invented would create one by accident the first time
|
||||||
|
somebody said yes.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The suggestions, one per task, and none for a task already filed well.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
suggestions:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/AISuggestion" }
|
||||||
|
"409": { $ref: "#/components/responses/Conflict" }
|
||||||
|
"502": { $ref: "#/components/responses/Upstream" }
|
||||||
|
|
||||||
|
/api/v1/ai/inbox/tidy/apply:
|
||||||
|
post:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: Write one accepted suggestion into the task it is about.
|
||||||
|
description: |
|
||||||
|
The line is parsed here rather than in the browser, so "tomorrow" means
|
||||||
|
what it means everywhere else — and a suggestion edited by hand before it
|
||||||
|
was accepted is read exactly like one that was not.
|
||||||
|
|
||||||
|
Only what the line actually says is applied. A missing date means "the
|
||||||
|
line said nothing about a date", never "clear the one that is there". A
|
||||||
|
project the model invented moves nothing.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [task_id, line]
|
||||||
|
properties:
|
||||||
|
task_id: { type: string }
|
||||||
|
line: { type: string }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The task as it now stands.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Task" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/ai/notes/{noteID}/actions:
|
||||||
|
parameters:
|
||||||
|
- { name: noteID, in: path, required: true, schema: { type: string } }
|
||||||
|
post:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: Pull what somebody committed to out of a note.
|
||||||
|
description: |
|
||||||
|
Only things somebody committed to or was asked to do — a heading is not a
|
||||||
|
task, a topic discussed is not a task. An empty array is a useful answer
|
||||||
|
and a made-up list is not.
|
||||||
|
|
||||||
|
Each suggestion quotes the words it came from, so a wrong one can be seen
|
||||||
|
through rather than merely being wrong.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
suggestions:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/AISuggestion" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"409": { $ref: "#/components/responses/Conflict" }
|
||||||
|
"502": { $ref: "#/components/responses/Upstream" }
|
||||||
|
|
||||||
|
/api/v1/ai/notes/{noteID}/actions/apply:
|
||||||
|
parameters:
|
||||||
|
- { name: noteID, in: path, required: true, schema: { type: string } }
|
||||||
|
post:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: Create one accepted task from a note.
|
||||||
|
description: |
|
||||||
|
The note is not touched. Writing the link back into it would make the two
|
||||||
|
point at each other, and a text a program edits without being asked is a
|
||||||
|
text people stop trusting — so the task carries where it came from, as a
|
||||||
|
`[[note title]]` in its description, and the note is left alone.
|
||||||
|
|
||||||
|
The note's own project is the default; a `#Project` in the line wins,
|
||||||
|
because typing it is a choice and inheriting is a circumstance.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [line]
|
||||||
|
properties:
|
||||||
|
line: { type: string }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Task" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
/api/v1/mcp:
|
/api/v1/mcp:
|
||||||
post:
|
post:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
@@ -4315,6 +4433,21 @@ components:
|
|||||||
note_id: { type: string }
|
note_id: { type: string }
|
||||||
title: { type: string }
|
title: { type: string }
|
||||||
|
|
||||||
|
AISuggestion:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
One proposed line, in quick-add syntax, plus one sentence on why it looks
|
||||||
|
like that. Nothing has happened yet — a suggestion becomes something when
|
||||||
|
somebody says yes to it.
|
||||||
|
properties:
|
||||||
|
task_id:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
The task the suggestion is about, when it is about one that already
|
||||||
|
exists. Absent when the suggestion is a new task.
|
||||||
|
line: { type: string, example: "Ring til Anders i morgen p1 #Firma" }
|
||||||
|
why: { type: string }
|
||||||
|
|
||||||
NoteInvite:
|
NoteInvite:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
description: >
|
||||||
|
|||||||
@@ -395,6 +395,132 @@ back, do not congratulate, and do not offer productivity advice.`, language)
|
|||||||
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suggestion er én foreslået opgave, skrevet som en linje, man selv kunne have
|
||||||
|
// tastet.
|
||||||
|
//
|
||||||
|
// `line` er quick-add-syntaks — "Ring til Anders i morgen p1 #Firma" — og ikke et
|
||||||
|
// felt pr. egenskab. To grunde, og den anden er den vigtige. Parseren findes
|
||||||
|
// allerede og er den samme, uanset hvor teksten kom fra, så der er ingen anden
|
||||||
|
// fortolkning at holde i takt med den. Og et forslag, der er skrevet i det sprog,
|
||||||
|
// man selv skriver i, kan læses og rettes af den, det bliver stillet til — hvor
|
||||||
|
// et sæt felter, en model har udfyldt, er noget, man skal tage eller lade være.
|
||||||
|
//
|
||||||
|
// `why` er én sætning om, hvorfor den ser sådan ud. Den står ved siden af
|
||||||
|
// forslaget, så et forkert gæt kan gennemskues frem for bare at være forkert.
|
||||||
|
type Suggestion struct {
|
||||||
|
Line string `json:"line"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TidyInbox foreslår, hvordan løse opgaver i indbakken kunne skrives færdig.
|
||||||
|
//
|
||||||
|
// Modellen får de projekter, der findes, og må kun bruge dem: et `#Nyt Projekt`,
|
||||||
|
// den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja.
|
||||||
|
func (c *Client) TidyInbox(ctx context.Context, tasks, projects []string, today, locale string) ([]Suggestion, error) {
|
||||||
|
language := languageOf(locale)
|
||||||
|
|
||||||
|
known := "none"
|
||||||
|
if len(projects) > 0 {
|
||||||
|
known = "#" + strings.Join(projects, ", #")
|
||||||
|
}
|
||||||
|
|
||||||
|
system := fmt.Sprintf(`You tidy up a task inbox.
|
||||||
|
|
||||||
|
For each task you are given, write the line the person would have typed if they
|
||||||
|
had filed it properly. Keep their own words for what the task is — you are filing
|
||||||
|
it, not rewriting it.
|
||||||
|
|
||||||
|
The line may carry, in this syntax and no other:
|
||||||
|
#Project one of these existing projects, and never one that is not: %s
|
||||||
|
p1..p4 priority, where p1 is most urgent
|
||||||
|
a date in plain %s, such as "i morgen", "on friday", "15/3"
|
||||||
|
|
||||||
|
Leave out anything the task does not tell you. A guessed deadline is worse than
|
||||||
|
none: the person can add one, but they cannot see a wrong one you invented.
|
||||||
|
Today is %s.
|
||||||
|
|
||||||
|
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||||
|
is {"line": "...", "why": "..."} where why is at most one short sentence in %s
|
||||||
|
saying what you read out of the task. Give one element per task, in the order you
|
||||||
|
were given them, and leave a task out entirely if it is already filed well.`,
|
||||||
|
known, language, today, language)
|
||||||
|
|
||||||
|
prompt := "Tasks:\n" + strings.Join(tasks, "\n")
|
||||||
|
return c.suggestions(ctx, system, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionsInNote trækker det, nogen har lovet, ud af en note.
|
||||||
|
//
|
||||||
|
// Beskrivelsen er stram med vilje. En model, der får lov at "finde opgaver" i en
|
||||||
|
// tekst, finder også emner, overskrifter og gode idéer — og så er resultatet en
|
||||||
|
// liste, man skal luge i, hvilket er dyrere end at skrive de tre punkter selv.
|
||||||
|
func (c *Client) ActionsInNote(ctx context.Context, note string, projects []string, today, locale string) ([]Suggestion, error) {
|
||||||
|
language := languageOf(locale)
|
||||||
|
|
||||||
|
known := "none"
|
||||||
|
if len(projects) > 0 {
|
||||||
|
known = "#" + strings.Join(projects, ", #")
|
||||||
|
}
|
||||||
|
|
||||||
|
system := fmt.Sprintf(`You read a note and pull out what somebody has to do.
|
||||||
|
|
||||||
|
Only things somebody committed to or was asked to do. A heading is not a task, a
|
||||||
|
topic that was discussed is not a task, and an idea nobody agreed to is not a
|
||||||
|
task. If the note contains none, reply with an empty array — that is a useful
|
||||||
|
answer and a made-up list is not.
|
||||||
|
|
||||||
|
Write each one as the line the person would have typed, in %s, in this syntax and
|
||||||
|
no other:
|
||||||
|
#Project one of these existing projects, and never one that is not: %s
|
||||||
|
p1..p4 priority, where p1 is most urgent
|
||||||
|
a date in plain %s, only when the note actually gives one
|
||||||
|
|
||||||
|
Today is %s.
|
||||||
|
|
||||||
|
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||||
|
is {"line": "...", "why": "..."} where why quotes the few words in the note that
|
||||||
|
the task came from, so it can be checked.`, language, known, language, today)
|
||||||
|
|
||||||
|
return c.suggestions(ctx, system, "Note:\n"+note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
|
||||||
|
// der ikke er en linje.
|
||||||
|
//
|
||||||
|
// Et tomt svar er ikke en fejl. "Der er ikke noget at foreslå" er et rigtigt svar
|
||||||
|
// på begge spørgsmål, og en fejlmeddelelse ville lære folk, at funktionen er i
|
||||||
|
// stykker, når den i virkeligheden var enig med dem.
|
||||||
|
func (c *Client) suggestions(ctx context.Context, system, prompt string) ([]Suggestion, error) {
|
||||||
|
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []Suggestion
|
||||||
|
if err := json.Unmarshal([]byte(extractJSON(reply)), &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
clean := make([]Suggestion, 0, len(out))
|
||||||
|
for _, s := range out {
|
||||||
|
s.Line = strings.TrimSpace(s.Line)
|
||||||
|
s.Why = strings.TrimSpace(s.Why)
|
||||||
|
if s.Line != "" {
|
||||||
|
clean = append(clean, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// languageOf er det sprog, svaret skal skrives i. Ét sted, fordi det ellers er
|
||||||
|
// den slags, der bliver rettet ét sted ud af fire.
|
||||||
|
func languageOf(locale string) string {
|
||||||
|
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||||
|
return "English"
|
||||||
|
}
|
||||||
|
return "Danish"
|
||||||
|
}
|
||||||
|
|
||||||
// extractJSON pulls an array out of a reply that may have been wrapped in a code
|
// 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
|
// fence or preceded by a sentence, which models do however firmly they are asked
|
||||||
// not to.
|
// not to.
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/ai"
|
||||||
|
"github.com/kristianwind/verdande/internal/quickadd"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AI'ens forslag, og hvad der sker med dem.
|
||||||
|
//
|
||||||
|
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
|
||||||
|
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
|
||||||
|
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
|
||||||
|
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
|
||||||
|
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
|
||||||
|
// skal have ret tit nok til at spare et par tastetryk.
|
||||||
|
//
|
||||||
|
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
|
||||||
|
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
|
||||||
|
// gennem den samme som alt andet.
|
||||||
|
|
||||||
|
// aiSuggestionJSON er ét forslag på vej til fladen.
|
||||||
|
type aiSuggestionJSON struct {
|
||||||
|
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
|
||||||
|
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
Line string `json:"line"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
|
||||||
|
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
|
||||||
|
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
|
||||||
|
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "ai settings", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !cfg.Configured() {
|
||||||
|
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
|
||||||
|
// lægger sin egen nøgle ind.
|
||||||
|
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ai.New(cfg), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectNames er de projekter, modellen må nævne.
|
||||||
|
//
|
||||||
|
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
|
||||||
|
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
|
||||||
|
// nogen sagde ja til forslaget.
|
||||||
|
func (s *Server) projectNames(r *http.Request) []string {
|
||||||
|
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("projects for ai", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.IsInbox {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
|
||||||
|
// skrives færdig.
|
||||||
|
//
|
||||||
|
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
|
||||||
|
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
|
||||||
|
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
|
||||||
|
// spørgsmål og et ja pr. linje.
|
||||||
|
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
client, ok := s.aiClientFor(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inbox, err := s.db.InboxID(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
|
ProjectIDs: []string{inbox}, Limit: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
|
||||||
|
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
|
||||||
|
// må ikke kunne flytte alle de andres forslag én plads — derfor står
|
||||||
|
// nummeret i teksten og bliver læst tilbage.
|
||||||
|
lines := make([]string, 0, len(tasks))
|
||||||
|
for i, t := range tasks {
|
||||||
|
line := strconv.Itoa(i+1) + ". " + t.Content
|
||||||
|
if t.Description != "" {
|
||||||
|
line += " — " + firstWords(t.Description, 30)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||||
|
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("ai tidy inbox", "err", err)
|
||||||
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||||
|
for i, sug := range suggestions {
|
||||||
|
n, line := numberedLine(sug.Line)
|
||||||
|
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
|
||||||
|
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
|
||||||
|
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
|
||||||
|
if n == 0 {
|
||||||
|
n = i + 1
|
||||||
|
}
|
||||||
|
if n < 1 || n > len(tasks) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type applyLineRequest struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Line string `json:"line"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
|
||||||
|
// om.
|
||||||
|
//
|
||||||
|
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
|
||||||
|
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
|
||||||
|
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
|
||||||
|
// der ikke blev rørt.
|
||||||
|
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req applyLineRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
if strings.TrimSpace(req.Line) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"line": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := userLocation(user.Timezone)
|
||||||
|
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||||
|
if strings.TrimSpace(parsed.Content) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
|
||||||
|
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
|
||||||
|
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
|
||||||
|
// slette noget.
|
||||||
|
update := store.TaskUpdate{Content: &parsed.Content}
|
||||||
|
if parsed.Priority != 0 {
|
||||||
|
update.Priority = &parsed.Priority
|
||||||
|
}
|
||||||
|
if parsed.DueDate != "" {
|
||||||
|
update.SetDue, update.DueDate = true, parsed.DueDate
|
||||||
|
}
|
||||||
|
if parsed.Recurrence != "" {
|
||||||
|
update.RecurrenceRule = &parsed.Recurrence
|
||||||
|
}
|
||||||
|
if len(parsed.Labels) > 0 {
|
||||||
|
update.Labels, update.SetLabels = parsed.Labels, true
|
||||||
|
}
|
||||||
|
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
|
||||||
|
s.storeError(w, r, "update task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
|
||||||
|
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
|
||||||
|
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
|
||||||
|
if parsed.Project != "" {
|
||||||
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||||
|
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
|
||||||
|
s.internal(w, r, "move task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
|
||||||
|
//
|
||||||
|
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
|
||||||
|
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
|
||||||
|
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
|
||||||
|
// bare at være forkert.
|
||||||
|
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
client, ok := s.aiClientFor(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(n.Body) == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||||
|
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("ai note actions", "err", err)
|
||||||
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||||
|
for _, sug := range suggestions {
|
||||||
|
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAICreateFromNote laver én accepteret opgave fra en note.
|
||||||
|
//
|
||||||
|
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
|
||||||
|
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
|
||||||
|
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
|
||||||
|
// den kom fra — og noten kan findes på sin titel.
|
||||||
|
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req applyLineRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := userLocation(user.Timezone)
|
||||||
|
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||||
|
if strings.TrimSpace(parsed.Content) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
|
||||||
|
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
|
||||||
|
// linjen vinder, fordi det er valgt frem for arvet.
|
||||||
|
projectID := n.ProjectID
|
||||||
|
if parsed.Project != "" {
|
||||||
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||||
|
projectID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
|
s.internal(w, r, "inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task := &store.Task{
|
||||||
|
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||||
|
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||||
|
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||||
|
s.internal(w, r, "create task from note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||||
|
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
|
||||||
|
//
|
||||||
|
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
|
||||||
|
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
|
||||||
|
// model, der springer en opgave over, ville så flytte alle de følgende forslag
|
||||||
|
// én plads, og så bliver "sæt dato på den her" sat på en anden.
|
||||||
|
func numberedLine(s string) (int, string) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
dot := strings.IndexByte(s, '.')
|
||||||
|
if dot <= 0 || dot > 3 {
|
||||||
|
return 0, s
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, c := range s[:dot] {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0, s
|
||||||
|
}
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
return n, strings.TrimSpace(s[dot+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
|
||||||
|
// fylde den med en hel note.
|
||||||
|
func firstWords(s string, n int) string {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
if len(fields) <= n {
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
return strings.Join(fields[:n], " ") + " …"
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
|
||||||
|
//
|
||||||
|
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
|
||||||
|
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
|
||||||
|
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
|
||||||
|
func fakeModel(t *testing.T, reply string) string {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{"message": map[string]string{"content": reply}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
|
||||||
|
// på en adresse i det private net, og bootstrap-kontoen er en.
|
||||||
|
func (ts *testServer) useModel(t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||||
|
"provider": "compatible", "base_url": url, "model": "prøvemodel",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
|
||||||
|
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
|
||||||
|
// anden opgave end den, den blev skrevet om.
|
||||||
|
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.useModel(t, fakeModel(t, `[
|
||||||
|
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
|
||||||
|
]`))
|
||||||
|
|
||||||
|
first := ts.quickTask(t, "noget som helst")
|
||||||
|
second := ts.quickTask(t, "anders 12345678")
|
||||||
|
|
||||||
|
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
list, _ := out["suggestions"].([]any)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("forslag = %v, want ét", list)
|
||||||
|
}
|
||||||
|
sug := list[0].(map[string]any)
|
||||||
|
if sug["task_id"] != second {
|
||||||
|
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
|
||||||
|
}
|
||||||
|
if sug["line"] != "Ring til Anders i morgen p1" {
|
||||||
|
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
|
||||||
|
}
|
||||||
|
if sug["why"] == "" {
|
||||||
|
t.Error("forslaget siger ikke hvorfor")
|
||||||
|
}
|
||||||
|
_ = first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
|
||||||
|
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
|
||||||
|
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.useModel(t, fakeModel(t, `[]`))
|
||||||
|
taskID := ts.quickTask(t, "anders")
|
||||||
|
|
||||||
|
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||||
|
"task_id": taskID, "line": "Ring til Anders i morgen p1",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
if out["content"] != "Ring til Anders" {
|
||||||
|
t.Errorf("indhold = %v", out["content"])
|
||||||
|
}
|
||||||
|
if out["priority"] != float64(1) {
|
||||||
|
t.Errorf("prioritet = %v, want 1", out["priority"])
|
||||||
|
}
|
||||||
|
if out["due_date"] == nil || out["due_date"] == "" {
|
||||||
|
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
|
||||||
|
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
|
||||||
|
// opgave hen, hvor ejeren ikke leder efter den.
|
||||||
|
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.useModel(t, fakeModel(t, `[]`))
|
||||||
|
taskID := ts.quickTask(t, "anders")
|
||||||
|
|
||||||
|
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||||
|
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("accepter: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if after["project_id"] != before["project_id"] {
|
||||||
|
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
|
||||||
|
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
|
||||||
|
// stole på.
|
||||||
|
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.useModel(t, fakeModel(t, `[
|
||||||
|
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
|
||||||
|
]`))
|
||||||
|
|
||||||
|
body := "# Møde med Anders\njeg sender prislisten fredag"
|
||||||
|
noteID := ts.createNote(t, body)
|
||||||
|
|
||||||
|
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
list, _ := out["suggestions"].([]any)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("forslag = %v, want ét", list)
|
||||||
|
}
|
||||||
|
line := list[0].(map[string]any)["line"].(string)
|
||||||
|
|
||||||
|
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
|
||||||
|
map[string]any{"line": line})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
|
||||||
|
}
|
||||||
|
if task["content"] != "Send prisliste til Anders" {
|
||||||
|
t.Errorf("opgave = %v", task["content"])
|
||||||
|
}
|
||||||
|
// Opgaven bærer, hvor den kom fra, som et notelink.
|
||||||
|
if task["description"] != "[[Møde med Anders]]" {
|
||||||
|
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||||
|
if note["body"] != body {
|
||||||
|
t.Errorf("noten blev rettet:\n%v", note["body"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
|
||||||
|
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
|
||||||
|
// noget".
|
||||||
|
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
|
||||||
|
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
|
||||||
|
func (ts *testServer) quickTask(t *testing.T, text string) string {
|
||||||
|
t.Helper()
|
||||||
|
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
id, _ := out["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
t.Fatalf("quickadd gav intet id: %v", out)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -352,6 +352,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Put("/settings", s.handleSetAISettings)
|
r.Put("/settings", s.handleSetAISettings)
|
||||||
r.Post("/summary", s.handleAISummary)
|
r.Post("/summary", s.handleAISummary)
|
||||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||||
|
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||||
|
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||||
|
// der sker, når nogen siger ja til én af dem.
|
||||||
|
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||||
|
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||||
|
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||||
|
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Get("/version", s.handleVersion)
|
r.Get("/version", s.handleVersion)
|
||||||
|
|||||||
@@ -482,6 +482,14 @@ export const api = {
|
|||||||
setAISettings: (data) => put('/ai/settings', data),
|
setAISettings: (data) => put('/ai/settings', data),
|
||||||
aiSummary: () => post('/ai/summary'),
|
aiSummary: () => post('/ai/summary'),
|
||||||
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
|
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
|
||||||
|
// Forslag frem for skrivninger: de to første spørger, de to sidste er svaret
|
||||||
|
// på et ja. Linjen sendes med, som den står i feltet — den kan være rettet i
|
||||||
|
// hånden, inden den blev accepteret, og skal læses på samme måde som en, der
|
||||||
|
// ikke blev rørt.
|
||||||
|
aiTidyInbox: () => post('/ai/inbox/tidy'),
|
||||||
|
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 }),
|
||||||
|
|
||||||
version: () => get('/version'),
|
version: () => get('/version'),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* En liste af forslag, man siger ja eller nej til, ét ad gangen.
|
||||||
|
*
|
||||||
|
* Den samme rude to steder — oprydning i indbakken og opgaver ud af en note —
|
||||||
|
* fordi det er den samme handling: en model har foreslået en linje, og et
|
||||||
|
* menneske bestemmer. To ruder ville betyde to svar på "hvad sker der, når
|
||||||
|
* modellen tager fejl", og det spørgsmål har kun ét rigtigt svar.
|
||||||
|
*
|
||||||
|
* Forslaget står i et felt, man kan skrive i, frem for i en etiket. Det er
|
||||||
|
* hele forskellen mellem at tage imod og at bestemme: et forslag, der er tæt
|
||||||
|
* på, bliver rettet på to tegn i stedet for at blive kasseret — og linjen
|
||||||
|
* læses af den samme parser bagefter, uanset om den blev rørt.
|
||||||
|
*
|
||||||
|
* `why` står under linjen, fordi et forkert gæt skal kunne gennemskues. "Fordi
|
||||||
|
* der står 'inden fredag' i noten" er til at være uenig i; en dato, der bare
|
||||||
|
* dukkede op, er ikke.
|
||||||
|
*/
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { humanMessage } from '$lib/api.js';
|
||||||
|
|
||||||
|
let { title, hint, load, apply, onclose } = $props();
|
||||||
|
|
||||||
|
let state = $state('loading'); // loading | ready | failed
|
||||||
|
let error = $state('');
|
||||||
|
let rows = $state([]);
|
||||||
|
let busy = $state(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let alive = true;
|
||||||
|
state = 'loading';
|
||||||
|
load()
|
||||||
|
.then((r) => {
|
||||||
|
if (!alive) return;
|
||||||
|
rows = (r?.suggestions ?? []).map((s, i) => ({ ...s, key: i, line: s.line, done: false }));
|
||||||
|
state = 'ready';
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!alive) return;
|
||||||
|
error = humanMessage(e);
|
||||||
|
state = 'failed';
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function accept(row) {
|
||||||
|
if (busy !== null) return;
|
||||||
|
busy = row.key;
|
||||||
|
try {
|
||||||
|
await apply(row, row.line);
|
||||||
|
rows = rows.map((r) => (r.key === row.key ? { ...r, done: true } : r));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip(row) {
|
||||||
|
rows = rows.filter((r) => r.key !== row.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = $derived(rows.filter((r) => !r.done).length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="suggest">
|
||||||
|
<header>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<button class="close" onclick={onclose} aria-label={t('detail.close')}>×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if state === 'loading'}
|
||||||
|
<p class="hint">{t('ai.thinking')}</p>
|
||||||
|
{:else if state === 'failed'}
|
||||||
|
<p class="hint bad">{error}</p>
|
||||||
|
{:else if !rows.length}
|
||||||
|
<p class="hint">{t('ai.nothingToSuggest')}</p>
|
||||||
|
{:else}
|
||||||
|
{#if hint}<p class="hint">{hint}</p>{/if}
|
||||||
|
<ul>
|
||||||
|
{#each rows as row (row.key)}
|
||||||
|
<li class:done={row.done}>
|
||||||
|
<input
|
||||||
|
value={row.line}
|
||||||
|
oninput={(e) => (row.line = e.currentTarget.value)}
|
||||||
|
disabled={row.done}
|
||||||
|
aria-label={title}
|
||||||
|
/>
|
||||||
|
{#if row.why}<p class="why">{row.why}</p>{/if}
|
||||||
|
{#if !row.done}
|
||||||
|
<div class="row">
|
||||||
|
<button class="button" disabled={busy !== null} onclick={() => accept(row)}>
|
||||||
|
{t('ai.accept')}
|
||||||
|
</button>
|
||||||
|
<button class="secondary" onclick={() => skip(row)}>{t('ai.skip')}</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="why ok">{t('ai.applied')}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if !left}
|
||||||
|
<p class="hint">{t('ai.allDone')}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.suggest {
|
||||||
|
margin: var(--s2) 0;
|
||||||
|
padding: var(--s3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-raised, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.bad {
|
||||||
|
color: var(--danger, var(--ink));
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
li input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.done input {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grunden til forslaget, under det. Mindre end linjen selv: det er baggrund
|
||||||
|
for en beslutning, ikke selve beslutningen. */
|
||||||
|
.why {
|
||||||
|
margin: 2px 0 var(--s1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.why.ok {
|
||||||
|
color: var(--ok, var(--ink-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -77,6 +77,22 @@ export const da = {
|
|||||||
'notes.sortUpdated': 'Senest rørt',
|
'notes.sortUpdated': 'Senest rørt',
|
||||||
'notes.sortCreated': 'Oprettet',
|
'notes.sortCreated': 'Oprettet',
|
||||||
'notes.sortTitle': 'Navn',
|
'notes.sortTitle': 'Navn',
|
||||||
|
// AI'ens forslag. Ordene skal gøre det klart, at der bliver foreslået og ikke
|
||||||
|
// rettet: den, der siger ja, er den, der bestemmer.
|
||||||
|
'ai.tidyInbox': 'Ryd op i indbakken',
|
||||||
|
'ai.tidyTitle': 'Forslag til indbakken',
|
||||||
|
'ai.tidyHint':
|
||||||
|
'Sådan kunne linjerne have set ud, hvis de var skrevet færdig. Ret i dem, du vil rette i — der sker først noget, når du siger ja.',
|
||||||
|
'ai.findActions': 'Find opgaver i noten',
|
||||||
|
'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.thinking': 'Læser …',
|
||||||
|
'ai.nothingToSuggest': 'Der er ikke noget at foreslå.',
|
||||||
|
'ai.accept': 'Tilføj',
|
||||||
|
'ai.skip': 'Nej tak',
|
||||||
|
'ai.applied': 'Gjort.',
|
||||||
|
'ai.allDone': 'Ikke mere at tage stilling til.',
|
||||||
'beacon.title': 'Tælling af installationer',
|
'beacon.title': 'Tælling af installationer',
|
||||||
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
||||||
'beacon.send': 'Send en anonym daglig melding',
|
'beacon.send': 'Send en anonym daglig melding',
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ export const en = {
|
|||||||
'notes.sortUpdated': 'Last touched',
|
'notes.sortUpdated': 'Last touched',
|
||||||
'notes.sortCreated': 'Created',
|
'notes.sortCreated': 'Created',
|
||||||
'notes.sortTitle': 'Name',
|
'notes.sortTitle': 'Name',
|
||||||
|
// The AI's suggestions. The words have to make it plain that this proposes
|
||||||
|
// rather than edits: whoever says yes is the one deciding.
|
||||||
|
'ai.tidyInbox': 'Tidy up the inbox',
|
||||||
|
'ai.tidyTitle': 'Suggestions for the inbox',
|
||||||
|
'ai.tidyHint':
|
||||||
|
'How the lines could have read if they had been finished. Edit the ones you want to edit — nothing happens until you say yes.',
|
||||||
|
'ai.findActions': 'Find tasks in the note',
|
||||||
|
'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.thinking': 'Reading …',
|
||||||
|
'ai.nothingToSuggest': 'There is nothing to suggest.',
|
||||||
|
'ai.accept': 'Add',
|
||||||
|
'ai.skip': 'No thanks',
|
||||||
|
'ai.applied': 'Done.',
|
||||||
|
'ai.allDone': 'Nothing left to decide on.',
|
||||||
'beacon.title': 'Counting installations',
|
'beacon.title': 'Counting installations',
|
||||||
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
||||||
'beacon.send': 'Send an anonymous daily report',
|
'beacon.send': 'Send an anonymous daily report',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||||
|
import AISuggestions from '$lib/components/AISuggestions.svelte';
|
||||||
|
|
||||||
let notes = $state([]);
|
let notes = $state([]);
|
||||||
|
|
||||||
@@ -207,6 +208,9 @@
|
|||||||
// Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej
|
// Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej
|
||||||
// frem, og det skal kunne kopieres frem for at forsvinde.
|
// frem, og det skal kunne kopieres frem for at forsvinde.
|
||||||
let shareInviteLink = $state('');
|
let shareInviteLink = $state('');
|
||||||
|
// Opgaver, der ligger i notens tekst. Lukket, indtil nogen spørger: det koster
|
||||||
|
// et kald til en model, og de fleste noter er ikke referater.
|
||||||
|
let showActions = $state(false);
|
||||||
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||||
|
|
||||||
// Dem, et `@` kan betyde i den åbne note.
|
// Dem, et `@` kan betyde i den åbne note.
|
||||||
@@ -246,8 +250,9 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = selected?.id;
|
const id = selected?.id;
|
||||||
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
// Nulstil ved hvert skift, så den forrige notes folk — og den forrige notes
|
||||||
// under den nye.
|
// forslag — ikke står et øjeblik under den nye.
|
||||||
|
showActions = false;
|
||||||
shares = [];
|
shares = [];
|
||||||
shareCandidates = [];
|
shareCandidates = [];
|
||||||
shareFollows = [];
|
shareFollows = [];
|
||||||
@@ -1086,6 +1091,16 @@
|
|||||||
onsave={save}
|
onsave={save}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if showActions}
|
||||||
|
<AISuggestions
|
||||||
|
title={t('ai.actionsTitle')}
|
||||||
|
hint={t('ai.actionsHint')}
|
||||||
|
load={() => api.aiNoteActions(selected.id)}
|
||||||
|
apply={(row, line) => api.aiCreateFromNote(selected.id, line)}
|
||||||
|
onclose={() => (showActions = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
||||||
{#if links.length}
|
{#if links.length}
|
||||||
@@ -1274,6 +1289,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||||
|
<!-- Det, nogen har lovet i et referat, står i teksten og ikke på
|
||||||
|
listen. Knappen finder det frem som forslag; opgaverne bliver
|
||||||
|
til noget, når man siger ja til dem. -->
|
||||||
|
<button class="button" onclick={() => (showActions = !showActions)}>
|
||||||
|
{t('ai.findActions')}
|
||||||
|
</button>
|
||||||
{#if ownsSelected}
|
{#if ownsSelected}
|
||||||
<!-- Laid away, not thrown away: archive is the note leaving the list
|
<!-- Laid away, not thrown away: archive is the note leaving the list
|
||||||
without leaving the account, and it reads "bring it back" once it
|
without leaving the account, and it reads "bring it back" once it
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
|
import AISuggestions from '$lib/components/AISuggestions.svelte';
|
||||||
import BoardView from '$lib/components/BoardView.svelte';
|
import BoardView from '$lib/components/BoardView.svelte';
|
||||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
@@ -31,6 +32,10 @@
|
|||||||
let members = $state([]);
|
let members = $state([]);
|
||||||
let showShare = $state(false);
|
let showShare = $state(false);
|
||||||
let showLog = $state(false);
|
let showLog = $state(false);
|
||||||
|
// Oprydningen i indbakken. Kun dér: det er den ene liste, der bliver fyldt med
|
||||||
|
// linjer, ingen nåede at skrive færdig, og derfor den ene, hvor det er værd at
|
||||||
|
// spørge en model om, hvordan de skulle have set ud.
|
||||||
|
let showTidy = $state(false);
|
||||||
let activity = $state([]);
|
let activity = $state([]);
|
||||||
let inviteEmail = $state('');
|
let inviteEmail = $state('');
|
||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
@@ -613,6 +618,18 @@
|
|||||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if project.is_inbox}
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => {
|
||||||
|
showTidy = !showTidy;
|
||||||
|
showMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('ai.tidyInbox')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !project.is_inbox}
|
{#if !project.is_inbox}
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -665,6 +682,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if showTidy}
|
||||||
|
<!-- Forslag, ikke rettelser. Hver linje står i et felt, man kan skrive
|
||||||
|
i, og sker først, når nogen siger ja til den. -->
|
||||||
|
<AISuggestions
|
||||||
|
title={t('ai.tidyTitle')}
|
||||||
|
hint={t('ai.tidyHint')}
|
||||||
|
load={() => api.aiTidyInbox()}
|
||||||
|
apply={(row, line) => api.aiApplyToTask(row.task_id, line)}
|
||||||
|
onclose={() => (showTidy = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showLog}
|
{#if showLog}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<ul class="log">
|
<ul class="log">
|
||||||
|
|||||||
Reference in New Issue
Block a user