888c1c6768
V-539 said SearXNG claims every world question, including invented terms, so Kiwix is never reached. Measured today against the configured instance: seven of eight invented Russian questions now return zero results, and Response.Empty() already passes those to the ZIM. The premise moved with the upstream engine set in three days. The three quality signals the task named were recorded per query and none separate the sets. Token overlap is zero for the one bad claim and also zero for "столица Франции", whose answer is Париж. Empty snippets never fire, because ParseResponse already drops a hit with no text. SearXNG returned no corrections or suggestions even for the query it silently respelled. So no threshold is built: it would cost a real answer to save one invented word. What ships is the second half. The claiming query source crosses the IPC seam on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It rides the context rather than a return value, because handleText answers every reach through one string and the mic, telegram and the web all share it. Chat now returns ChatReply instead of a bare string. Full -race suite green.
1705 lines
58 KiB
Go
1705 lines
58 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"embed"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/kami/maven/internal/audio"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/pattern"
|
|
"github.com/kami/maven/internal/tasks"
|
|
"github.com/kami/maven/internal/tool"
|
|
"github.com/kami/maven/internal/voice"
|
|
"github.com/kami/maven/internal/webauthn"
|
|
)
|
|
|
|
// presenceSignals — the only fact keys /api/signal may write. mavweb is a
|
|
// network-facing surface inside wg; an allowlist keeps a compromised caller
|
|
// boxed to forging weak presence signals (reachability, multi-source, never
|
|
// truth) — it can't write arbitrary facts. ponytail: floor auth (wg-only); a
|
|
// per-signal token belongs here if the tunnel ever hosts untrusted devices.
|
|
var presenceSignals = map[string]string{
|
|
"desk_active": "infer:hyprland",
|
|
"page_heartbeat": "infer:heartbeat",
|
|
"wg_handshake": "infer:wg",
|
|
}
|
|
|
|
//go:embed static/*
|
|
var staticFiles embed.FS
|
|
|
|
//go:embed dash.html
|
|
var dashHTML string
|
|
|
|
//go:embed history.html
|
|
var historyHTML string
|
|
|
|
//go:embed trace.html
|
|
var traceHTML string
|
|
|
|
//go:embed notifications.html
|
|
var notificationsHTML string
|
|
|
|
//go:embed reminders.html
|
|
var remindersHTML string
|
|
|
|
//go:embed tasks.html
|
|
var tasksHTML string
|
|
|
|
//go:embed voice.html
|
|
var voiceHTML string
|
|
|
|
//go:embed ecosystem.html
|
|
var ecosystemHTML string
|
|
|
|
//go:embed morning.html
|
|
var morningHTML string
|
|
|
|
//go:embed events.html
|
|
var eventsHTML string
|
|
|
|
//go:embed tools.html
|
|
var toolsHTML string
|
|
|
|
//go:embed routines.html
|
|
var routinesHTML string
|
|
|
|
//go:embed chat.html
|
|
var chatPageHTML string
|
|
|
|
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
|
|
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
|
|
// with the sidebar assembled by a strings.Builder, which is the one piece of
|
|
// markup that was still concatenated in Go.
|
|
//
|
|
//go:embed shell.html
|
|
var shellHTML string
|
|
|
|
// ── Ethos Workstation Shell ──
|
|
//
|
|
// Two template pieces that wrap every page:
|
|
// {{template "shellTop" "<page-key>"}} ← opens <html>, topbar, sidebar, content
|
|
// {{template "shellBottom"}} ← closes content, inspector, </html>
|
|
//
|
|
// The page-key argument highlights the active sidebar link and sets breadcrumbs.
|
|
|
|
// sidebarSections maps sidebar section → page entries {label, url, icon}
|
|
var sidebarSections = []struct {
|
|
Label string
|
|
Pages []struct{ Label, URL, Key string }
|
|
}{
|
|
{
|
|
Label: "Workspace",
|
|
Pages: []struct{ Label, URL, Key string }{
|
|
{Label: "Dashboard", URL: "/dash", Key: "dash"},
|
|
},
|
|
},
|
|
{
|
|
Label: "Infrastructure",
|
|
Pages: []struct{ Label, URL, Key string }{
|
|
{Label: "History", URL: "/history", Key: "history"},
|
|
},
|
|
},
|
|
{
|
|
Label: "Automation",
|
|
Pages: []struct{ Label, URL, Key string }{
|
|
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
|
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
|
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
|
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
|
{Label: "Routines", URL: "/routines", Key: "routines"},
|
|
{Label: "Morning", URL: "/morning", Key: "morning"},
|
|
{Label: "Intake", URL: "/events", Key: "events"},
|
|
},
|
|
},
|
|
{
|
|
Label: "Ecosystem",
|
|
Pages: []struct{ Label, URL, Key string }{
|
|
{Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"},
|
|
},
|
|
},
|
|
{
|
|
Label: "AI",
|
|
Pages: []struct{ Label, URL, Key string }{
|
|
{Label: "Chat", URL: "/chat", Key: "chat"},
|
|
{Label: "Voice", URL: "/", Key: "voice"},
|
|
},
|
|
},
|
|
{
|
|
Label: "Settings",
|
|
Pages: []struct{ Label, URL, Key string }{
|
|
{Label: "Tools", URL: "/tools", Key: "tools"},
|
|
{Label: "Model", URL: "/models", Key: "models"},
|
|
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
|
|
// sidebar template wraps it in the <use> reference.
|
|
func pageIcon(key string) string {
|
|
switch key {
|
|
case "dash":
|
|
return "i-grid"
|
|
case "history":
|
|
return "i-clock"
|
|
case "trace":
|
|
return "i-wave"
|
|
case "notifications":
|
|
return "i-bell"
|
|
case "tasks":
|
|
return "i-grid"
|
|
case "reminders":
|
|
return "i-calendar"
|
|
case "routines":
|
|
return "i-repeat"
|
|
case "morning":
|
|
return "i-calendar"
|
|
case "chat":
|
|
return "i-message"
|
|
case "voice":
|
|
return "i-mic"
|
|
case "ecosystem":
|
|
return "i-grid"
|
|
case "tools":
|
|
return "i-settings"
|
|
case "models":
|
|
return "i-wave"
|
|
case "passkey":
|
|
return "i-lock"
|
|
default:
|
|
return "i-search"
|
|
}
|
|
}
|
|
|
|
// pageTitle returns the human-readable page title for the given key.
|
|
func pageTitle(key string) string {
|
|
switch key {
|
|
case "dash":
|
|
return "Dashboard"
|
|
case "history":
|
|
return "History"
|
|
case "trace":
|
|
return "Rule Trace"
|
|
case "notifications":
|
|
return "Notifications"
|
|
case "tasks":
|
|
return "Tasks"
|
|
case "reminders":
|
|
return "Reminders"
|
|
case "routines":
|
|
return "Routines"
|
|
case "morning":
|
|
return "Morning Routines"
|
|
case "chat":
|
|
return "Chat"
|
|
case "voice":
|
|
return "Voice"
|
|
case "ecosystem":
|
|
return "Ecosystem"
|
|
case "tools":
|
|
return "Tools"
|
|
case "models":
|
|
return "Resident Model"
|
|
case "passkey":
|
|
return "Passkey"
|
|
default:
|
|
return key
|
|
}
|
|
}
|
|
|
|
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
|
func shellFuncs() template.FuncMap {
|
|
return template.FuncMap{
|
|
"pageTitle": pageTitle,
|
|
"pageIcon": pageIcon,
|
|
"sidebarSections": func() any { return sidebarSections },
|
|
"ago": func(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
return time.Since(t).Round(time.Second).String() + " ago"
|
|
},
|
|
"connected": func() bool { return true }, // if page renders, core was available
|
|
}
|
|
}
|
|
|
|
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
|
|
// a small fetch loop refreshes the tables in place. html/template escapes the
|
|
// user text in facts/nudges. Read-only: browses the append-only store via
|
|
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
|
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
|
|
|
|
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
|
// human surface is here (they ship no web UI of their own).
|
|
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
|
|
|
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
|
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML))
|
|
|
|
// morningTmpl — read-only view of today's checklist state per configured
|
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
|
// checklist state changes on the scale of minutes, not seconds.
|
|
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
|
|
|
|
func noCache(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
addr := flag.String("addr", "127.0.0.1:9200", "HTTP listen address (loopback by default; pass e.g. \":9200\" or a LAN IP deliberately for wider exposure — POST /chat and /routines are state-changing)")
|
|
voiceAddr := flag.String("voice", "127.0.0.1:9100", "voice server TCP addr (host:port)")
|
|
// ntfyWS: the ntfy WebSocket subscribe URL the PWA connects to for in-app
|
|
// nudge delivery, e.g. wss://ntfy.kvmx.ru/maven/ws?auth=<base64-token>. The
|
|
// client subscribes directly (lowest overhead — mavweb isn't in the path);
|
|
// we only serve it the URL so the deny-all auth token stays deployment
|
|
// config, never baked into the static JS. Empty ⇒ /api/ntfy returns 204 and
|
|
// the PWA skips subscription (voice-only, as before).
|
|
ntfyWS := flag.String("ntfy", "", "ntfy WebSocket subscribe URL served to the PWA (e.g. wss://host/topic/ws?auth=...)")
|
|
// coreSock: mavend's IPC socket. When set, /api/signal writes presence
|
|
// facts through CoreAPI (page heartbeat from the PWA, desk_active from a PC
|
|
// script). Empty ⇒ /api/signal returns 503 and presence stays unfed.
|
|
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
|
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
|
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
|
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
|
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
|
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
|
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
|
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
|
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
|
// reads the work calendar as a signal instead of holding a work credential
|
|
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
|
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
|
flag.Parse()
|
|
|
|
var core ipc.CoreAPI
|
|
// swapConn — a second connection, for /models and nothing else. A model swap
|
|
// is a multi-minute IPC call and ipc.Client serialises everything on one
|
|
// mutex, so sharing the connection would freeze every other page for the
|
|
// length of the load. See handleModels.
|
|
var swapConn modelController
|
|
if *coreSock != "" {
|
|
c, err := ipc.DialWait(*coreSock, 60*time.Second)
|
|
if err != nil {
|
|
log.Fatalf("dial core %s: %v", *coreSock, err)
|
|
}
|
|
defer c.Close()
|
|
core = c
|
|
if sc, err := ipc.Dial(*coreSock); err != nil {
|
|
log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err)
|
|
} else {
|
|
defer sc.Close()
|
|
swapConn = sc
|
|
}
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
sub, err := fs.Sub(staticFiles, "static")
|
|
if err != nil {
|
|
log.Fatalf("static fs: %v", err)
|
|
}
|
|
staticHandler := noCache(http.FileServer(http.FS(sub)))
|
|
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
staticHandler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
handleVoice(w, r)
|
|
}))
|
|
|
|
// /ws and /api/ptt are registered further down, next to /api/chat: they
|
|
// carry the same step-up gate and so need stepUpSession, which is only
|
|
// built once the passkey endpoints are wired.
|
|
mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("pong"))
|
|
})
|
|
mux.HandleFunc("/api/ntfy", func(w http.ResponseWriter, r *http.Request) {
|
|
if *ntfyWS == "" {
|
|
w.WriteHeader(http.StatusNoContent) // not configured → PWA skips
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte(*ntfyWS))
|
|
})
|
|
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
|
handleSignal(w, r, core)
|
|
})
|
|
// Off unless configured: no token, no route — an unconfigured ingest is not
|
|
// a 503 waiting to be probed, it does not exist.
|
|
if *ambientToken != "" {
|
|
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
|
handleAmbient(w, r, core, *ambientToken)
|
|
})
|
|
log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient")
|
|
}
|
|
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
|
handleDash(w, r, core)
|
|
})
|
|
mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) {
|
|
handleHistory(w, r, core)
|
|
})
|
|
mux.HandleFunc("/trace", func(w http.ResponseWriter, r *http.Request) {
|
|
handleTrace(w, r, core)
|
|
})
|
|
mux.HandleFunc("/notifications", func(w http.ResponseWriter, r *http.Request) {
|
|
handleNotifications(w, r, core)
|
|
})
|
|
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
|
handleReminders(w, r, core)
|
|
})
|
|
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
|
|
// why a task write is not in the same class as /tools or /routines.
|
|
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
|
|
handleTasks(w, r, core)
|
|
})
|
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
|
handleMorning(w, r, core)
|
|
})
|
|
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
|
handleEvents(w, r, core)
|
|
})
|
|
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
|
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
|
handleEcosystem(w, r, ecoURLsCfg, core)
|
|
})
|
|
// ----- passkey (WebAuthn) endpoints -----
|
|
// Wired when both -core and a configured origin are present. The origin
|
|
// must match the browser's view of mavweb (e.g. https://maven.kvmx.ru).
|
|
// Passkey registration + assertion are the step-up mechanism for
|
|
// AuthStepUp actions (tool enable). Without -webauthn-origin, these
|
|
// endpoints return 503 and step-up is unavailable (FloorSession).
|
|
// stepUpSession stays nil unless the passkey endpoints are wired — it can
|
|
// only ever be asserted via AssertFinish, so gating POST /tools on it
|
|
// without those endpoints would make tool enable/disable permanently 403.
|
|
// Without WebAuthn configured, /tools falls back to the transport-level
|
|
// auth it sits behind (wg+nginx+auth), same as before step-up existed.
|
|
var stepUpSession *webauthn.PasskeySession
|
|
|
|
if *pkOrigin != "" && *pkRPID != "" && core != nil {
|
|
stepUpSession = webauthn.NewPasskeySession(5 * time.Minute)
|
|
pk, err := newPasskeyHandle(webauthn.Config{
|
|
Origin: *pkOrigin,
|
|
RPID: *pkRPID,
|
|
RPName: "maven",
|
|
}, core, *pkFile, stepUpSession)
|
|
if err != nil {
|
|
log.Fatalf("passkey store: %v", err)
|
|
}
|
|
mux.HandleFunc("/auth/passkey", pk.Page)
|
|
mux.HandleFunc("/auth/webauthn/register/begin", pk.RegisterBegin)
|
|
mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish)
|
|
mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin)
|
|
mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish)
|
|
}
|
|
if stepUpSession == nil {
|
|
// One surface per line: these are read in a terminal at the moment
|
|
// someone is deciding whether the box is safe to expose.
|
|
surfaces := []string{
|
|
"POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES",
|
|
"POST /routines accepting schedules recurring firing",
|
|
"POST /models chooses the resident model that routes and words every turn",
|
|
"POST /api/revert voids the latest fact for a key",
|
|
"POST /api/chat reaches the router, the LLM and, through applyAction, the act path",
|
|
"POST /api/ptt the same, from audio",
|
|
"GET /ws the same, streamed",
|
|
}
|
|
if *requireStepUp {
|
|
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):")
|
|
} else {
|
|
log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:")
|
|
}
|
|
for _, s := range surfaces {
|
|
log.Printf("SECURITY: %s", s)
|
|
}
|
|
if *requireStepUp {
|
|
log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
|
} else {
|
|
log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
|
}
|
|
}
|
|
|
|
// /tools — the authed enable surface. maven proposes acts she can't run;
|
|
// this page is where a human reviews and enables them (proposed→enabled).
|
|
// Enabling is the boundary-moving act (docs/design.md § Tool registration —
|
|
// drafting is suggest, enabling is act), so it lives ONLY here,
|
|
// behind wg+nginx+auth — never the voice/chat path.
|
|
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
|
handleTools(w, r, core, stepUpSession, *requireStepUp)
|
|
})
|
|
// /routines — the authed accept surface. Registered here, next to /tools,
|
|
// because accepting shares the same step-up gate.
|
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
|
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
|
})
|
|
// /models — the resident-model surface (Vikunja #250). Same step-up gate as
|
|
// /tools, and for a comparable reason: which model is loaded decides how every
|
|
// utterance is routed and how every reply is worded. GET is read-only.
|
|
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
|
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
|
})
|
|
|
|
// State-changing routes on this server, and their gate (Vikunja #317):
|
|
//
|
|
// POST /tools step-up — defines argv that internal/tool executes
|
|
// POST /routines step-up — accepting schedules recurring firing
|
|
// POST /models step-up — replaces the model that routes and phrases
|
|
// POST /api/revert step-up — voids the latest fact for a key
|
|
// POST /api/chat step-up — reaches the router, LLM and the act path
|
|
// POST /api/ptt step-up — audio into runTurn, so the same router,
|
|
// LLM and act path as /api/chat
|
|
// GET /ws step-up — same, streamed
|
|
// POST /api/signal none — appends a presence fact, no argv, no act
|
|
// POST /api/ambient shared secret — notification relay, constant-time
|
|
// token compare, poster is a phone service
|
|
// and not a browser, so step-up cannot apply
|
|
//
|
|
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
|
|
// otherwise fail-open unless -require-stepup, which denies.
|
|
//
|
|
// /api/ptt and /ws used to be ungated, justified by mavend's voice port
|
|
// being reachable only inside the deploy. That argument does not hold:
|
|
// mavweb is the thing proxying into it from outside. Speaking "выключи
|
|
// свет" is not a smaller act than typing it (Vikunja #317).
|
|
//
|
|
// The gate here is per-request, which costs the hands-free case a passkey
|
|
// assertion per turn whenever WebAuthn is configured. A session-scoped
|
|
// assertion covering a run of turns is the right shape and is its own task.
|
|
//
|
|
// GET /chat only renders the page and echoes back the q/r query params the
|
|
// POST redirect set — nothing to gate.
|
|
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
|
handleChatPage(w, r, core)
|
|
})
|
|
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
|
handleChatAPI(w, r, core, stepUpSession, *requireStepUp)
|
|
})
|
|
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
|
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
|
})
|
|
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
|
})
|
|
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
|
|
handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
|
})
|
|
|
|
srv := &http.Server{Addr: *addr, Handler: mux}
|
|
|
|
go func() {
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt)
|
|
<-sig
|
|
log.Println("shutting down...")
|
|
srv.Close()
|
|
}()
|
|
|
|
log.Printf("mavweb listening on %s, voice → %s", *addr, *voiceAddr)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
|
if !stepUpOK(session, requireStepUp) {
|
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
return
|
|
}
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
|
OriginPatterns: []string{"*"},
|
|
})
|
|
if err != nil {
|
|
log.Printf("ws accept: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
|
|
|
ctx := r.Context()
|
|
|
|
var d net.Dialer
|
|
tc, err := d.DialContext(ctx, "tcp", voiceAddr)
|
|
if err != nil {
|
|
log.Printf("dial voice: %v", err)
|
|
writeWSErr(conn, ctx, "voice unavailable")
|
|
return
|
|
}
|
|
defer tc.Close()
|
|
|
|
for {
|
|
_, msg, err := conn.Read(ctx)
|
|
if err != nil {
|
|
log.Printf("ws read: %v", err)
|
|
return
|
|
}
|
|
if len(msg) < 4 {
|
|
log.Printf("ws msg too short (%d bytes)", len(msg))
|
|
continue
|
|
}
|
|
|
|
log.Printf("ws got %d bytes from client", len(msg))
|
|
pcm := audio.Audio{Format: audio.PCM16kMono, Bytes: msg}
|
|
|
|
req := voice.Request{
|
|
ID: uint64(time.Now().UnixNano()),
|
|
Method: voice.MethodPushToTalk,
|
|
Params: mustMarshal(voice.PushToTalkReq{
|
|
Audio: pcm,
|
|
Lang: "mixed",
|
|
Surface: voice.SurfacePCClient,
|
|
}),
|
|
}
|
|
|
|
if err := writeFrame(tc, &req); err != nil {
|
|
log.Printf("write voice req: %v", err)
|
|
return
|
|
}
|
|
|
|
// Read frames until we get the matching Response (handling any interleaved Pushes)
|
|
for {
|
|
resp, push, err := readOneFrame(tc)
|
|
if err != nil {
|
|
log.Printf("read voice: %v", err)
|
|
return
|
|
}
|
|
if push != nil {
|
|
data, _ := json.Marshal(push)
|
|
conn.Write(ctx, websocket.MessageText, data)
|
|
continue
|
|
}
|
|
if resp.Error != nil {
|
|
writeWSErr(conn, ctx, resp.Error.Message)
|
|
break
|
|
}
|
|
var pttResp voice.PushToTalkResp
|
|
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
|
log.Printf("unmarshal resp: %v", err)
|
|
break
|
|
}
|
|
if pttResp.ReplyText != "" {
|
|
conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText))
|
|
}
|
|
if len(pttResp.ReplyAudio.Bytes) > 0 {
|
|
conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleSignal ingests one presence signal and writes a fresh fact through
|
|
// CoreAPI. The fact's timestamp (now) is all the presence scorer reads; value
|
|
// is a marker. Only allowlisted keys are accepted (see presenceSignals).
|
|
func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if core == nil {
|
|
http.Error(w, "presence ingest disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
key := r.URL.Query().Get("key")
|
|
source, ok := presenceSignals[key]
|
|
if !ok {
|
|
http.Error(w, "unknown signal key", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// kind=env: an observation about the device/surface, NOT a self-fact — a
|
|
// passive signal never writes truth about you (spec), it only feeds
|
|
// presence. confidence 1.0: the reading ("input happened") is certain;
|
|
// presence applies its own per-signal weight/decay on top.
|
|
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
|
Ts: time.Now(),
|
|
Kind: "env",
|
|
Key: key,
|
|
Value: `"active"`,
|
|
Source: source,
|
|
Confidence: 1.0,
|
|
}); err != nil {
|
|
log.Printf("signal %s: %v", key, err)
|
|
http.Error(w, "write failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "dash disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
pres, err1 := core.Presence(ctx)
|
|
facts, err2 := core.RecentFacts(ctx, 50)
|
|
nudges, err3 := core.RecentNudges(ctx, 50)
|
|
notes, err4 := core.RecentNotes(ctx, 50)
|
|
if err := cmp.Or(err1, err2, err3, err4); err != nil {
|
|
log.Printf("dash: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := dashTmpl.Execute(w, struct {
|
|
Presence ipc.Presence
|
|
Facts []ipc.Fact
|
|
Nudges []ipc.Nudge
|
|
Notes []ipc.Note
|
|
}{pres, facts, nudges, notes}); err != nil {
|
|
log.Printf("dash render: %v", err)
|
|
}
|
|
}
|
|
|
|
// toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form
|
|
// POSTs back to /tools to enable a proposal. html/template escapes tool names +
|
|
// utterances (they came from voice STT — untrusted text).
|
|
var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
|
|
m := shellFuncs()
|
|
m["join"] = strings.Join
|
|
m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }
|
|
m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) }
|
|
return m
|
|
}()).Parse(shellHTML + toolsHTML))
|
|
|
|
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
|
|
|
|
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
|
|
|
|
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
|
|
|
|
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
|
|
|
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
|
|
|
|
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
|
|
|
|
// routinesTmpl — the proposed-routine review surface. One row per thing maven
|
|
// noticed, in her words, with at most two actions: accept or dismiss.
|
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML))
|
|
|
|
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
|
m := shellFuncs()
|
|
m["fmtTime"] = func(t *time.Time) string {
|
|
if t == nil || t.IsZero() {
|
|
return "—"
|
|
}
|
|
return t.Format("15:04:05")
|
|
}
|
|
m["join"] = strings.Join
|
|
return m
|
|
}()).Parse(shellHTML + traceHTML))
|
|
|
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "history disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
facts, err := core.RecentFacts(ctx, 200)
|
|
if err != nil {
|
|
log.Printf("history: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := historyTmpl.Execute(w, struct {
|
|
Facts []ipc.Fact
|
|
}{facts}); err != nil {
|
|
log.Printf("history render: %v", err)
|
|
}
|
|
}
|
|
|
|
func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "notifications disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
nudges, err := core.RecentNudges(ctx, 50)
|
|
if err != nil {
|
|
log.Printf("notifications: %v", err)
|
|
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
// The outbox, on the page that already answers "what did she send".
|
|
// A failed or dropped attempt is why she went quiet, and until now it was
|
|
// recorded and unreadable (Vikunja #390). Filter with ?status=dropped.
|
|
status := r.URL.Query().Get("status")
|
|
attempts, err := core.DeliveryAttempts(ctx, status, 50)
|
|
if err != nil {
|
|
// The nudge list is still worth showing, so this is a note on the page
|
|
// rather than a dead page.
|
|
log.Printf("notifications: delivery attempts: %v", err)
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := notificationsTmpl.Execute(w, map[string]any{
|
|
"Nudges": nudges,
|
|
"Attempts": deliveryRows(attempts),
|
|
"Status": status,
|
|
}); err != nil {
|
|
log.Printf("notifications template: %v", err)
|
|
}
|
|
}
|
|
|
|
// deliveryRow is one outbox line, with every timestamp already formatted so
|
|
// the template holds no date logic — same shape as taskRow.
|
|
type deliveryRow struct {
|
|
Kind string
|
|
Target string
|
|
Channel string
|
|
Status string
|
|
Created string
|
|
Completed string
|
|
}
|
|
|
|
func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
|
out := make([]deliveryRow, 0, len(as))
|
|
for _, a := range as {
|
|
target := a.Rule
|
|
if target == "" && a.ReminderID != 0 {
|
|
target = "reminder #" + strconv.FormatInt(a.ReminderID, 10)
|
|
}
|
|
row := deliveryRow{
|
|
Kind: a.Kind,
|
|
Target: target,
|
|
Channel: a.Channel,
|
|
Status: a.Status,
|
|
Created: a.Created.Format("02.01 15:04"),
|
|
}
|
|
if a.Completed != nil {
|
|
row.Completed = a.Completed.Format("15:04")
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reminderRow is one line on /reminders, with the payload unwrapped and both
|
|
// timestamps already in his clock.
|
|
//
|
|
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
|
|
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
|
|
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
|
|
// shape he never chose, and a time on a page he reads is the time on his wall.
|
|
type reminderRow struct {
|
|
Created string
|
|
Fires string
|
|
Status string
|
|
Text string
|
|
}
|
|
|
|
// reminderText unwraps the {"text":...} payload the router writes.
|
|
//
|
|
// A copy of store.ReminderText rather than a call to it, because mavweb is one
|
|
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
|
|
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
|
|
// whoever renders it. Payload that is not that shape is shown as he said it.
|
|
func reminderText(payload string) string {
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
|
if t, ok := m["text"]; ok {
|
|
if s, isStr := t.(string); isStr && s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return strings.TrimSpace(payload)
|
|
}
|
|
|
|
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
|
out := make([]reminderRow, 0, len(rs))
|
|
for _, r := range rs {
|
|
out = append(out, reminderRow{
|
|
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
|
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
|
Status: r.Status,
|
|
Text: reminderText(r.Payload),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
reminders, err := core.ListReminders(ctx, 50)
|
|
if err != nil {
|
|
log.Printf("reminders: %v", err)
|
|
http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
|
|
log.Printf("reminders template: %v", err)
|
|
}
|
|
}
|
|
|
|
// now — the wall clock, indirected so the task page can be rendered at a fixed
|
|
// instant in a test. internal/tasks is pure and the daemon path already ranks
|
|
// through a clock it is handed; the page had no reason to be the one surface
|
|
// that could only be tested at whatever time it happened to run.
|
|
var now = time.Now
|
|
|
|
// resolvedShown — how many finished tasks the page renders. The list is
|
|
// history, it only grows, and the rows below the first screen are read by
|
|
// nobody.
|
|
const resolvedShown = 50
|
|
|
|
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
|
// template holds no date logic.
|
|
type taskRow struct {
|
|
ID int64
|
|
Text string
|
|
Source string
|
|
Evidence string
|
|
Status string
|
|
Due string
|
|
Created string
|
|
Resolved string
|
|
ResolvedBy string
|
|
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
|
// Russian, empty when nothing distinguished the task. Blank is the honest
|
|
// rendering: he never said this one mattered more.
|
|
Why string
|
|
}
|
|
|
|
// handleTasks serves the task review surface (GET) and the four writes it
|
|
// offers (POST): add, confirm, done, drop.
|
|
//
|
|
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
|
// point: enabling a tool defines argv Maven will execute, and accepting a
|
|
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
|
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
|
// weaker caller can do here is write a line onto a list he reads himself. It
|
|
// still sits behind whatever transport auth fronts mavweb, like every other
|
|
// page.
|
|
//
|
|
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
|
// from something she read into work he owns. That review step is why derived
|
|
// tasks are captured as candidates in the first place.
|
|
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
var msg, errMsg string
|
|
if r.Method == http.MethodPost {
|
|
var err error
|
|
msg, err = applyTaskPost(ctx, core, r)
|
|
if err != nil {
|
|
log.Printf("tasks: %v", err)
|
|
errMsg = err.Error()
|
|
}
|
|
}
|
|
|
|
all, err := core.ListTasks(ctx, "")
|
|
if err != nil {
|
|
log.Printf("tasks: list: %v", err)
|
|
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
|
// and the voice reply can never disagree about what comes first. Resolved
|
|
// rows keep store order (newest first) — ranking finished work is pointless.
|
|
var live []tasks.Item
|
|
var resolved []taskRow
|
|
resolvedTotal := 0
|
|
for _, t := range all {
|
|
switch t.Status {
|
|
case "candidate", "open":
|
|
live = append(live, tasks.Item{
|
|
ID: t.ID, Text: t.Text, Status: t.Status,
|
|
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
|
})
|
|
default:
|
|
resolvedTotal++
|
|
// Finished work is history, and the history only grows. The page
|
|
// showed every row that ever existed, which is a page that gets
|
|
// slower every month for a section nobody reads past the top of.
|
|
if len(resolved) >= resolvedShown {
|
|
continue
|
|
}
|
|
resolved = append(resolved, taskRow{
|
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
|
ResolvedBy: t.ResolvedBy,
|
|
})
|
|
}
|
|
}
|
|
byID := make(map[int64]ipc.Task, len(all))
|
|
for _, t := range all {
|
|
byID[t.ID] = t
|
|
}
|
|
var cands, open []taskRow
|
|
for _, r := range tasks.Rank(live, now()) {
|
|
t := byID[r.ID]
|
|
row := taskRow{
|
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
|
Why: r.Reason,
|
|
}
|
|
if t.Status == "candidate" {
|
|
// A candidate's due date is Maven's reading of a mail, so its
|
|
// ranking reason is not shown as if he had set a priority.
|
|
row.Why = ""
|
|
cands = append(cands, row)
|
|
} else {
|
|
open = append(open, row)
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tasksTmpl.Execute(w, struct {
|
|
Msg, Err string
|
|
Candidates []taskRow
|
|
Open []taskRow
|
|
Resolved []taskRow
|
|
ResolvedMore bool
|
|
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
|
|
log.Printf("tasks render: %v", err)
|
|
}
|
|
}
|
|
|
|
// applyTaskPost performs one write and returns the message to show. A bad
|
|
// request returns an error, which the page renders inline rather than as a
|
|
// bare 400 — this is a form surface, not an API.
|
|
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
|
action := r.FormValue("action")
|
|
if action == "add" {
|
|
text := strings.TrimSpace(r.FormValue("text"))
|
|
if text == "" {
|
|
return "", errors.New("empty task text")
|
|
}
|
|
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
|
// Importance is his, stated on the form. Out-of-range values are
|
|
// clamped rather than rejected — a bad select is not worth a 400.
|
|
if v := r.FormValue("weight"); v != "" {
|
|
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
|
|
// form value is not a place to accept trailing garbage.
|
|
wgt, err := strconv.Atoi(v)
|
|
if err != nil || wgt < 0 {
|
|
return "", fmt.Errorf("bad weight %q", v)
|
|
}
|
|
if wgt > tasks.MaxWeight {
|
|
wgt = tasks.MaxWeight
|
|
}
|
|
req.Weight = wgt
|
|
}
|
|
if d := r.FormValue("due"); d != "" {
|
|
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
|
|
if err != nil {
|
|
return "", fmt.Errorf("bad due date %q", d)
|
|
}
|
|
req.Due = &due
|
|
}
|
|
resp, err := core.CaptureTask(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.Promoted {
|
|
return "confirmed a candidate maven had found", nil
|
|
}
|
|
if !resp.Created {
|
|
return "already on the list", nil
|
|
}
|
|
return "added task", nil
|
|
}
|
|
|
|
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
|
if err != nil {
|
|
return "", errors.New("invalid id")
|
|
}
|
|
var status, msg string
|
|
switch action {
|
|
case "confirm":
|
|
status, msg = "open", "confirmed task"
|
|
case "done":
|
|
status, msg = "done", "task done"
|
|
case "drop":
|
|
status, msg = "dropped", "dropped task"
|
|
default:
|
|
return "", fmt.Errorf("unknown action %q", action)
|
|
}
|
|
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
|
return "", err
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
func fmtTaskTime(t *time.Time) string {
|
|
if t == nil || t.IsZero() {
|
|
return "—"
|
|
}
|
|
return t.Local().Format("02 Jan 15:04")
|
|
}
|
|
|
|
func fmtTaskDate(t *time.Time) string {
|
|
if t == nil || t.IsZero() {
|
|
return "—"
|
|
}
|
|
return t.Local().Format("02 Jan")
|
|
}
|
|
|
|
// routineView is one line on the page: what maven noticed, in her words, and
|
|
// how long ago she noticed it. A view model, not a database row — the template
|
|
// never formats an interval or a timestamp itself.
|
|
type routineView struct {
|
|
ID int64
|
|
Phrase string
|
|
Noticed string
|
|
}
|
|
|
|
// handleRoutines serves the routine review surface (GET) and answers a
|
|
// proposal (POST id + action=accept|dismiss).
|
|
//
|
|
// Accept is gated at step-up, the same tier as enabling a tool: saying yes
|
|
// hands the trigger loop a new standing reason to speak to the human, so it
|
|
// moves the boundary and only an authed surface may do it. Dismiss is not
|
|
// gated — it only ever removes a reason to speak, so the worst a weaker caller
|
|
// can do is make maven quieter.
|
|
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
|
if core == nil {
|
|
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
var msg string
|
|
if r.Method == http.MethodPost {
|
|
action := r.FormValue("action")
|
|
// "seed" is the one action with no routine to act on — it is what
|
|
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
|
|
// lives on this route rather than a page of its own because it is
|
|
// already the step-up-gated surface for this table, and a second gated
|
|
// surface is a second thing to get wrong.
|
|
if action == "seed" {
|
|
if !stepUpOK(session, requireStepUp) {
|
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
return
|
|
}
|
|
out, err := seedRoutineEvent(ctx, core, r)
|
|
if err != nil {
|
|
log.Printf("routines: seed: %v", err)
|
|
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
msg = out
|
|
} else {
|
|
idStr := r.FormValue("id")
|
|
var rid int64
|
|
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
switch action {
|
|
case "accept":
|
|
if !stepUpOK(session, requireStepUp) {
|
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
return
|
|
}
|
|
if err := acceptRoutine(ctx, core, rid); err != nil {
|
|
log.Printf("routines: accept %d: %v", rid, err)
|
|
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
msg = "accepted routine — maven will remind you"
|
|
case "dismiss":
|
|
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
|
log.Printf("routines: dismiss %d: %v", rid, err)
|
|
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
msg = "dismissed routine"
|
|
default:
|
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
proposed, err := core.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
log.Printf("routines: list: %v", err)
|
|
http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := routinesTmpl.Execute(w, struct {
|
|
Msg string
|
|
Proposed []routineView
|
|
}{msg, toRoutineViews(proposed)}); err != nil {
|
|
log.Printf("routines render: %v", err)
|
|
}
|
|
}
|
|
|
|
// toRoutineViews turns the wire rows into view models. The phrase comes from
|
|
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
|
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
|
out := make([]routineView, 0, len(rs))
|
|
for _, r := range rs {
|
|
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
|
noticed := "just now"
|
|
if r.CreatedTs > 0 {
|
|
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
|
}
|
|
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
|
|
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
|
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
|
// behind step-up. Voice can park the question and dismiss, never accept.
|
|
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
|
|
// so the pattern detector can be exercised against a running daemon instead of
|
|
// over real days. Refused unless mavend was started with -allow-seed; on an
|
|
// ordinary box the error says so and nothing is written.
|
|
//
|
|
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
|
|
// so a QA sitting can space four seeds three hours apart without doing clock
|
|
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
|
|
// meaning now.
|
|
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
|
key := strings.TrimSpace(r.FormValue("key"))
|
|
value := strings.TrimSpace(r.FormValue("value"))
|
|
if key == "" || value == "" {
|
|
return "", errors.New("seed needs a key and a value")
|
|
}
|
|
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
|
|
if err != nil {
|
|
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
|
|
}
|
|
if agoHours < 0 {
|
|
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
|
|
}
|
|
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
|
|
Key: key,
|
|
Value: value,
|
|
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !resp.Extracted {
|
|
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
|
|
}
|
|
if !resp.Proposed {
|
|
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
|
|
}
|
|
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
|
|
}
|
|
|
|
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
|
proposed, err := core.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var found *ipc.ProposedRoutine
|
|
for i := range proposed {
|
|
if proposed[i].ID == id {
|
|
found = &proposed[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return errors.New("no such proposed routine")
|
|
}
|
|
|
|
// No reminder is created here. Accepting only flips the status; the tick
|
|
// loop reads accepted routines and nudges on the interval (Vikunja #366).
|
|
// The old code made a one-shot reminder, so a non-weekly routine fired
|
|
// once and then went quiet forever.
|
|
return core.AcceptProposedRoutine(ctx, id)
|
|
}
|
|
|
|
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
trace, err := core.TickTrace(ctx)
|
|
if err != nil {
|
|
log.Printf("trace: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := traceTmpl.Execute(w, trace); err != nil {
|
|
log.Printf("trace render: %v", err)
|
|
}
|
|
}
|
|
|
|
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
status, err := core.MorningStatus(ctx)
|
|
if err != nil {
|
|
log.Printf("morning: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
view := morningView{Routines: status}
|
|
// The day plan (#128) shows on this page because it is the same question at
|
|
// a different scale. A plan read that fails must not take the checklist
|
|
// down with it — the page degrades to what it had before.
|
|
plan, err := core.DayPlan(ctx)
|
|
if err != nil {
|
|
log.Printf("morning: day plan: %v", err)
|
|
view.PlanErr = err.Error()
|
|
} else {
|
|
view.Plan = &plan
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := morningTmpl.Execute(w, view); err != nil {
|
|
log.Printf("morning render: %v", err)
|
|
}
|
|
}
|
|
|
|
// morningView — what /morning renders: today's plan on top, the checklist
|
|
// state under it. PlanErr is set instead of Plan when the core could not build
|
|
// a plan, so the page says so rather than showing an empty day.
|
|
type morningView struct {
|
|
Plan *ipc.DayPlan
|
|
PlanErr string
|
|
Routines []ipc.MorningRoutineStatus
|
|
}
|
|
|
|
// eventsView — what /events renders. Err is set instead of Events when the
|
|
// core could not serve the journal, so the page says why rather than showing an
|
|
// empty intake and implying nothing arrived.
|
|
type eventsView struct {
|
|
Events []ipc.IntakeEvent
|
|
Err string
|
|
}
|
|
|
|
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
|
// page is for scanning what just happened, not for archaeology.
|
|
const eventsPageLimit = 200
|
|
|
|
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "intake journal disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
var view eventsView
|
|
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
|
if err != nil {
|
|
log.Printf("events: %v", err)
|
|
view.Err = err.Error()
|
|
} else {
|
|
view.Events = evs
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := eventsTmpl.Execute(w, view); err != nil {
|
|
log.Printf("events render: %v", err)
|
|
}
|
|
}
|
|
|
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := voiceTmpl.Execute(w, nil); err != nil {
|
|
log.Printf("voice render: %v", err)
|
|
}
|
|
}
|
|
|
|
// stepUpOK is the single decision point for the AuthStepUp gate shared by
|
|
// POST /tools and POST /api/revert.
|
|
//
|
|
// A nil session means WebAuthn is not configured (-webauthn-origin /
|
|
// -webauthn-rpid unset), so step-up can never be asserted — not merely unmet.
|
|
// The default is therefore fail-OPEN: gating on an unassertable session would
|
|
// 403 those surfaces permanently. In that mode the actions rest on the
|
|
// transport-level auth in front of mavweb (wg+nginx+auth), and main logs a
|
|
// startup warning naming them. With -require-stepup the same situation fails
|
|
// CLOSED instead: no assertable step-up ⇒ deny.
|
|
func stepUpOK(session *webauthn.PasskeySession, requireStepUp bool) bool {
|
|
if session == nil {
|
|
return !requireStepUp
|
|
}
|
|
return session.IsStepUp()
|
|
}
|
|
|
|
func handleRevert(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 core == nil {
|
|
http.Error(w, "revert disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if !stepUpOK(session, requireStepUp) {
|
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
key := strings.TrimSpace(r.FormValue("key"))
|
|
if key == "" {
|
|
http.Error(w, "key required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
newID, err := core.RevertFact(ctx, key)
|
|
if err != nil {
|
|
log.Printf("revert %q: %v", key, err)
|
|
if errors.Is(err, ipc.ErrNoFact) {
|
|
http.Error(w, "no fact to revert", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "revert failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
log.Printf("reverted fact for key=%s, new_id=%d", key, newID)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID})
|
|
}
|
|
|
|
// handleTools serves the enable surface (GET) and applies an enable (POST).
|
|
// POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is
|
|
// whitespace-split — argv with embedded spaces isn't supported (ponytail: no
|
|
// shell-word parsing; the box owner controls this input, quote a wrapper script
|
|
// if an arg needs spaces).
|
|
func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
|
if core == nil {
|
|
http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
var msg string
|
|
if r.Method == http.MethodPost {
|
|
if !stepUpOK(session, requireStepUp) {
|
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
return
|
|
}
|
|
action := r.FormValue("action")
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
switch action {
|
|
case "enable":
|
|
scope := r.FormValue("scope")
|
|
cmd := strings.Fields(r.FormValue("cmd"))
|
|
destructive := r.FormValue("destructive") != ""
|
|
if name == "" || len(cmd) == 0 {
|
|
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
|
|
log.Printf("tools: enable %q: %v", name, err)
|
|
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
msg = "enabled " + name
|
|
case "disable":
|
|
if name == "" {
|
|
http.Error(w, "name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := core.DisableTool(ctx, name); err != nil {
|
|
log.Printf("tools: disable %q: %v", name, err)
|
|
http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
msg = "disabled " + name
|
|
case "dismiss":
|
|
if name == "" {
|
|
http.Error(w, "name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := core.DeleteTool(ctx, name); err != nil {
|
|
log.Printf("tools: dismiss %q: %v", name, err)
|
|
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
msg = "dismissed " + name
|
|
default:
|
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
proposed, err1 := core.ListTools(ctx, "proposed")
|
|
enabled, err2 := core.ListTools(ctx, "enabled")
|
|
if err := cmp.Or(err1, err2); err != nil {
|
|
log.Printf("tools: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
// MCP is off by default and an older core may not know the method at all,
|
|
// so a failure here renders an empty section rather than breaking the page.
|
|
servers, err := core.MCPServers(ctx)
|
|
if err != nil {
|
|
log.Printf("tools: mcp servers: %v", err)
|
|
servers = nil
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
|
|
// flat list stops answering "what can she do to the house" somewhere
|
|
// around fifteen rows, and that is the question this page exists for.
|
|
if err := toolsTmpl.Execute(w, struct {
|
|
Msg string
|
|
Proposed []ipc.Tool
|
|
Enabled []ipc.Tool
|
|
Groups []tool.CapabilityGroup
|
|
MCP []ipc.MCPServerStatus
|
|
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil {
|
|
log.Printf("tools render: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) {
|
|
conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`))
|
|
}
|
|
|
|
func writeFrame(w io.Writer, v any) error {
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal: %w", err)
|
|
}
|
|
const maxFrame = 64 << 20
|
|
if len(body) > maxFrame {
|
|
return fmt.Errorf("frame too large: %d", len(body))
|
|
}
|
|
var hdr [4]byte
|
|
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
|
if _, err := w.Write(hdr[:]); err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(body)
|
|
return err
|
|
}
|
|
|
|
func readFrame(r io.Reader, v any) error {
|
|
var hdr [4]byte
|
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
|
return err
|
|
}
|
|
n := binary.BigEndian.Uint32(hdr[:])
|
|
const maxFrame = 64 << 20
|
|
if n > maxFrame {
|
|
return fmt.Errorf("frame too large: %d", n)
|
|
}
|
|
buf := make([]byte, n)
|
|
if _, err := io.ReadFull(r, buf); err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(buf, v)
|
|
}
|
|
|
|
func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
|
|
var raw struct {
|
|
ID uint64 `json:"id"`
|
|
Result json.RawMessage `json:"r,omitempty"`
|
|
Error *voice.RpcError `json:"e,omitempty"`
|
|
Kind voice.PushKind `json:"kind,omitempty"`
|
|
Params json.RawMessage `json:"p,omitempty"`
|
|
}
|
|
if err := readFrame(r, &raw); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if raw.Kind != "" && raw.ID == 0 {
|
|
return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil
|
|
}
|
|
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
|
|
}
|
|
|
|
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", 405)
|
|
return
|
|
}
|
|
if !stepUpOK(session, requireStepUp) {
|
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
if len(body) < 4 {
|
|
http.Error(w, "too short", 400)
|
|
return
|
|
}
|
|
|
|
log.Printf("ptt got %d bytes from client", len(body))
|
|
|
|
pcm := audio.Audio{Format: audio.PCM16kMono, Bytes: body}
|
|
|
|
var d net.Dialer
|
|
tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
|
|
if err != nil {
|
|
log.Printf("ptt dial voice: %v", err)
|
|
http.Error(w, "voice unavailable", 503)
|
|
return
|
|
}
|
|
defer tc.Close()
|
|
|
|
req := voice.Request{
|
|
ID: uint64(time.Now().UnixNano()),
|
|
Method: voice.MethodPushToTalk,
|
|
Params: mustMarshal(voice.PushToTalkReq{
|
|
Audio: pcm,
|
|
Lang: "mixed",
|
|
Surface: voice.SurfacePCClient,
|
|
}),
|
|
}
|
|
if err := writeFrame(tc, &req); err != nil {
|
|
log.Printf("ptt write: %v", err)
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
for {
|
|
resp, push, err := readOneFrame(tc)
|
|
if err != nil {
|
|
log.Printf("ptt read: %v", err)
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
if push != nil {
|
|
continue
|
|
}
|
|
if resp.Error != nil {
|
|
http.Error(w, resp.Error.Message, 500)
|
|
return
|
|
}
|
|
var pttResp voice.PushToTalkResp
|
|
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
|
|
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
|
|
// as "+", which is form encoding, and the client decodes this header
|
|
// with decodeURIComponent, which only knows "%20" — so every space in a
|
|
// spoken reply reached the on-page log as a plus sign. PathEscape is the
|
|
// flavour decodeURIComponent actually reverses, which keeps the encoding
|
|
// a property of the header rather than something the client has to know.
|
|
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
|
|
w.Write(pttResp.ReplyAudio.Bytes)
|
|
return
|
|
}
|
|
}
|
|
|
|
// --- chat page ---
|
|
|
|
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
|
// and the handler redirects back to /chat with the response.
|
|
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
|
|
|
|
// handleChatPage renders the chat conversation page.
|
|
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
|
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")})
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := chatTmpl.Execute(w, struct {
|
|
Error string
|
|
Messages []chatMsg
|
|
}{Messages: msgs}); err != nil {
|
|
log.Printf("chat render: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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 core == nil {
|
|
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if !stepUpOK(session, requireStepUp) {
|
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func mustMarshal(v any) json.RawMessage {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|