Files
Maven/cmd/mavweb/chat.go
claude a4b4733767 the correction gesture is step-up gated after all (V-630)
Trace ids are sequential integers and the label table is the one thing the
routing heads will be fitted on, so an ungated POST let anyone past the
transport gate mislabel turns the owner never touched.

The cost argument for leaving it open does not hold: he tapped to send the
turn he is correcting, so the session is already up when the buttons appear.
2026-08-06 19:48:29 +04:00

196 lines
7.4 KiB
Go

package main
import (
_ "embed"
"errors"
"log"
"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, 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 {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if !requireCore(w, core, "chat") {
return
}
if !stepUpGate(w, 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 {
log.Printf("chat api: %v", err)
http.Redirect(w, r, "/chat", http.StatusSeeOther)
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 {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if !requireCore(w, core, "correct") {
return
}
if !stepUpGate(w, session, requireStepUp) {
return
}
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, "trace_id required", http.StatusBadRequest)
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) {
http.Error(w, "should_be must be one of the seven intents", http.StatusBadRequest)
return
}
if err := core.CorrectTurn(r.Context(), id, shouldBe); err != nil {
log.Printf("correct turn %d: %v", id, err)
// A turn past the retention bound is gone, and saying so is different
// from saying the write broke.
if errors.Is(err, ipc.ErrNoSuchTrace) {
http.Error(w, "that turn is no longer stored", http.StatusNotFound)
return
}
http.Error(w, "correction failed", http.StatusBadGateway)
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
}