feat: add dev preview proxy, tool, and Mini App tab
Expose local dev servers through the Mini App reverse proxy so agents can let users preview in-progress web apps directly in Telegram. - Add /miniapp/dev/ reverse proxy with localhost-only validation - Add /miniapp/api/dev GET/POST endpoints and SSE "dev" event - Add dev_preview tool (start/stop/status) for agent control - Add Dev tab to Mini App with status, target input, and iframe preview - Add dev-preview skill (SKILL.md) in workspace Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d004879579
commit
18c35c4836
6 changed files with 591 additions and 9 deletions
|
|
@ -238,6 +238,11 @@ func gatewayCmd() {
|
|||
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier)
|
||||
agentLoop.OnStateChange = miniappNotifier.Notify
|
||||
handler.RegisterRoutes(healthServer.Mux())
|
||||
|
||||
// Register dev preview tool for all agents
|
||||
devPreviewTool := tools.NewDevPreviewTool(handler)
|
||||
agentLoop.RegisterTool(devPreviewTool)
|
||||
|
||||
fmt.Printf("✓ Mini App registered at %s\n", webAppURL)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
|
@ -158,12 +159,22 @@ func (n *StateNotifier) Notify() {
|
|||
}
|
||||
}
|
||||
|
||||
// DevTargetSetter allows tools to control the dev proxy target.
|
||||
type DevTargetSetter interface {
|
||||
SetDevTarget(target string) error
|
||||
GetDevTarget() string
|
||||
}
|
||||
|
||||
// Handler serves the Mini App HTML and API endpoints.
|
||||
type Handler struct {
|
||||
provider DataProvider
|
||||
sender CommandSender
|
||||
botToken string
|
||||
notifier *StateNotifier
|
||||
|
||||
devMu sync.RWMutex
|
||||
devTarget *url.URL
|
||||
devProxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
// NewHandler creates a new Mini App handler.
|
||||
|
|
@ -176,6 +187,49 @@ func NewHandler(provider DataProvider, sender CommandSender, botToken string, no
|
|||
}
|
||||
}
|
||||
|
||||
// SetDevTarget sets the reverse proxy target URL. Only localhost targets are allowed.
|
||||
// Pass an empty string to disable the proxy.
|
||||
func (h *Handler) SetDevTarget(target string) error {
|
||||
h.devMu.Lock()
|
||||
defer h.devMu.Unlock()
|
||||
|
||||
if target == "" {
|
||||
h.devTarget = nil
|
||||
h.devProxy = nil
|
||||
if h.notifier != nil {
|
||||
h.notifier.Notify()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
|
||||
return fmt.Errorf("only localhost targets are allowed, got %q", host)
|
||||
}
|
||||
|
||||
h.devTarget = u
|
||||
h.devProxy = httputil.NewSingleHostReverseProxy(u)
|
||||
if h.notifier != nil {
|
||||
h.notifier.Notify()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
|
||||
func (h *Handler) GetDevTarget() string {
|
||||
h.devMu.RLock()
|
||||
defer h.devMu.RUnlock()
|
||||
if h.devTarget == nil {
|
||||
return ""
|
||||
}
|
||||
return h.devTarget.String()
|
||||
}
|
||||
|
||||
// RegisterRoutes registers Mini App routes on the given mux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/miniapp", h.serveIndex)
|
||||
|
|
@ -185,7 +239,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
||||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
||||
}
|
||||
|
||||
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -286,6 +342,59 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
target := h.GetDevTarget()
|
||||
writeJSON(w, map[string]any{
|
||||
"active": target != "",
|
||||
"target": target,
|
||||
})
|
||||
case http.MethodPost:
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.SetDevTarget(req.Target); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
target := h.GetDevTarget()
|
||||
writeJSON(w, map[string]any{
|
||||
"active": target != "",
|
||||
"target": target,
|
||||
})
|
||||
default:
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) {
|
||||
h.devMu.RLock()
|
||||
proxy := h.devProxy
|
||||
h.devMu.RUnlock()
|
||||
|
||||
if proxy == nil {
|
||||
http.Error(w, "dev proxy not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Strip /miniapp/dev prefix so /miniapp/dev/foo → /foo
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/miniapp/dev")
|
||||
if r.URL.Path == "" {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// extractUserFromInitData parses user.id from the initData query string.
|
||||
// initData contains a "user" param with JSON like {"id":123456,...}.
|
||||
func extractUserFromInitData(initData string) (userID, chatID string) {
|
||||
|
|
@ -325,7 +434,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
ch := h.notifier.Subscribe()
|
||||
defer h.notifier.Unsubscribe(ch)
|
||||
|
||||
var lastPlan, lastSession, lastSkills []byte
|
||||
var lastPlan, lastSession, lastSkills, lastDev []byte
|
||||
|
||||
// Send initial state immediately
|
||||
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||
|
|
@ -333,6 +442,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
||||
&lastSession)
|
||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||
|
||||
for {
|
||||
select {
|
||||
|
|
@ -346,10 +456,19 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
||||
&lastSession)
|
||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) devStatus() map[string]any {
|
||||
target := h.GetDevTarget()
|
||||
return map[string]any{
|
||||
"active": target != "",
|
||||
"target": target,
|
||||
}
|
||||
}
|
||||
|
||||
func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) {
|
||||
data, _ := json.Marshal(v)
|
||||
if !bytes.Equal(data, *last) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -248,7 +249,7 @@ func TestSSE_InitialEvents(t *testing.T) {
|
|||
events := make(map[string]bool)
|
||||
deadline := time.After(2 * time.Second)
|
||||
|
||||
for len(events) < 3 {
|
||||
for len(events) < 4 {
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
done <- scanner.Scan()
|
||||
|
|
@ -267,7 +268,7 @@ func TestSSE_InitialEvents(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{"plan", "session", "skills"} {
|
||||
for _, name := range []string{"plan", "session", "skills", "dev"} {
|
||||
if !events[name] {
|
||||
t.Errorf("missing initial event %q", name)
|
||||
}
|
||||
|
|
@ -374,8 +375,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
|||
defer resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// Drain initial 3 events
|
||||
drainEvents(t, scanner, 3, 2*time.Second)
|
||||
// Drain initial 4 events (plan, session, skills, dev)
|
||||
drainEvents(t, scanner, 4, 2*time.Second)
|
||||
|
||||
// Mutate state and notify — diff dedup should detect the change and send a new event
|
||||
provider.mutated.Store(true)
|
||||
|
|
@ -403,8 +404,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
|
|||
defer resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// Drain initial events
|
||||
drainEvents(t, scanner, 3, 2*time.Second)
|
||||
// Drain initial events (plan, session, skills, dev)
|
||||
drainEvents(t, scanner, 4, 2*time.Second)
|
||||
|
||||
// Notify with unchanged data — should produce zero new event lines
|
||||
notifier.Notify()
|
||||
|
|
@ -458,6 +459,135 @@ func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
|
|||
return GitInfo{Name: name}
|
||||
}
|
||||
|
||||
// ── Dev proxy tests ──
|
||||
|
||||
func TestDevProxy_SetAndGet(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
|
||||
// Initially empty
|
||||
if got := h.GetDevTarget(); got != "" {
|
||||
t.Errorf("expected empty target, got %q", got)
|
||||
}
|
||||
|
||||
// Set a valid target
|
||||
if err := h.SetDevTarget("http://localhost:3000"); err != nil {
|
||||
t.Fatalf("SetDevTarget failed: %v", err)
|
||||
}
|
||||
if got := h.GetDevTarget(); got != "http://localhost:3000" {
|
||||
t.Errorf("expected http://localhost:3000, got %q", got)
|
||||
}
|
||||
|
||||
// Clear target
|
||||
if err := h.SetDevTarget(""); err != nil {
|
||||
t.Fatalf("SetDevTarget(\"\") failed: %v", err)
|
||||
}
|
||||
if got := h.GetDevTarget(); got != "" {
|
||||
t.Errorf("expected empty target after clear, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_LocalhostOnly(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
|
||||
// External host should be rejected
|
||||
if err := h.SetDevTarget("http://example.com:3000"); err == nil {
|
||||
t.Error("expected error for external host, got nil")
|
||||
}
|
||||
|
||||
// 127.0.0.1 should be allowed
|
||||
if err := h.SetDevTarget("http://127.0.0.1:8080"); err != nil {
|
||||
t.Errorf("expected 127.0.0.1 to be allowed, got %v", err)
|
||||
}
|
||||
|
||||
// ::1 should be allowed
|
||||
if err := h.SetDevTarget("http://[::1]:9000"); err != nil {
|
||||
t.Errorf("expected [::1] to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_ReverseProxy(t *testing.T) {
|
||||
// Create a backend server
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprintf(w, "path=%s", r.URL.Path)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
// Set target to the test backend
|
||||
if err := h.SetDevTarget(backend.URL); err != nil {
|
||||
t.Fatalf("SetDevTarget failed: %v", err)
|
||||
}
|
||||
|
||||
// Request through the proxy
|
||||
req := httptest.NewRequest("GET", "/miniapp/dev/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if got := w.Body.String(); got != "path=/hello" {
|
||||
t.Errorf("expected path=/hello, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_503WhenNotConfigured(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_APIEndpoint(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
initData := testInitData()
|
||||
|
||||
// GET — initially inactive
|
||||
resp, err := http.Get(ts.URL + "/miniapp/api/dev?initData=" + url.QueryEscape(initData))
|
||||
if err != nil {
|
||||
t.Fatalf("GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result["active"] != false {
|
||||
t.Errorf("expected active=false, got %v", result["active"])
|
||||
}
|
||||
|
||||
// POST — set target
|
||||
body := strings.NewReader(`{"target":"http://localhost:4000"}`)
|
||||
resp2, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(initData), "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("POST failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
var result2 map[string]any
|
||||
json.NewDecoder(resp2.Body).Decode(&result2)
|
||||
if result2["active"] != true {
|
||||
t.Errorf("expected active=true after set, got %v", result2["active"])
|
||||
}
|
||||
if result2["target"] != "http://localhost:4000" {
|
||||
t.Errorf("expected target=http://localhost:4000, got %v", result2["target"])
|
||||
}
|
||||
}
|
||||
|
||||
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
|
||||
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
|
||||
t.Helper()
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
width: calc(20% - 2px);
|
||||
width: calc(100%/6 - 2px);
|
||||
border-radius: 8px;
|
||||
background: var(--tab-pill-bg);
|
||||
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
|
||||
|
|
@ -645,6 +645,7 @@
|
|||
<button class="tab" data-panel="skills">Skills</button>
|
||||
<button class="tab" data-panel="session">Session</button>
|
||||
<button class="tab" data-panel="config">Config</button>
|
||||
<button class="tab" data-panel="dev">Dev</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -686,6 +687,29 @@
|
|||
<div id="git-content" class="hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="dev" class="panel">
|
||||
<div id="dev-status" class="card glass">
|
||||
<div class="card-title">Dev Preview</div>
|
||||
<div class="card-value" id="dev-status-label">Inactive</div>
|
||||
<div id="dev-target-display" style="color:var(--hint);margin-top:4px;font-size:13px"></div>
|
||||
</div>
|
||||
<div class="card glass">
|
||||
<div class="card-title">Target URL</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<input id="dev-target-input" class="send-input glass glass-interactive" placeholder="http://localhost:3000" style="flex:1">
|
||||
<button class="send-btn" id="dev-set-btn" onclick="setDevTarget()">Set</button>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="send-btn" id="dev-stop-btn" onclick="clearDevTarget()" style="background:var(--hint);width:100%">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dev-iframe-wrap" class="hidden" style="margin-top:12px">
|
||||
<div class="card glass" style="padding:0;overflow:hidden">
|
||||
<iframe id="dev-iframe" src="" style="width:100%;height:50vh;border:none;border-radius:16px"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="send-bar hidden" id="send-bar">
|
||||
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
|
||||
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
|
||||
|
|
@ -698,7 +722,7 @@ tg.ready();
|
|||
const API_BASE = location.origin;
|
||||
let initData = tg.initData || '';
|
||||
let selectedSkill = null;
|
||||
var lastSSE = { plan: 0, skills: 0, session: 0 };
|
||||
var lastSSE = { plan: 0, skills: 0, session: 0, dev: 0 };
|
||||
|
||||
// Tab switching — re-fetch data unless SSE delivered recently
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
|
|
@ -725,6 +749,7 @@ tabs.forEach((tab, index) => {
|
|||
if (p === 'skills' && !fresh) loadSkills();
|
||||
if (p === 'session' && !fresh) loadSession();
|
||||
if (p === 'git') loadGit();
|
||||
if (p === 'dev' && !fresh) loadDev();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1238,6 +1263,65 @@ function renderGitDetail(repo) {
|
|||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Dev tab ──
|
||||
function renderDevFromData(data) {
|
||||
var label = document.getElementById('dev-status-label');
|
||||
var display = document.getElementById('dev-target-display');
|
||||
var iframeWrap = document.getElementById('dev-iframe-wrap');
|
||||
var iframe = document.getElementById('dev-iframe');
|
||||
|
||||
if (data.active) {
|
||||
label.textContent = 'Active';
|
||||
label.style.color = 'var(--done)';
|
||||
display.textContent = data.target;
|
||||
iframeWrap.classList.remove('hidden');
|
||||
var iframeSrc = location.origin + '/miniapp/dev/';
|
||||
if (iframe.src !== iframeSrc) iframe.src = iframeSrc;
|
||||
} else {
|
||||
label.textContent = 'Inactive';
|
||||
label.style.color = '';
|
||||
display.textContent = '';
|
||||
iframeWrap.classList.add('hidden');
|
||||
iframe.src = '';
|
||||
}
|
||||
}
|
||||
|
||||
function loadDev() {
|
||||
apiFetch('/miniapp/api/dev').then(renderDevFromData).catch(function() {});
|
||||
}
|
||||
|
||||
async function setDevTarget() {
|
||||
var input = document.getElementById('dev-target-input');
|
||||
var btn = document.getElementById('dev-set-btn');
|
||||
var target = input.value.trim();
|
||||
if (!target) return;
|
||||
try {
|
||||
var res = await fetch(API_BASE + '/miniapp/api/dev?initData=' + encodeURIComponent(initData), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target: target }),
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.error) return;
|
||||
renderDevFromData(data);
|
||||
flashSent(btn);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function clearDevTarget() {
|
||||
var btn = document.getElementById('dev-stop-btn');
|
||||
try {
|
||||
var res = await fetch(API_BASE + '/miniapp/api/dev?initData=' + encodeURIComponent(initData), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target: '' }),
|
||||
});
|
||||
var data = await res.json();
|
||||
renderDevFromData(data);
|
||||
flashSent(btn);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── SSE real-time updates ──
|
||||
var eventSource = null;
|
||||
|
||||
|
|
@ -1259,6 +1343,9 @@ function connectSSE() {
|
|||
eventSource.addEventListener('skills', function(e) {
|
||||
try { lastSSE.skills = Date.now(); renderSkillsFromData(JSON.parse(e.data)); } catch(err) {}
|
||||
});
|
||||
eventSource.addEventListener('dev', function(e) {
|
||||
try { lastSSE.dev = Date.now(); renderDevFromData(JSON.parse(e.data)); } catch(err) {}
|
||||
});
|
||||
eventSource.onerror = function() {
|
||||
// Browser will auto-reconnect EventSource
|
||||
};
|
||||
|
|
|
|||
77
pkg/tools/dev_preview.go
Normal file
77
pkg/tools/dev_preview.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/miniapp"
|
||||
)
|
||||
|
||||
// DevPreviewTool allows the agent to control the Mini App dev reverse proxy.
|
||||
type DevPreviewTool struct {
|
||||
setter miniapp.DevTargetSetter
|
||||
}
|
||||
|
||||
// NewDevPreviewTool creates a new DevPreviewTool.
|
||||
func NewDevPreviewTool(setter miniapp.DevTargetSetter) *DevPreviewTool {
|
||||
return &DevPreviewTool{setter: setter}
|
||||
}
|
||||
|
||||
func (t *DevPreviewTool) Name() string { return "dev_preview" }
|
||||
|
||||
func (t *DevPreviewTool) Description() string {
|
||||
return "Control the Mini App dev preview proxy. Use 'start' to expose a local dev server (localhost only) through the Mini App, 'stop' to disable it, or 'status' to check the current state."
|
||||
}
|
||||
|
||||
func (t *DevPreviewTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"start", "stop", "status"},
|
||||
"description": "Action to perform: start (set proxy target), stop (disable proxy), status (check current target).",
|
||||
},
|
||||
"target": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Target URL for the dev server (e.g. http://localhost:3000). Required for 'start' action. Must be a localhost URL.",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DevPreviewTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
action, ok := args["action"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "start":
|
||||
target, _ := args["target"].(string)
|
||||
if target == "" {
|
||||
return ErrorResult("target is required for start action")
|
||||
}
|
||||
if err := t.setter.SetDevTarget(target); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to set dev target: %v", err))
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("Dev preview started. Target: %s\nUsers can view it in the Mini App Dev tab.", target))
|
||||
|
||||
case "stop":
|
||||
if err := t.setter.SetDevTarget(""); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to stop dev preview: %v", err))
|
||||
}
|
||||
return SilentResult("Dev preview stopped.")
|
||||
|
||||
case "status":
|
||||
target := t.setter.GetDevTarget()
|
||||
if target == "" {
|
||||
return SilentResult("Dev preview is not active.")
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("Dev preview is active. Target: %s", target))
|
||||
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||
}
|
||||
}
|
||||
164
pkg/tools/dev_preview_test.go
Normal file
164
pkg/tools/dev_preview_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockDevTargetSetter implements miniapp.DevTargetSetter for testing.
|
||||
type mockDevTargetSetter struct {
|
||||
target string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDevTargetSetter) SetDevTarget(target string) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.target = target
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDevTargetSetter) GetDevTarget() string {
|
||||
return m.target
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_Start(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
"target": "http://localhost:3000",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
if setter.target != "http://localhost:3000" {
|
||||
t.Errorf("expected target http://localhost:3000, got %q", setter.target)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "started") {
|
||||
t.Errorf("expected result to contain 'started', got %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error for missing target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_StartError(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{err: fmt.Errorf("only localhost")}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
"target": "http://example.com:3000",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error for setter failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_Stop(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{target: "http://localhost:3000"}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "stop",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
if setter.target != "" {
|
||||
t.Errorf("expected empty target after stop, got %q", setter.target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_Status(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{target: "http://localhost:8080"}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "status",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "active") {
|
||||
t.Errorf("expected 'active' in result, got %q", result.ForLLM)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "http://localhost:8080") {
|
||||
t.Errorf("expected target URL in result, got %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_StatusInactive(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "status",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "not active") {
|
||||
t.Errorf("expected 'not active' in result, got %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_UnknownAction(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "restart",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error for unknown action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_MissingAction(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error for missing action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_NameAndSchema(t *testing.T) {
|
||||
setter := &mockDevTargetSetter{}
|
||||
tool := NewDevPreviewTool(setter)
|
||||
|
||||
if tool.Name() != "dev_preview" {
|
||||
t.Errorf("expected name dev_preview, got %q", tool.Name())
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Error("expected non-empty description")
|
||||
}
|
||||
params := tool.Parameters()
|
||||
if params == nil {
|
||||
t.Fatal("expected non-nil parameters")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue