diff --git a/cmd/mavweb/dash.html b/cmd/mavweb/dash.html index 189d214..f333807 100644 --- a/cmd/mavweb/dash.html +++ b/cmd/mavweb/dash.html @@ -1,33 +1,42 @@ - - -maven · dash - -{{template "nav" "dash"}} -
+{{template "shellTop" "dash"}} +

Dashboard

-

presence

-

{{.Presence.Bucket}} — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})

-
обновляется каждые 10с
+

+ + presence +

+

+ {{.Presence.Bucket}} + — score {{printf "%.2f" .Presence.Score}} + ({{ago .Presence.Updated}}) +

+
updates every 10s
-

nudges

+
+

nudges

+
{{range .Nudges}}{{end}}
whenrulechanoutcomemessage
{{ago .Ts}}{{.Rule}}{{.Channel}}{{.Outcome}}{{.Message}}
-

facts

+
+

facts

+
{{range .Facts}}{{end}}
whenkindkeyvaluesourceconf
{{ago .Ts}}{{.Kind}}{{.Key}}{{.Value}}{{.Source}}{{printf "%.2f" .Confidence}}
-

notes

+
+

notes

+
{{range .Notes}}{{end}}
whensourcetext
{{ago .Ts}}{{.Source}}{{.Text}}
-
+{{template "shellBottom"}} + diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 30458c9..a739b09 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -74,6 +74,10 @@ func (f *fakeCore) DisableTool(_ context.Context, name string) error { return f.disableErr } +func (f *fakeCore) DeleteTool(_ context.Context, name string) error { + return nil +} + func (f *fakeCore) ListTools(_ context.Context, status string) ([]ipc.Tool, error) { if f.listErr != nil { return nil, f.listErr diff --git a/cmd/mavweb/history.html b/cmd/mavweb/history.html index 468edf8..32542bd 100644 --- a/cmd/mavweb/history.html +++ b/cmd/mavweb/history.html @@ -1,25 +1,28 @@ - - -maven · history - -{{template "nav" "history"}} -
-

command history

-

{{len .Facts}} facts shown (newest first)

+{{template "shellTop" "history"}} +

Command History

+{{if .Facts}} +

{{len .Facts}} facts shown (newest first)

-{{range .Facts}}{{if .VoidsID}}{{else}}{{end}} +{{range .Facts}} - + - +{{end}}
whenkindkeyvaluesourceconf
{{if .VoidsID}}⨯ voided{{end}}{{.Ts.Format "2006-01-02 15:04"}} {{.Kind}} {{.Key}} {{.Value}}{{.Source}}{{.Source}} {{printf "%.2f" .Confidence}}{{if not .VoidsID}}{{end}}{{if not .VoidsID}}{{end}}
-
+{{else}} +
+ +
no facts recorded yet
+
facts appear here when maven observes or records events
+
+{{end}} +{{template "shellBottom"}} + diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 19b2f1c..b1a057c 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -57,6 +57,9 @@ var notificationsHTML string //go:embed reminders.html var remindersHTML string +//go:embed voice.html +var voiceHTML string + // ── Ethos Workstation Shell ── // // Two template pieces that wrap every page: @@ -88,11 +91,13 @@ var sidebarSections = []struct { {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: "AI", Pages: []struct{ Label, URL, Key string }{ + {Label: "Chat", URL: "/chat", Key: "chat"}, {Label: "Voice", URL: "/", Key: "voice"}, }, }, @@ -153,6 +158,10 @@ func pageIcon(key string) string { return `` case "reminders": return `` + case "routines": + return `` + case "chat": + return `` case "voice": return `` case "tools": @@ -177,6 +186,10 @@ func pageTitle(key string) string { return "Notifications" case "reminders": return "Reminders" + case "routines": + return "Routines" + case "chat": + return "Chat" case "voice": return "Voice" case "tools": @@ -191,7 +204,9 @@ func pageTitle(key string) string { // shellTopHTML opens the shell and renders the top bar + sidebar. // Usage: {{template "shellTop" ""}} const shellTopHTML = `{{define "shellTop"}} - + + + maven · {{pageTitle .}}
@@ -301,7 +316,14 @@ func main() { if err != nil { log.Fatalf("static fs: %v", err) } - mux.Handle("/", noCache(http.FileServer(http.FS(sub)))) + 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) @@ -338,6 +360,9 @@ func main() { 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) + }) // ----- 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). @@ -379,6 +404,12 @@ func main() { // /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) }) @@ -569,7 +600,11 @@ const toolsHTML = `{{template "shellTop" "tools"}} - + +
+ + +
{{end}}
{{else}}
@@ -595,6 +630,31 @@ const toolsHTML = `{{template "shellTop" "tools"}} {{template "shellBottom"}}` +// routinesHTML — proposed routine review surface. Lists detected patterns +// awaiting human confirmation, with accept (→ reminder) and dismiss buttons. +const routinesHTML = `{{template "shellTop" "routines"}} +

Routines

+{{if .Msg}}
{{.Msg}}
{{end}} +
+

proposed {{len .Proposed}}

+{{if .Proposed}}
+{{range .Proposed}} + + +{{end}}
actionobjectevery
{{.Action}}{{.Object}}{{.IntervalDays}} days +
+ + +
+
+{{else}}
+ +
no proposed routines
+
maven will propose routines here when she detects a recurring pattern
+
{{end}} +
+{{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)) @@ -603,6 +663,10 @@ var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()). 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 { @@ -671,6 +735,49 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } +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) @@ -689,6 +796,13 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } +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) @@ -772,6 +886,17 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi 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 @@ -919,6 +1044,97 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string) { } } +// --- 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"}} +

Chat

+
+{{if .Error}}
{{.Error}}
{{end}} +
+{{range .Messages}} +
{{if eq .Role "user"}}you{{else}}maven{{end}}: {{.Text}}
+{{else}} +
+ +
start a conversation
+
+{{end}} +
+
+ + +
+
+ + +{{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 { diff --git a/cmd/mavweb/notifications.html b/cmd/mavweb/notifications.html index 8024430..62bd7ba 100644 --- a/cmd/mavweb/notifications.html +++ b/cmd/mavweb/notifications.html @@ -1,18 +1,18 @@ - - -maven · notifications - -{{template "nav" "notifications"}} -
-

notifications

+{{template "shellTop" "notifications"}} +

Notifications

{{if .Nudges}}
{{range .Nudges}} - +{{end}}
whenrulechanneloutcomemessage
{{.Ts.Format "15:04"}}{{.Ts.Format "15:04"}} {{.Rule}} {{.Channel}} {{.Outcome}} {{.Message}}
-{{else}}
no notifications yet
{{end}} -
+{{else}}
+ +
no notifications yet
+
check back later or ask maven a question
+
{{end}} +{{template "shellBottom"}} + diff --git a/cmd/mavweb/static/icon.svg b/cmd/mavweb/static/icon.svg index 46f49fb..f11058d 100644 --- a/cmd/mavweb/static/icon.svg +++ b/cmd/mavweb/static/icon.svg @@ -1,10 +1,13 @@ - - + + - M + M + + + diff --git a/cmd/mavweb/static/index.html b/cmd/mavweb/static/index.html index 3fae841..07e4772 100644 --- a/cmd/mavweb/static/index.html +++ b/cmd/mavweb/static/index.html @@ -3,89 +3,135 @@ - + + - -
-

Maven Voice

-
tap & hold to speak
- -
-
- Cheatsheet -
-
Act
-
включи свет · сделай громче
-
restart nginx · turn on the light
-
Reminder
-
напомни через 4 часа размяться
-
remind me tomorrow at 8am to call the doctor
-
Fact
-
выпил кофе · вес 73 кг
-
ran 3 kilometers
-
Note
-
запиши рецепт блинов
-
note: add a backup job for the db
-
Query
-
какой сегодня день · какой прогноз погоды
-
what version are you · what's the weather
-
System
-
включи тихий режим · как загрузка системы
-
quiet mode on · system load
-
-
-
- RU - EN +
+
+ +
+
+ + Search + Ctrl+/ +
+ + + + +
+
+
+ +
+
+
tap & hold to speak
+ +
+
+ Cheatsheet +
+
Act
+
включи свет · сделай громче
+
restart nginx · turn on the light
+
Reminder
+
напомни через 4 часа размяться
+
remind me tomorrow at 8am to call the doctor
+
Fact
+
выпил кофе · вес 73 кг
+
ran 3 kilometers
+
Note
+
запиши рецепт блинов
+
note: add a backup job for the db
+
Query
+
какой сегодня день · какой прогноз погоды
+
what version are you · what's the weather
+
System
+
включи тихий режим · как загрузка системы
+
quiet mode on · system load
+
+
+
+ RU + EN +
+
+
+
+ +{{template "shellBottom"}} + diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index 8054c55..b3493fc 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -79,20 +79,17 @@ func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) { passkeyTmpl.Execute(w, nil) } -// passkeyPageHTML — rendered via passkeyTmpl (main.go) which prepends navHTML. -const passkeyPageHTML = ` - -maven · passkey - -{{template "nav" "passkey"}} -
-

passkey

-

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

+// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom. +const passkeyPageHTML = `{{template "shellTop" "passkey"}} +

Passkey

+

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

+
+
-
+{{template "shellBottom"}}