5fe8f228c1
Add a read-only /ecosystem page that consumes the sibling services' JSON APIs (Nexus entities, Praxis attention, Hexis capabilities), fetched concurrently with honest per-panel error states. Siblings stay headless — mavweb is their human surface (arch §16). Wired via mavweb -nexus/-praxis/-hexis flags; mavweb joins the ecosystem compose network. Fix mobile horizontal overflow across all pages: .content is a flex child with default min-width:auto, so it refused to shrink below the tables' intrinsic width. min-width:0 lets wide tables pan inside .scroll instead of dragging the page sideways. Verified via CDP geometry check (scrollWidth === clientWidth at 430px). Also includes in-progress Ethos UI redesign, ecosystem deploy compose, and planning docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1162 lines
38 KiB
Go
1162 lines
38 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"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/kami/maven/internal/audio"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"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 voice.html
|
|
var voiceHTML string
|
|
|
|
//go:embed ecosystem.html
|
|
var ecosystemHTML 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: "Reminders", URL: "/reminders", Key: "reminders"},
|
|
{Label: "Routines", URL: "/routines", Key: "routines"},
|
|
},
|
|
},
|
|
{
|
|
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: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
|
},
|
|
},
|
|
}
|
|
|
|
func sidebarActive(url, key string, activeKey string) string {
|
|
if key == activeKey {
|
|
return `class="active"`
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sidebarHTML renders the sidebar navigation given the active page key.
|
|
func sidebarHTML(active string) template.HTML {
|
|
var b strings.Builder
|
|
for _, sec := range sidebarSections {
|
|
b.WriteString(`<div class=sidebar-section>`)
|
|
b.WriteString(`<div class=sidebar-label>`)
|
|
b.WriteString(sec.Label)
|
|
b.WriteString(`</div>`)
|
|
for _, p := range sec.Pages {
|
|
cls := ""
|
|
if p.Key == active {
|
|
cls = ` class="active"`
|
|
}
|
|
b.WriteString(`<a href="`)
|
|
b.WriteString(p.URL)
|
|
b.WriteString(`"`)
|
|
b.WriteString(cls)
|
|
b.WriteString(`><span class=icon>`)
|
|
b.WriteString(pageIcon(p.Key))
|
|
b.WriteString(`</span><span>`)
|
|
b.WriteString(p.Label)
|
|
b.WriteString(`</span></a>`)
|
|
}
|
|
b.WriteString(`</div>`)
|
|
}
|
|
return template.HTML(b.String())
|
|
}
|
|
|
|
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
|
|
func pageIcon(key string) string {
|
|
switch key {
|
|
case "dash":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
|
case "history":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
|
|
case "trace":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
|
case "notifications":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
|
case "reminders":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
|
case "routines":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
|
case "chat":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
|
case "voice":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
|
|
case "ecosystem":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
|
case "tools":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
|
case "passkey":
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
|
default:
|
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
|
|
}
|
|
}
|
|
|
|
// 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 "reminders":
|
|
return "Reminders"
|
|
case "routines":
|
|
return "Routines"
|
|
case "chat":
|
|
return "Chat"
|
|
case "voice":
|
|
return "Voice"
|
|
case "ecosystem":
|
|
return "Ecosystem"
|
|
case "tools":
|
|
return "Tools"
|
|
case "passkey":
|
|
return "Passkey"
|
|
default:
|
|
return key
|
|
}
|
|
}
|
|
|
|
// shellTopHTML opens the shell and renders the top bar + sidebar.
|
|
// Usage: {{template "shellTop" "<page-key>"}}
|
|
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
|
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
<meta name=theme-color content="#14110D">
|
|
<link rel=manifest href=/manifest.json>
|
|
<title>maven · {{pageTitle .}}</title>
|
|
<link rel=stylesheet href=/ui.css>
|
|
<div class=shell data-app=maven>
|
|
<header class=topbar>
|
|
<div class=breadcrumbs>
|
|
<span class=current>{{pageTitle .}}</span>
|
|
</div>
|
|
<div class=topbar-actions>
|
|
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
|
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
|
Search
|
|
<span class=kbd-hint>Ctrl+/</span>
|
|
</div>
|
|
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
|
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
|
</button>
|
|
<span class=conn-status>
|
|
<span class="dot online" id=connDot></span>
|
|
</span>
|
|
</div>
|
|
</header>
|
|
<div class=shell-body>
|
|
<aside class=sidebar>
|
|
{{sidebarHTML .}}
|
|
</aside>
|
|
<main class=content>
|
|
{{end}}`
|
|
|
|
// shellBottomHTML closes the content area, inspector, and shell.
|
|
// Usage: {{template "shellBottom"}}
|
|
const shellBottomHTML = `{{define "shellBottom"}}
|
|
</main>
|
|
<aside class=inspector id=inspector>
|
|
<div class=inspector-inner>
|
|
<div class=inspector-header>
|
|
<span id=inspectorTitle>Details</span>
|
|
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">×</button>
|
|
</div>
|
|
<div class=inspector-body id=inspectorBody></div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
<script src=/mavweb.js></script>
|
|
{{end}}`
|
|
|
|
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
|
func shellFuncs() template.FuncMap {
|
|
return template.FuncMap{
|
|
"pageTitle": pageTitle,
|
|
"sidebarHTML": sidebarHTML,
|
|
"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(shellTopHTML + dashHTML + shellBottomHTML))
|
|
|
|
// 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(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
|
|
|
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", ":9200", "HTTP listen address")
|
|
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)")
|
|
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)")
|
|
flag.Parse()
|
|
|
|
var core ipc.CoreAPI
|
|
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
|
|
}
|
|
|
|
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)
|
|
}))
|
|
|
|
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
handleWS(w, r, *voiceAddr)
|
|
})
|
|
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
|
|
handlePTT(w, r, *voiceAddr)
|
|
})
|
|
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)
|
|
})
|
|
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)
|
|
})
|
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
|
handleRoutines(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)
|
|
})
|
|
// ----- 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)
|
|
}
|
|
|
|
// /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 (maven.md), 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)
|
|
})
|
|
|
|
// /api/revert voids the latest fact for a key — a store mutation, so it
|
|
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
|
// WebAuthn unconfigured ⇒ transport-level auth only, same as /tools).
|
|
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)
|
|
})
|
|
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
|
handleRevert(w, r, core, stepUpSession)
|
|
})
|
|
|
|
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) {
|
|
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
|
|
return m
|
|
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
|
|
|
|
const toolsHTML = `{{template "shellTop" "tools"}}
|
|
<h1>Tools</h1>
|
|
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
|
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
<section class=card>
|
|
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
|
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
|
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
|
{{range .Proposed}}<tr>
|
|
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
|
<td><form method=post action=/tools>
|
|
<input type=hidden name=name value="{{.Name}}">
|
|
<input type=hidden name=scope value="{{.Scope}}">
|
|
<input type=hidden name=action value=enable>
|
|
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
|
|
<label><input type=checkbox name=destructive> destructive</label>
|
|
<button class=btn>enable</button></form>
|
|
<form method=post action=/tools class=inline-form>
|
|
<input type=hidden name=name value="{{.Name}}">
|
|
<input type=hidden name=action value=dismiss>
|
|
<button class="btn btn-muted">dismiss</button></form></td>
|
|
</tr>{{end}}</table></div>
|
|
{{else}}<div class=empty>
|
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
|
|
<div>no proposed tools</div>
|
|
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
|
</div>{{end}}
|
|
</section>
|
|
<section class=card>
|
|
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
|
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
|
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
|
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
|
<td><form method=post action=/tools class=inline-form>
|
|
<input type=hidden name=name value="{{.Name}}">
|
|
<input type=hidden name=scope value="{{.Scope}}">
|
|
<input type=hidden name=action value=disable>
|
|
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
|
{{else}}<div class=empty>
|
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
|
<div>no tools enabled</div>
|
|
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
|
</div>{{end}}
|
|
</section>
|
|
{{template "shellBottom"}}`
|
|
|
|
// routinesHTML — proposed routine review surface. Lists detected patterns
|
|
// awaiting human confirmation, with accept (→ reminder) and dismiss buttons.
|
|
const routinesHTML = `{{template "shellTop" "routines"}}
|
|
<h1>Routines</h1>
|
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
<section class=card>
|
|
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
|
{{if .Proposed}}<div class=scroll><table><tr><th>action</th><th>object</th><th>every</th><th></th></tr>
|
|
{{range .Proposed}}<tr>
|
|
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</td>
|
|
<td>
|
|
<form method=post action=/routines class=inline-form>
|
|
<input type=hidden name=id value="{{.ID}}">
|
|
<input type=hidden name=action value=dismiss>
|
|
<button class="btn btn-muted">dismiss</button></form>
|
|
</td>
|
|
</tr>{{end}}</table></div>
|
|
{{else}}<div class=empty>
|
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
|
<div>no proposed routines</div>
|
|
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
|
</div>{{end}}
|
|
</section>
|
|
{{template "shellBottom"}}`
|
|
|
|
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
|
|
|
|
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
|
|
|
|
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
|
|
|
|
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
|
|
|
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
|
|
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
|
|
|
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(shellTopHTML + traceHTML + shellBottomHTML))
|
|
|
|
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
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil {
|
|
log.Printf("notifications template: %v", err)
|
|
}
|
|
}
|
|
|
|
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": reminders}); err != nil {
|
|
log.Printf("reminders template: %v", err)
|
|
}
|
|
}
|
|
|
|
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
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 "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 []ipc.ProposedRoutine
|
|
}{msg, proposed}); err != nil {
|
|
log.Printf("routines render: %v", err)
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession) {
|
|
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
|
|
}
|
|
// nil session ⇒ WebAuthn not configured; step-up gate not applicable
|
|
// (asserting would be impossible, not just unmet) — matches handleTools.
|
|
if session != nil && !session.IsStepUp() {
|
|
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) {
|
|
if core == nil {
|
|
http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
var msg string
|
|
if r.Method == http.MethodPost {
|
|
// nil session ⇒ WebAuthn not configured; step-up gate not applicable
|
|
// (see wiring in main — asserting would be impossible, not just unmet).
|
|
if session != nil && !session.IsStepUp() {
|
|
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
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := toolsTmpl.Execute(w, struct {
|
|
Msg string
|
|
Proposed []ipc.Tool
|
|
Enabled []ipc.Tool
|
|
}{msg, proposed, enabled}); 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) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", 405)
|
|
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")
|
|
w.Header().Set("X-Reply-Text", url.QueryEscape(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(shellTopHTML + chatPageHTML + shellBottomHTML))
|
|
|
|
const chatPageHTML = `{{template "shellTop" "chat"}}
|
|
<h1>Chat</h1>
|
|
<section class=card>
|
|
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
|
<div class="scroll chat-scroll" id=chatHistory>
|
|
{{range .Messages}}
|
|
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
|
{{else}}
|
|
<div class=empty>
|
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
|
<div>start a conversation</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
<form method=post action=/api/chat class=chat-form>
|
|
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
|
<button class=btn>send</button>
|
|
</form>
|
|
</section>
|
|
<script>
|
|
var ch = document.getElementById('chatHistory');
|
|
if(ch) ch.scrollTop = ch.scrollHeight;
|
|
</script>
|
|
{{template "shellBottom"}}`
|
|
|
|
// 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.
|
|
func handleChatAPI(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, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
text := strings.TrimSpace(r.FormValue("text"))
|
|
if text == "" {
|
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
|
return
|
|
}
|
|
reply, err := core.Chat(r.Context(), 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
|
|
}
|