35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
201 lines
7.7 KiB
Go
201 lines
7.7 KiB
Go
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
|
|
}
|