feat: add SSE real-time updates for MiniApp dashboard
Push plan, session, and skills state changes to the MiniApp via Server-Sent Events instead of requiring manual refresh. A lightweight StateNotifier fans out notifications from AgentLoop's three mutation points (post-LLM state machine, /plan commands, plan creation) to all connected SSE clients, with JSON diff dedup to avoid redundant writes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3d573cb2be
commit
2eaf6984a6
6 changed files with 431 additions and 113 deletions
|
|
@ -222,7 +222,9 @@ func gatewayCmd() {
|
||||||
if webAppURL != "" {
|
if webAppURL != "" {
|
||||||
provider := &agentLoopDataProvider{loop: agentLoop}
|
provider := &agentLoopDataProvider{loop: agentLoop}
|
||||||
sender := &telegramCommandSender{bus: msgBus}
|
sender := &telegramCommandSender{bus: msgBus}
|
||||||
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token)
|
notifier := miniapp.NewStateNotifier()
|
||||||
|
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, notifier)
|
||||||
|
agentLoop.OnStateChange = notifier.Notify
|
||||||
handler.RegisterRoutes(healthServer.Mux())
|
handler.RegisterRoutes(healthServer.Mux())
|
||||||
fmt.Printf("✓ Mini App registered at %s\n", webAppURL)
|
fmt.Printf("✓ Mini App registered at %s\n", webAppURL)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ type AgentLoop struct {
|
||||||
sessionLocks sync.Map // sessionKey → *sessionSemaphore
|
sessionLocks sync.Map // sessionKey → *sessionSemaphore
|
||||||
activeTasks sync.Map // sessionKey → *activeTask
|
activeTasks sync.Map // sessionKey → *activeTask
|
||||||
sessions *SessionTracker
|
sessions *SessionTracker
|
||||||
|
OnStateChange func() // called on plan/session/skills mutations
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -142,6 +143,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) notifyStateChange() {
|
||||||
|
if al.OnStateChange != nil {
|
||||||
|
al.OnStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
||||||
func registerSharedTools(
|
func registerSharedTools(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|
@ -866,6 +873,8 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
al.notifyStateChange()
|
||||||
|
|
||||||
// 5b. Interview staleness detection: compare MEMORY.md size after iteration.
|
// 5b. Interview staleness detection: compare MEMORY.md size after iteration.
|
||||||
if agent.ContextBuilder.GetPlanStatus() == "interviewing" {
|
if agent.ContextBuilder.GetPlanStatus() == "interviewing" {
|
||||||
postMemoryLen := len(agent.ContextBuilder.ReadMemory())
|
postMemoryLen := len(agent.ContextBuilder.ReadMemory())
|
||||||
|
|
@ -2327,6 +2336,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
case "/plan":
|
case "/plan":
|
||||||
resp, handled := al.handlePlanCommand(args)
|
resp, handled := al.handlePlanCommand(args)
|
||||||
|
if handled {
|
||||||
|
al.notifyStateChange()
|
||||||
|
}
|
||||||
return resp, handled
|
return resp, handled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2551,9 +2563,9 @@ func isPlanPreExecution(status string) bool {
|
||||||
func isToolAllowedDuringInterview(toolName string, args map[string]interface{}) bool {
|
func isToolAllowedDuringInterview(toolName string, args map[string]interface{}) bool {
|
||||||
norm := tools.NormalizeToolName(toolName)
|
norm := tools.NormalizeToolName(toolName)
|
||||||
|
|
||||||
// Read-type tools: always allowed
|
// Read-type tools and communication: always allowed
|
||||||
switch norm {
|
switch norm {
|
||||||
case "readfile", "listdir", "websearch", "webfetch":
|
case "readfile", "listdir", "websearch", "webfetch", "message":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2609,6 +2621,7 @@ func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string,
|
||||||
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
|
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
al.notifyStateChange()
|
||||||
|
|
||||||
// Expanded: the task description goes to LLM.
|
// Expanded: the task description goes to LLM.
|
||||||
// The system prompt already contains the interview guide.
|
// The system prompt already contains the interview guide.
|
||||||
|
|
|
||||||
|
|
@ -1391,6 +1391,9 @@ func TestIsToolAllowedDuringInterview_FuzzyNames(t *testing.T) {
|
||||||
{"listdir", nil, true},
|
{"listdir", nil, true},
|
||||||
{"websearch", nil, true},
|
{"websearch", nil, true},
|
||||||
{"webfetch", nil, true},
|
{"webfetch", nil, true},
|
||||||
|
// Message tool — allowed (needed for interview questions)
|
||||||
|
{"message", nil, true},
|
||||||
|
{"Message", nil, true},
|
||||||
// Write to MEMORY.md — allowed
|
// Write to MEMORY.md — allowed
|
||||||
{"edit_file", map[string]interface{}{"path": "/ws/memory/MEMORY.md"}, true},
|
{"edit_file", map[string]interface{}{"path": "/ws/memory/MEMORY.md"}, true},
|
||||||
{"editfile", map[string]interface{}{"path": "/ws/memory/MEMORY.md"}, true},
|
{"editfile", map[string]interface{}{"path": "/ws/memory/MEMORY.md"}, true},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package miniapp
|
package miniapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
|
@ -12,6 +13,8 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/stats"
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
|
|
@ -67,19 +70,60 @@ type CommandSender interface {
|
||||||
SendCommand(senderID, chatID, command string)
|
SendCommand(senderID, chatID, command string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateNotifier broadcasts state-change signals to SSE subscribers.
|
||||||
|
type StateNotifier struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
subs map[chan struct{}]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStateNotifier creates a new StateNotifier.
|
||||||
|
func NewStateNotifier() *StateNotifier {
|
||||||
|
return &StateNotifier{subs: make(map[chan struct{}]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a channel that receives a signal on each state change.
|
||||||
|
func (n *StateNotifier) Subscribe() chan struct{} {
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
n.mu.Lock()
|
||||||
|
n.subs[ch] = struct{}{}
|
||||||
|
n.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a subscriber channel.
|
||||||
|
func (n *StateNotifier) Unsubscribe(ch chan struct{}) {
|
||||||
|
n.mu.Lock()
|
||||||
|
delete(n.subs, ch)
|
||||||
|
n.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify sends a signal to all subscribers, coalescing rapid notifications.
|
||||||
|
func (n *StateNotifier) Notify() {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
for ch := range n.subs {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handler serves the Mini App HTML and API endpoints.
|
// Handler serves the Mini App HTML and API endpoints.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
provider DataProvider
|
provider DataProvider
|
||||||
sender CommandSender
|
sender CommandSender
|
||||||
botToken string
|
botToken string
|
||||||
|
notifier *StateNotifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new Mini App handler.
|
// NewHandler creates a new Mini App handler.
|
||||||
func NewHandler(provider DataProvider, sender CommandSender, botToken string) *Handler {
|
func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
provider: provider,
|
provider: provider,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
botToken: botToken,
|
botToken: botToken,
|
||||||
|
notifier: notifier,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,6 +135,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
||||||
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
||||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
||||||
|
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -204,6 +249,55 @@ func extractUserFromInitData(initData string) (userID, chatID string) {
|
||||||
return id, id
|
return id, id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"streaming not supported"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
_ = rc.SetWriteDeadline(time.Time{})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
ch := h.notifier.Subscribe()
|
||||||
|
defer h.notifier.Unsubscribe(ch)
|
||||||
|
|
||||||
|
var lastPlan, lastSession, lastSkills []byte
|
||||||
|
|
||||||
|
// Send initial state immediately
|
||||||
|
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||||
|
sendSSEIfChanged(w, flusher, "session",
|
||||||
|
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
||||||
|
&lastSession)
|
||||||
|
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-ch:
|
||||||
|
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||||
|
sendSSEIfChanged(w, flusher, "session",
|
||||||
|
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
||||||
|
&lastSession)
|
||||||
|
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) {
|
||||||
|
data, _ := json.Marshal(v)
|
||||||
|
if !bytes.Equal(data, *last) {
|
||||||
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data)
|
||||||
|
f.Flush()
|
||||||
|
*last = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
package miniapp
|
package miniapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildInitData constructs a valid initData string from params and a bot token.
|
// buildInitData constructs a valid initData string from params and a bot token.
|
||||||
|
|
@ -97,3 +104,163 @@ func TestValidateInitData(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── StateNotifier tests ──
|
||||||
|
|
||||||
|
func TestStateNotifier_FanOut(t *testing.T) {
|
||||||
|
n := NewStateNotifier()
|
||||||
|
ch1 := n.Subscribe()
|
||||||
|
ch2 := n.Subscribe()
|
||||||
|
defer n.Unsubscribe(ch1)
|
||||||
|
defer n.Unsubscribe(ch2)
|
||||||
|
|
||||||
|
n.Notify()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ch1:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Error("ch1 did not receive notification")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ch2:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Error("ch2 did not receive notification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateNotifier_Coalesce(t *testing.T) {
|
||||||
|
n := NewStateNotifier()
|
||||||
|
ch := n.Subscribe()
|
||||||
|
defer n.Unsubscribe(ch)
|
||||||
|
|
||||||
|
// Multiple rapid notifications should coalesce into one
|
||||||
|
n.Notify()
|
||||||
|
n.Notify()
|
||||||
|
n.Notify()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Error("ch did not receive notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel should be empty now (coalesced)
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
t.Error("expected no second notification (should coalesce)")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE endpoint tests ──
|
||||||
|
|
||||||
|
type mockDataProvider struct{}
|
||||||
|
|
||||||
|
func (m *mockDataProvider) ListSkills() []skills.SkillInfo {
|
||||||
|
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
||||||
|
}
|
||||||
|
func (m *mockDataProvider) GetPlanInfo() PlanInfo {
|
||||||
|
return PlanInfo{HasPlan: false, Status: "none"}
|
||||||
|
}
|
||||||
|
func (m *mockDataProvider) GetSessionStats() *stats.Stats {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
|
return []SessionInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockSender struct{}
|
||||||
|
|
||||||
|
func (m *mockSender) SendCommand(senderID, chatID, command string) {}
|
||||||
|
|
||||||
|
const testBotToken = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
|
|
||||||
|
func testInitData() string {
|
||||||
|
return buildInitData(map[string]string{
|
||||||
|
"user": `{"id":279058397,"first_name":"Test"}`,
|
||||||
|
"auth_date": "1234567890",
|
||||||
|
}, testBotToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_AuthRequired(t *testing.T) {
|
||||||
|
notifier := NewStateNotifier()
|
||||||
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/miniapp/api/events", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_Headers(t *testing.T) {
|
||||||
|
notifier := NewStateNotifier()
|
||||||
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/miniapp/api/events?initData=" + url.QueryEscape(testInitData()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
||||||
|
t.Errorf("expected Content-Type text/event-stream, got %q", ct)
|
||||||
|
}
|
||||||
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||||
|
t.Errorf("expected Cache-Control no-cache, got %q", cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_InitialEvents(t *testing.T) {
|
||||||
|
notifier := NewStateNotifier()
|
||||||
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/miniapp/api/events?initData=" + url.QueryEscape(testInitData()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
events := make(map[string]bool)
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
|
||||||
|
for len(events) < 3 {
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
done <- scanner.Scan()
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case ok := <-done:
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("scanner ended early: %v", scanner.Err())
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out waiting for events, got: %v", events)
|
||||||
|
}
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "event: ") {
|
||||||
|
events[strings.TrimPrefix(line, "event: ")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"plan", "session", "skills"} {
|
||||||
|
if !events[name] {
|
||||||
|
t.Errorf("missing initial event %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -581,15 +581,9 @@ async function apiFetch(path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plan tab ──
|
// ── Plan tab ──
|
||||||
async function loadPlan() {
|
function renderPlanFromData(data) {
|
||||||
const loading = document.getElementById('plan-loading');
|
var loading = document.getElementById('plan-loading');
|
||||||
const el = document.getElementById('plan-content');
|
var el = document.getElementById('plan-content');
|
||||||
loading.style.display = 'block';
|
|
||||||
loading.textContent = 'Loading plan...';
|
|
||||||
el.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await apiFetch('/miniapp/api/plan');
|
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
|
|
||||||
|
|
@ -606,24 +600,31 @@ async function loadPlan() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
var html = '';
|
||||||
|
|
||||||
// Status header card
|
|
||||||
html += '<div class="card">' +
|
html += '<div class="card">' +
|
||||||
'<div class="card-title">Status</div>' +
|
'<div class="card-title">Status</div>' +
|
||||||
'<div class="card-value">' + escapeHtml(data.status) + '</div>' +
|
'<div class="card-value">' + escapeHtml(data.status) + '</div>' +
|
||||||
'<div style="color:var(--hint);margin-top:4px">Phase ' + data.current_phase + ' / ' + data.total_phases + '</div>' +
|
'<div style="color:var(--hint);margin-top:4px">Phase ' + data.current_phase + ' / ' + data.total_phases + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// Phase/step list
|
|
||||||
if (data.phases && data.phases.length > 0) {
|
if (data.phases && data.phases.length > 0) {
|
||||||
html += renderPhases(data.phases, data.current_phase);
|
html += renderPhases(data.phases, data.current_phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh button (in-app, not sendData)
|
|
||||||
html += '<button class="refresh-btn" onclick="loadPlan()">Refresh</button>';
|
html += '<button class="refresh-btn" onclick="loadPlan()">Refresh</button>';
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlan() {
|
||||||
|
var loading = document.getElementById('plan-loading');
|
||||||
|
var el = document.getElementById('plan-content');
|
||||||
|
loading.style.display = 'block';
|
||||||
|
loading.textContent = 'Loading plan...';
|
||||||
|
el.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = await apiFetch('/miniapp/api/plan');
|
||||||
|
renderPlanFromData(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading.textContent = 'Failed to load plan.';
|
loading.textContent = 'Failed to load plan.';
|
||||||
loading.style.display = 'block';
|
loading.style.display = 'block';
|
||||||
|
|
@ -687,15 +688,9 @@ document.getElementById('plan-content').addEventListener('click', function(e) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Skills tab ──
|
// ── Skills tab ──
|
||||||
async function loadSkills() {
|
function renderSkillsFromData(data) {
|
||||||
const loading = document.getElementById('skills-loading');
|
var loading = document.getElementById('skills-loading');
|
||||||
const el = document.getElementById('skills-list');
|
var el = document.getElementById('skills-list');
|
||||||
loading.style.display = 'block';
|
|
||||||
loading.textContent = 'Loading skills...';
|
|
||||||
el.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await apiFetch('/miniapp/api/skills');
|
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
|
|
||||||
|
|
@ -704,34 +699,44 @@ async function loadSkills() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = data.map(s =>
|
el.innerHTML = data.map(function(s) {
|
||||||
'<div class="skill-item" data-skill="' + escapeAttr(s.name) + '">' +
|
return '<div class="skill-item" data-skill="' + escapeAttr(s.name) + '">' +
|
||||||
'<div class="skill-body">' +
|
'<div class="skill-body">' +
|
||||||
'<div class="skill-name">' + escapeHtml(s.name) + '</div>' +
|
'<div class="skill-name">' + escapeHtml(s.name) + '</div>' +
|
||||||
'<div class="skill-desc">' + escapeHtml(s.description || 'No description') + '</div>' +
|
'<div class="skill-desc">' + escapeHtml(s.description || 'No description') + '</div>' +
|
||||||
'<span class="skill-source">' + escapeHtml(s.source) + '</span>' +
|
'<span class="skill-source">' + escapeHtml(s.source) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<span class="skill-arrow">\u203A</span>' +
|
'<span class="skill-arrow">\u203A</span>' +
|
||||||
'</div>'
|
'</div>';
|
||||||
).join('');
|
}).join('');
|
||||||
|
|
||||||
// Restore selection if still valid
|
|
||||||
if (selectedSkill) {
|
if (selectedSkill) {
|
||||||
const prev = el.querySelector('[data-skill="' + CSS.escape(selectedSkill) + '"]');
|
var prev = el.querySelector('[data-skill="' + CSS.escape(selectedSkill) + '"]');
|
||||||
if (prev) prev.classList.add('selected');
|
if (prev) prev.classList.add('selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
el.querySelectorAll('.skill-item').forEach(item => {
|
el.querySelectorAll('.skill-item').forEach(function(item) {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', function() {
|
||||||
el.querySelectorAll('.skill-item').forEach(i => i.classList.remove('selected'));
|
el.querySelectorAll('.skill-item').forEach(function(i) { i.classList.remove('selected'); });
|
||||||
item.classList.add('selected');
|
item.classList.add('selected');
|
||||||
selectedSkill = item.dataset.skill;
|
selectedSkill = item.dataset.skill;
|
||||||
document.getElementById('send-bar').style.display = 'flex';
|
document.getElementById('send-bar').style.display = 'flex';
|
||||||
document.getElementById('skill-msg').placeholder =
|
document.getElementById('skill-msg').placeholder = 'Message for /' + selectedSkill + '...';
|
||||||
'Message for /' + selectedSkill + '...';
|
|
||||||
document.getElementById('skill-msg').focus();
|
document.getElementById('skill-msg').focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSkills() {
|
||||||
|
var loading = document.getElementById('skills-loading');
|
||||||
|
var el = document.getElementById('skills-list');
|
||||||
|
loading.style.display = 'block';
|
||||||
|
loading.textContent = 'Loading skills...';
|
||||||
|
el.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = await apiFetch('/miniapp/api/skills');
|
||||||
|
renderSkillsFromData(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading.textContent = 'Failed to load skills.';
|
loading.textContent = 'Failed to load skills.';
|
||||||
loading.style.display = 'block';
|
loading.style.display = 'block';
|
||||||
|
|
@ -778,47 +783,54 @@ function renderActiveSessions(sessions) {
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSession() {
|
function renderSessionFromData(sessions, stats) {
|
||||||
const loading = document.getElementById('session-loading');
|
var loading = document.getElementById('session-loading');
|
||||||
const el = document.getElementById('session-content');
|
var el = document.getElementById('session-content');
|
||||||
loading.style.display = 'block';
|
|
||||||
loading.textContent = 'Loading session...';
|
|
||||||
el.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [data, sessions] = await Promise.all([
|
|
||||||
apiFetch('/miniapp/api/session'),
|
|
||||||
apiFetch('/miniapp/api/sessions').catch(function() { return []; }),
|
|
||||||
]);
|
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
|
|
||||||
var html = renderActiveSessions(sessions);
|
var html = renderActiveSessions(sessions);
|
||||||
|
|
||||||
if (data.status === 'stats not enabled') {
|
if (!stats || stats.status === 'stats not enabled') {
|
||||||
html += '<div class="empty-state">Stats tracking not enabled.<br>Start gateway with --stats flag.</div>';
|
html += '<div class="empty-state">Stats tracking not enabled.<br>Start gateway with --stats flag.</div>';
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const since = data.since ? new Date(data.since).toLocaleDateString() : 'N/A';
|
var since = stats.since ? new Date(stats.since).toLocaleDateString() : 'N/A';
|
||||||
|
var today = stats.today || {};
|
||||||
html +=
|
html +=
|
||||||
'<div class="card">' +
|
'<div class="card">' +
|
||||||
'<div class="card-title">Today</div>' +
|
'<div class="card-title">Today</div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Prompts</span><span class="stat-value">' + (data.today?.prompts || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Prompts</span><span class="stat-value">' + (today.prompts || 0) + '</span></div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Requests</span><span class="stat-value">' + (data.today?.requests || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Requests</span><span class="stat-value">' + (today.requests || 0) + '</span></div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Tokens</span><span class="stat-value">' + formatTokens(data.today?.total_tokens || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Tokens</span><span class="stat-value">' + formatTokens(today.total_tokens || 0) + '</span></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card">' +
|
'<div class="card">' +
|
||||||
'<div class="card-title">All Time (since ' + escapeHtml(since) + ')</div>' +
|
'<div class="card-title">All Time (since ' + escapeHtml(since) + ')</div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Prompts</span><span class="stat-value">' + (data.total_prompts || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Prompts</span><span class="stat-value">' + (stats.total_prompts || 0) + '</span></div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Requests</span><span class="stat-value">' + (data.total_requests || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Requests</span><span class="stat-value">' + (stats.total_requests || 0) + '</span></div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Total Tokens</span><span class="stat-value">' + formatTokens(data.total_tokens || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Total Tokens</span><span class="stat-value">' + formatTokens(stats.total_tokens || 0) + '</span></div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Prompt Tokens</span><span class="stat-value">' + formatTokens(data.total_prompt_tokens || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Prompt Tokens</span><span class="stat-value">' + formatTokens(stats.total_prompt_tokens || 0) + '</span></div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Completion Tokens</span><span class="stat-value">' + formatTokens(data.total_completion_tokens || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Completion Tokens</span><span class="stat-value">' + formatTokens(stats.total_completion_tokens || 0) + '</span></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
var loading = document.getElementById('session-loading');
|
||||||
|
var el = document.getElementById('session-content');
|
||||||
|
loading.style.display = 'block';
|
||||||
|
loading.textContent = 'Loading session...';
|
||||||
|
el.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var results = await Promise.all([
|
||||||
|
apiFetch('/miniapp/api/session'),
|
||||||
|
apiFetch('/miniapp/api/sessions').catch(function() { return []; }),
|
||||||
|
]);
|
||||||
|
renderSessionFromData(results[1], results[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading.textContent = 'Failed to load session.';
|
loading.textContent = 'Failed to load session.';
|
||||||
loading.style.display = 'block';
|
loading.style.display = 'block';
|
||||||
|
|
@ -842,7 +854,34 @@ function escapeAttr(s) {
|
||||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load
|
// ── SSE real-time updates ──
|
||||||
|
var eventSource = null;
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
eventSource = new EventSource(
|
||||||
|
API_BASE + '/miniapp/api/events?initData=' + encodeURIComponent(initData)
|
||||||
|
);
|
||||||
|
eventSource.addEventListener('plan', function(e) {
|
||||||
|
try { renderPlanFromData(JSON.parse(e.data)); } catch(err) {}
|
||||||
|
});
|
||||||
|
eventSource.addEventListener('session', function(e) {
|
||||||
|
try {
|
||||||
|
var d = JSON.parse(e.data);
|
||||||
|
renderSessionFromData(d.sessions, d.stats);
|
||||||
|
} catch(err) {}
|
||||||
|
});
|
||||||
|
eventSource.addEventListener('skills', function(e) {
|
||||||
|
try { renderSkillsFromData(JSON.parse(e.data)); } catch(err) {}
|
||||||
|
});
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
// Browser will auto-reconnect EventSource
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connectSSE();
|
||||||
|
|
||||||
|
// Initial load (fallback for tabs not covered by initial SSE burst)
|
||||||
loadPlan();
|
loadPlan();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue