package main import ( _ "embed" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/webauthn" ) //go:embed chat.html var chatPageHTML string // chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat // and the handler redirects back to /chat with the response. var chatTmpl = parsePage("chat", chatPageHTML, nil) // chatMsg — one message in the conversation history. type chatMsg struct { Role string // "user" | "assistant" Text string // Source — the query source that claimed the turn, shown as a badge beside // the reply. Empty for a turn no source claimed (V-539). Source string // TraceID anchors the correction gesture (V-630). Non-zero ⇒ the turn was // persisted and can be corrected in one click. 0 ⇒ no correction is offered, // which is honest: a box with no database has no turn to correct. TraceID int64 // Corrected — the owner already corrected this turn, so the page says thank // you instead of offering the buttons again. Corrected string } // correctionTargets — the seven public intents, in the order the buttons are // shown. Read from internal/router rather than typed out, so a new intent cannot // exist without a way to correct a turn into it. var correctionTargets = []router.Intent{ router.IntentFact, router.IntentNote, router.IntentReminder, router.IntentQuery, router.IntentAct, router.IntentChat, router.IntentSystem, } // handleChatPage renders the chat conversation page. func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if !requireCore(w, r, core, "chat") { return } msgs := []chatMsg{} // Read user message + reply from query params (set by /api/chat redirect). if q := r.URL.Query().Get("q"); q != "" { msgs = append(msgs, chatMsg{Role: "user", Text: q}) } if reply := r.URL.Query().Get("r"); reply != "" { id, _ := strconv.ParseInt(r.URL.Query().Get("t"), 10, 64) msgs = append(msgs, chatMsg{ Role: "assistant", Text: reply, Source: r.URL.Query().Get("s"), TraceID: id, Corrected: r.URL.Query().Get("c"), }) } // UserText rides beside the messages so the correction form can hand the // conversation back on the redirect: this page has no session and no JS, so // what is on screen is what the query params carry. renderPage(w, chatTmpl, struct { Error string Messages []chatMsg Targets []router.Intent UserText string }{Messages: msgs, Targets: correctionTargets, UserText: r.URL.Query().Get("q")}) } // handleChatAPI processes a chat message POST and redirects back to /chat. // // State-changing, and the widest surface on this server: the text reaches the // router, the LLM, and through mavend's applyAction the whole action path // including `act` — so it is gated on the same step-up as POST /tools and // POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is // fail-open exactly like the others (see stepUpOK); with -require-stepup it // denies, which is the point of that flag. func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { if r.Method != http.MethodPost { writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed, "POST only", nil) return } if !requireCore(w, r, core, "chat") { return } if !stepUpGate(w, r, session, requireStepUp) { return } text := strings.TrimSpace(r.FormValue("text")) if text == "" { http.Redirect(w, r, "/chat", http.StatusSeeOther) return } // One conversation id for the whole web chat, and a different one from // telegram or the mic. A parked question belongs to the reach that was // asked; before this, a clarify nobody answered on the web ate the next // utterance spoken at the mic (Vikunja #466). This server has no // per-browser session, so every browser tab is the same conversation — // which is right for a single-owner box. reply, err := core.Chat(r.Context(), "web", text) if err != nil { writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed, "chat failed", fmt.Errorf("run web chat turn: %w", err)) return } // The claiming query source rides back on the redirect so the page can show // it. Empty for a turn no source claimed, which is most of them. dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply) if reply.Source != "" { dest += "&s=" + url.QueryEscape(reply.Source) } // The trace id rides along so the reply can carry a correction gesture // (V-630). Absent when nothing persisted, and the page then offers none. if reply.TraceID != 0 { dest += "&t=" + strconv.FormatInt(reply.TraceID, 10) } http.Redirect(w, r, dest, http.StatusSeeOther) } // handleCorrectAPI records that the last turn was routed wrongly (V-630). // // A correction is the only supervised signal this box gets, and everything else // in the trace accumulates on its own. So the gesture has to cost nothing: one // POST from the reply he is already looking at, carrying the trace id and // optionally the intent it should have been. An unstated target is accepted, // because a turn marked wrong with no target is still a usable negative. // // Step-up gated like POST /api/chat, and that costs the gesture nothing: he // tapped to send the turn he is now correcting, so the session is already up. // It is gated because trace ids are sequential integers and this writes the one // table the routing heads (V-546) will be fitted on. A caller who can guess an // id could otherwise mislabel turns he never corrected. func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { if r.Method != http.MethodPost { writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed, "POST only", nil) return } if !requireCore(w, r, core, "correct") { return } if !stepUpGate(w, r, session, requireStepUp) { return } id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64) if err != nil || id <= 0 { writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest, "trace_id required", err) return } shouldBe := strings.TrimSpace(r.FormValue("should_be")) // Only one of the seven, or nothing. Free text here would put an unroutable // label in the one table V-632 fits prototypes from. if shouldBe != "" && !isCorrectionTarget(shouldBe) { writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest, "should_be must be one of the seven intents", nil) return } if err := core.CorrectTurn(r.Context(), id, shouldBe); err != nil { // A turn past the retention bound is gone, and saying so is different // from saying the write broke. if errors.Is(err, ipc.ErrNoSuchTrace) { writeProblem(w, r, http.StatusNotFound, problemResourceNotFound, "that turn is no longer stored", fmt.Errorf("correct turn %d: %w", id, err)) return } writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed, "correction failed", fmt.Errorf("correct turn %d: %w", id, err)) return } stamp := shouldBe if stamp == "" { stamp = "wrong" } // Back to the conversation he was in, with the turn still on screen. The // query params carry it, so the correction is preserved by re-sending them. dest := "/chat?q=" + url.QueryEscape(r.FormValue("q")) + "&r=" + url.QueryEscape(r.FormValue("rep")) + "&c=" + url.QueryEscape(stamp) if s := r.FormValue("s"); s != "" { dest += "&s=" + url.QueryEscape(s) } http.Redirect(w, r, dest, http.StatusSeeOther) } // isCorrectionTarget — one of the seven, and nothing else. func isCorrectionTarget(s string) bool { for _, t := range correctionTargets { if string(t) == s { return true } } return false }