fix zombie leak, add quiet-hours toggle, improve query reply, configurable router threshold, JS dashboard
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<!doctype html><meta charset=utf-8>
|
||||
<title>maven dash</title>
|
||||
<style>
|
||||
body{font:14px monospace;background:#111;color:#ddd;margin:1rem}
|
||||
h2{color:#8cf;margin:1.2rem 0 .3rem}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
td,th{border-bottom:1px solid #333;padding:.2rem .5rem;text-align:left}
|
||||
.pending{color:#fc6}.acted{color:#6c6}.ignored{color:#888}.snoozed{color:#c9f}
|
||||
.pres{color:#6c6}.away{color:#888}
|
||||
.updated{color:#666;font-size:.85rem;margin-top:-.5rem;margin-bottom:1rem}
|
||||
</style>
|
||||
<h2>presence</h2>
|
||||
<p><span class={{.Presence.Bucket}}>{{.Presence.Bucket}}</span> — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})</p>
|
||||
<div class=updated>обновляется каждые 10с</div>
|
||||
<h2>nudges</h2>
|
||||
<table id=nudges><tr><th>when<th>rule<th>chan<th>outcome<th>message</tr>
|
||||
{{range .Nudges}}<tr><td>{{ago .Ts}}<td>{{.Rule}}<td>{{.Channel}}<td class={{.Outcome}}>{{.Outcome}}<td>{{.Message}}</tr>{{end}}
|
||||
</table>
|
||||
<h2>facts</h2>
|
||||
<table id=facts><tr><th>when<th>kind<th>key<th>value<th>source<th>conf</tr>
|
||||
{{range .Facts}}<tr><td>{{ago .Ts}}<td>{{.Kind}}<td>{{.Key}}<td>{{.Value}}<td>{{.Source}}<td>{{printf "%.2f" .Confidence}}</tr>{{end}}
|
||||
</table>
|
||||
<h2>notes</h2>
|
||||
<table id=notes><tr><th>when<th>source<th>text</tr>
|
||||
{{range .Notes}}<tr><td>{{ago .Ts}}<td>{{.Source}}<td>{{.Text}}</tr>{{end}}
|
||||
</table>
|
||||
<script>
|
||||
setInterval(() => fetch('/dash').then(r => r.text()).then(html => {
|
||||
const p = new DOMParser(), d = p.parseFromString(html, 'text/html');
|
||||
for (const id of ['nudges', 'facts', 'notes']) {
|
||||
const old = document.getElementById(id), nu = d.getElementById(id);
|
||||
if (old && nu) old.replaceWith(nu);
|
||||
}
|
||||
document.querySelector('h2+div').textContent = 'обновлено ' + new Date().toLocaleTimeString();
|
||||
}), 10000);
|
||||
</script>
|
||||
@@ -0,0 +1,496 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// dashTmpl — the monitoring read surface, server-rendered from dash.html (no JS,
|
||||
// no client fetch); meta-refresh keeps it live. 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(template.FuncMap{
|
||||
"ago": func(t time.Time) string { return time.Since(t).Round(time.Second).String() + " ago" },
|
||||
}).Parse(dashHTML))
|
||||
|
||||
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)")
|
||||
flag.Parse()
|
||||
|
||||
var core ipc.CoreAPI
|
||||
if *coreSock != "" {
|
||||
c, err := ipc.Dial(*coreSock)
|
||||
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)
|
||||
}
|
||||
mux.Handle("/", noCache(http.FileServer(http.FS(sub))))
|
||||
|
||||
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)
|
||||
})
|
||||
// /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)
|
||||
})
|
||||
|
||||
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(template.FuncMap{
|
||||
"join": strings.Join,
|
||||
}).Parse(toolsHTML))
|
||||
|
||||
const toolsHTML = `<!doctype html><meta charset=utf-8><title>maven · tools</title>
|
||||
<style>body{font:15px system-ui;margin:2rem;max-width:52rem}h2{margin-top:2rem}
|
||||
table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.4rem .6rem;text-align:left}
|
||||
input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
|
||||
.d{color:#b00}.msg{background:#efe;border:1px solid #6c6;padding:.5rem;margin:1rem 0}</style>
|
||||
<h1>maven · tools</h1>
|
||||
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
|
||||
<h2>proposed <small>({{len .Proposed}})</small></h2>
|
||||
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
||||
<table><tr><th>name</th><th>from utterance</th><th>enable as</th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Name}}</code></td><td>{{.Utterance}}</td>
|
||||
<td><form method=post action=/tools>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=text name=cmd placeholder="systemctl restart" required>
|
||||
<label><input type=checkbox name=destructive> destructive</label>
|
||||
<button>enable</button></form></td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p>none pending.</p>{{end}}
|
||||
<h2>enabled <small>({{len .Enabled}})</small></h2>
|
||||
{{if .Enabled}}<table><tr><th>name</th><th>command</th><th></th></tr>
|
||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
|
||||
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td></tr>{{end}}</table>
|
||||
{{else}}<p>none enabled.</p>{{end}}
|
||||
`
|
||||
|
||||
// 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) {
|
||||
if core == nil {
|
||||
http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
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, 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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Optional nginx config: put this in /etc/nginx/sites-available/voice.kvmx.ru
|
||||
# and symlink to sites-enabled. The phone accesses http://voice.kvmx.ru:9200/.
|
||||
# Alternatively, mavweb can bind 10.42.0.1:9200 directly (no nginx needed).
|
||||
#
|
||||
# sudo ln -sf /etc/nginx/sites-available/voice.kvmx.ru /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
server {
|
||||
listen 10.42.0.1:9200;
|
||||
listen 192.168.1.104:9200;
|
||||
server_name voice.kvmx.ru;
|
||||
|
||||
allow 10.42.0.0/24;
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9201;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
(() => {
|
||||
const btn = document.getElementById("btn");
|
||||
const status = document.getElementById("status");
|
||||
const log = document.getElementById("log");
|
||||
|
||||
let mediaRecorder = null;
|
||||
let recordingChunks = [];
|
||||
let isRecording = false;
|
||||
let recordingCancel = false;
|
||||
let isBusy = false;
|
||||
|
||||
function setBtnIdle() { btn.classList.remove("active"); btn.innerHTML = "🎙"; btn.disabled = false; }
|
||||
function setBtnActive() { btn.classList.add("active"); btn.innerHTML = "■"; }
|
||||
|
||||
function startRecording() {
|
||||
if (isRecording || isBusy) return;
|
||||
isRecording = true;
|
||||
recordingCancel = false;
|
||||
recordingChunks = [];
|
||||
status.textContent = "recording... tap to stop";
|
||||
setBtnActive();
|
||||
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
})
|
||||
.then((stream) => {
|
||||
if (recordingCancel) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
return;
|
||||
}
|
||||
const mr = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
||||
mediaRecorder = mr;
|
||||
mr.ondataavailable = (e) => { if (e.data.size > 0) recordingChunks.push(e.data); };
|
||||
mr.onstop = () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
processRecording();
|
||||
};
|
||||
mr.start(100);
|
||||
})
|
||||
.catch((err) => {
|
||||
status.textContent = "mic error: " + err.message;
|
||||
isRecording = false;
|
||||
setBtnIdle();
|
||||
});
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!isRecording) return;
|
||||
isRecording = false;
|
||||
if (!mediaRecorder) {
|
||||
recordingCancel = true;
|
||||
setBtnIdle();
|
||||
return;
|
||||
}
|
||||
status.textContent = "stopping...";
|
||||
mediaRecorder.stop();
|
||||
mediaRecorder = null;
|
||||
}
|
||||
|
||||
function processRecording() {
|
||||
status.textContent = "processing...";
|
||||
const blob = new Blob(recordingChunks, { type: "audio/webm" });
|
||||
if (blob.size < 200) { status.textContent = "too short"; setBtnIdle(); return; }
|
||||
|
||||
const ac = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
blob.arrayBuffer().then((buf) => ac.decodeAudioData(buf))
|
||||
.then((audioBuffer) => {
|
||||
const srcRate = audioBuffer.sampleRate;
|
||||
const srcChan = audioBuffer.numberOfChannels;
|
||||
const srcLen = audioBuffer.length;
|
||||
const ratio = 16000 / srcRate;
|
||||
const dstLen = Math.floor(srcLen * ratio);
|
||||
|
||||
const srcData = new Float32Array(srcLen);
|
||||
for (let i = 0; i < srcLen; i++) {
|
||||
let s = 0;
|
||||
for (let c = 0; c < srcChan; c++) s += audioBuffer.getChannelData(c)[i];
|
||||
srcData[i] = s / srcChan;
|
||||
}
|
||||
|
||||
const dstData = new Int16Array(dstLen);
|
||||
for (let i = 0; i < dstLen; i++) {
|
||||
const srcIdx = i / ratio;
|
||||
const lo = Math.floor(srcIdx);
|
||||
const hi = Math.min(lo + 1, srcLen - 1);
|
||||
const frac = srcIdx - lo;
|
||||
const sample = srcData[lo] + (srcData[hi] - srcData[lo]) * frac;
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
dstData[i] = clamped < 0 ? clamped * 32768 : clamped * 32767;
|
||||
}
|
||||
|
||||
ac.close();
|
||||
sendPCM(new Uint8Array(dstData.buffer));
|
||||
})
|
||||
.catch((err) => {
|
||||
status.textContent = "decode error: " + err.message;
|
||||
setBtnIdle();
|
||||
});
|
||||
}
|
||||
|
||||
function testFetch() {
|
||||
fetch("/api/ping").then(r => r.text()).then(t => {
|
||||
if (t === "pong") appendLog("server reachable", "");
|
||||
else appendLog("unexpected ping: " + t, "error");
|
||||
}).catch(e => appendLog("fetch failed: " + e.message, "error"));
|
||||
}
|
||||
|
||||
function sendPCM(pcm) {
|
||||
isBusy = true;
|
||||
btn.disabled = true;
|
||||
status.textContent = "sending...";
|
||||
appendLog("sending " + pcm.length + " bytes", "");
|
||||
|
||||
fetch("/api/ptt", { method: "POST", body: pcm })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(txt);
|
||||
}
|
||||
const replyText = res.headers.get("X-Reply-Text");
|
||||
if (replyText) {
|
||||
const txt = decodeURIComponent(replyText);
|
||||
appendLog(txt, "reply");
|
||||
}
|
||||
const audioData = await res.arrayBuffer();
|
||||
if (audioData.byteLength > 0) {
|
||||
playPCM(new Uint8Array(audioData));
|
||||
} else {
|
||||
status.textContent = "no reply audio";
|
||||
setBtnIdle();
|
||||
isBusy = false;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
status.textContent = "error: " + err.message;
|
||||
setBtnIdle();
|
||||
isBusy = false;
|
||||
});
|
||||
}
|
||||
|
||||
function playPCM(pcm) {
|
||||
const sampleRate = 16000;
|
||||
const bitsPerSample = 16;
|
||||
const channels = 1;
|
||||
const dataLen = pcm.length;
|
||||
const headerLen = 44;
|
||||
const wav = new Uint8Array(headerLen + dataLen);
|
||||
|
||||
const dv = (i, v) => { wav[i] = v & 255; wav[i+1] = (v>>8) & 255; wav[i+2] = (v>>16) & 255; wav[i+3] = (v>>24) & 255; };
|
||||
const sv = (i, v) => { wav[i] = v & 255; wav[i+1] = (v>>8) & 255; };
|
||||
wav[0] = 0x52; wav[1] = 0x49; wav[2] = 0x46; wav[3] = 0x46;
|
||||
dv(4, 36 + dataLen);
|
||||
wav[8] = 0x57; wav[9] = 0x41; wav[10] = 0x56; wav[11] = 0x45;
|
||||
wav[12] = 0x66; wav[13] = 0x6d; wav[14] = 0x74; wav[15] = 0x20;
|
||||
dv(16, 16);
|
||||
sv(20, 1);
|
||||
sv(22, channels);
|
||||
dv(24, sampleRate);
|
||||
dv(28, sampleRate * channels * bitsPerSample / 8);
|
||||
sv(32, channels * bitsPerSample / 8);
|
||||
sv(34, bitsPerSample);
|
||||
wav[36] = 0x64; wav[37] = 0x61; wav[38] = 0x74; wav[39] = 0x61;
|
||||
dv(40, dataLen);
|
||||
wav.set(pcm, 44);
|
||||
|
||||
const blob = new Blob([wav], { type: "audio/wav" });
|
||||
const audio = new Audio();
|
||||
audio.src = URL.createObjectURL(blob);
|
||||
status.textContent = "playing...";
|
||||
audio.onended = () => { status.textContent = "ready"; setBtnIdle(); isBusy = false; };
|
||||
audio.play().catch(() => { isBusy = false; setBtnIdle(); });
|
||||
}
|
||||
|
||||
function appendLog(msg, cls) {
|
||||
const el = document.createElement("div");
|
||||
el.className = cls;
|
||||
el.textContent = msg;
|
||||
log.appendChild(el);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
if (isBusy) return;
|
||||
if (isRecording) { stopRecording(); }
|
||||
else { startRecording(); }
|
||||
});
|
||||
|
||||
// ---- ntfy WS subscribe: proactive nudges land in-app -------------------
|
||||
// The PWA connects straight to ntfy's WebSocket (mavweb serves only the URL,
|
||||
// token included). ntfy streams one JSON object per frame; we care about
|
||||
// event:"message". Reconnects with backoff — ntfy drops idle sockets and the
|
||||
// phone sleeps. A missed nudge while disconnected is non-loss: sev>=3 also
|
||||
// hit the native ntfy push, this is the in-app mirror, not the only channel.
|
||||
function subscribeNtfy() {
|
||||
fetch("/api/ntfy").then((r) => (r.status === 204 ? "" : r.text())).then((url) => {
|
||||
if (!url) return; // not configured
|
||||
if ("Notification" in window && Notification.permission === "default") {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
connectNtfy(url, 1000);
|
||||
}).catch(() => {}); // no ntfy config endpoint → stay voice-only
|
||||
}
|
||||
|
||||
function connectNtfy(url, backoff) {
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); } catch (e) { scheduleReconnect(url, backoff); return; }
|
||||
ws.onopen = () => { backoff = 1000; appendLog("nudges connected", ""); };
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
if (m.event !== "message") return; // skip open/keepalive/poll_request
|
||||
const text = (m.title ? m.title + ": " : "") + (m.message || "");
|
||||
appendLog(text, "reply");
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification(m.title || "maven", { body: m.message || "" });
|
||||
}
|
||||
};
|
||||
ws.onclose = () => scheduleReconnect(url, backoff);
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
}
|
||||
|
||||
function scheduleReconnect(url, backoff) {
|
||||
const next = Math.min(backoff * 2, 30000); // cap at 30s
|
||||
setTimeout(() => connectNtfy(url, next), backoff);
|
||||
}
|
||||
|
||||
// ---- presence: page heartbeat -----------------------------------------
|
||||
// A surface you have open + alive is a weak presence signal (τ=4min). Ping
|
||||
// every 30s; the fact's fresh timestamp is what the scorer reads. Fire-and-
|
||||
// forget — a dropped ping just decays, non-loss. Disabled server-side (503)
|
||||
// when mavweb has no -core; we ignore the failure and stop pinging isn't
|
||||
// needed (the scorer just never sees the key).
|
||||
function heartbeat() {
|
||||
fetch("/api/signal?key=page_heartbeat", { method: "POST" }).catch(() => {});
|
||||
}
|
||||
heartbeat();
|
||||
setInterval(heartbeat, 30000);
|
||||
|
||||
status.textContent = "ready";
|
||||
testFetch();
|
||||
subscribeNtfy();
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#111">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<script defer src="/app.js"></script>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{height:100%;background:#111;color:#ddd;font-family:system-ui,-apple-system,sans-serif}
|
||||
body{display:flex;flex-direction:column}
|
||||
|
||||
nav{display:flex;background:#1a1a2e;border-bottom:1px solid #333;flex-shrink:0}
|
||||
nav button{flex:1;padding:.6rem;background:none;border:none;color:#666;font-size:.85rem;cursor:pointer;font-family:inherit;letter-spacing:.05em;text-transform:uppercase;transition:color .15s;-webkit-tap-highlight-color:transparent}
|
||||
nav button.active{color:#00aaff;border-bottom:2px solid #00aaff}
|
||||
nav button:hover{color:#ddd}
|
||||
|
||||
.tab{display:none;flex-direction:column;align-items:center;flex:1;overflow:auto;padding:1rem}
|
||||
.tab.active{display:flex}
|
||||
#tab-voice{gap:2rem}
|
||||
#tab-dash{padding:0}
|
||||
|
||||
h1{font-size:1.2rem;font-weight:400;color:#888;letter-spacing:.1em;text-transform:uppercase}
|
||||
#status{font-size:.85rem;color:#666;min-height:1.2em}
|
||||
#btn{width:140px;height:140px;border-radius:50%;border:4px solid #00aaff;background:#1a1a2e;color:#00aaff;font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
|
||||
#btn:active,#btn.active{background:#00aaff22;border-color:#00ff88;color:#00ff88;transform:scale(1.05)}
|
||||
#btn:disabled{opacity:.3;border-color:#444}
|
||||
#log{width:100%;max-width:480px;max-height:40vh;overflow-y:auto;font-size:.8rem;color:#666;line-height:1.6;padding:.5rem;border-top:1px solid #222;margin-top:1rem}
|
||||
#log .reply{color:#8f8}
|
||||
#log .error{color:#f88}
|
||||
#log .push{color:#88f}
|
||||
|
||||
#dash-frame{width:100%;flex:1;border:none;background:#111;min-height:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<button class="active" data-tab="voice">Voice</button>
|
||||
<button data-tab="dash">Dash</button>
|
||||
</nav>
|
||||
<div id="tab-voice" class="tab active">
|
||||
<h1>Maven Voice</h1>
|
||||
<div id="status">tap & hold to speak</div>
|
||||
<button id="btn" type="button">🎙</button>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
<div id="tab-dash" class="tab">
|
||||
<iframe id="dash-frame" src="/dash"></iframe>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('nav button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('nav button').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
var tab = document.getElementById('tab-' + btn.dataset.tab);
|
||||
if (tab) tab.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Maven",
|
||||
"short_name": "Maven",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111",
|
||||
"theme_color": "#00aaff",
|
||||
"icons": [],
|
||||
"description": "Voice client and dashboard for Maven"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const CACHE = "maven-v2";
|
||||
self.addEventListener("install", e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", "/manifest.json", "/dash"])));
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener("activate", e => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
|
||||
);
|
||||
clients.claim();
|
||||
});
|
||||
self.addEventListener("fetch", e => {
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(() => caches.match(e.request))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user