00595c2211
The transcript beside the spoken reply read "на+04.08.2026+ничего+нет." X-Reply-Text was written with url.QueryEscape, which is form encoding and writes a space as "+", and static/app.js reads it with decodeURIComponent, which only knows "%20". Every space in a spoken reply arrived as a plus. Fixed on the Go side rather than by replacing plus with space in the client: the encoding is a property of the header, and a client that has to know which flavour it got is a client that will get it wrong again. Escaping in the client's own dialect also keeps a plus the speaker actually said — "2+2" — from becoming a space. PathEscape writes %0A for a newline too, so a two-line reply stays a legal header value instead of a truncated one. The test round-trips through a stand-in for decodeURIComponent rather than checking the encoder alone, because QueryEscape passes any assertion that only looks at what went in. Cosmetic and log-only. The audio was never affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
1638 lines
55 KiB
Go
1638 lines
55 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")
|
|
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.
|
|
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 r := r.URL.Query().Get("r"); r != "" {
|
|
msgs = append(msgs, chatMsg{Role: "assistant", Text: r})
|
|
}
|
|
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
|
|
}
|
|
http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther)
|
|
}
|
|
|
|
// chatMsg — one message in the conversation history.
|
|
type chatMsg struct {
|
|
Role string // "user" | "assistant"
|
|
Text string
|
|
}
|
|
|
|
func mustMarshal(v any) json.RawMessage {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|