package main import ( _ "embed" "log" "net/http" "net/url" "strings" "github.com/kami/maven/internal/ipc" "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 } // 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 != "" { msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")}) } renderPage(w, chatTmpl, struct { Error string Messages []chatMsg }{Messages: msgs}) } // 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) } http.Redirect(w, r, dest, http.StatusSeeOther) }