diff --git a/agent/agent.go b/agent/agent.go index b7472f40..9b819486 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -407,7 +407,11 @@ form.addEventListener('submit',function(e){ .then(function(resp){ if(!resp.ok&&resp.status===401){ prog.style.display='none'; - result.innerHTML='

Please login to use the agent.

'; + resp.json().then(function(j){ + result.innerHTML='

'+(j.error||'Authentication required')+'

Sign up or log in

'; + }).catch(function(){ + result.innerHTML='

Please login to use the agent.

'; + }); btn.disabled=false;btn.textContent='Do'; return; } @@ -751,11 +755,18 @@ func handleQuery(w http.ResponseWriter, r *http.Request) { return } - // Require authentication - _, acc, err := auth.RequireSession(r) - if err != nil { - http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized) - return + _, acc := auth.TrySession(r) + isGuest := acc == nil + + if isGuest { + ip := app.ClientIP(r) + if !guestQueryAllowed(ip) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"Sign up to keep using the AI agent. 3 free queries per day."}`)) + return + } + guestQueryRecord(ip) } // Resolve model @@ -767,8 +778,8 @@ func handleQuery(w http.ResponseWriter, r *http.Request) { } } - // Check wallet quota before starting - if QuotaCheck != nil { + // Check wallet quota (authenticated users only) + if !isGuest && QuotaCheck != nil { canProceed, _, err := QuotaCheck(r, model.WalletOp) if !canProceed { msg := "Insufficient credits for agent query. Top up at /wallet." @@ -788,16 +799,22 @@ func handleQuery(w http.ResponseWriter, r *http.Request) { // Create flow early so progress is saved server-side even if the // SSE connection drops (e.g. mobile browser suspends the tab). + accountID := "" + if acc != nil { + accountID = acc.ID + } flow := &Flow{ ID: newFlowID(), - AccountID: acc.ID, + AccountID: accountID, Prompt: req.Prompt, Status: "running", ParentID: req.ContextID, CreatedAt: time.Now().UTC(), } - if err := saveFlow(flow); err != nil { - app.Log("agent", "Failed to create flow: %v", err) + if !isGuest { + if err := saveFlow(flow); err != nil { + app.Log("agent", "Failed to create flow: %v", err) + } } // Start SSE stream diff --git a/agent/guest.go b/agent/guest.go new file mode 100644 index 00000000..c533a1e3 --- /dev/null +++ b/agent/guest.go @@ -0,0 +1,44 @@ +package agent + +import ( + "sync" + "time" +) + +const guestDailyLimit = 3 + +var ( + guestMu sync.Mutex + guestCounts = map[string]*guestBucket{} +) + +type guestBucket struct { + count int + resetAt time.Time +} + +func guestQueryAllowed(ip string) bool { + guestMu.Lock() + defer guestMu.Unlock() + + b, ok := guestCounts[ip] + if !ok || time.Now().After(b.resetAt) { + return true + } + return b.count < guestDailyLimit +} + +func guestQueryRecord(ip string) { + guestMu.Lock() + defer guestMu.Unlock() + + b, ok := guestCounts[ip] + if !ok || time.Now().After(b.resetAt) { + guestCounts[ip] = &guestBucket{ + count: 1, + resetAt: time.Now().Add(24 * time.Hour), + } + return + } + b.count++ +} diff --git a/agent/run.go b/agent/run.go index 726273f1..9134b4d7 100644 --- a/agent/run.go +++ b/agent/run.go @@ -102,14 +102,20 @@ func RunHandler(w http.ResponseWriter, r *http.Request) { return } - _, acc, err := auth.RequireSession(r) - if err != nil { - w.WriteHeader(401) - app.RespondJSON(w, RunResponse{Error: "authentication required"}) - return + _, acc := auth.TrySession(r) + isGuest := acc == nil + + if isGuest { + ip := app.ClientIP(r) + if !guestQueryAllowed(ip) { + w.WriteHeader(401) + app.RespondJSON(w, RunResponse{Error: "Sign up to keep using the AI agent. 3 free queries per day."}) + return + } + guestQueryRecord(ip) } - // Check quota + // Check quota (authenticated users only) model := Models[0] for _, m := range Models { if m.ID == req.Model { @@ -117,7 +123,7 @@ func RunHandler(w http.ResponseWriter, r *http.Request) { break } } - if QuotaCheck != nil { + if !isGuest && QuotaCheck != nil { canProceed, _, err := QuotaCheck(r, model.WalletOp) if !canProceed { w.WriteHeader(402) @@ -130,11 +136,9 @@ func RunHandler(w http.ResponseWriter, r *http.Request) { } } - _ = acc // authenticated - // Step 1: Plan userCtx := "" - if UserContextFunc != nil { + if !isGuest && UserContextFunc != nil { userCtx = UserContextFunc(acc.ID) } planSystem := "You are an AI agent. Given a user question, output ONLY a JSON array of tool calls.\n\n" + @@ -217,10 +221,13 @@ func RunHandler(w http.ResponseWriter, r *http.Request) { answer = app.StripLatexDollars(answer) - // Check if the user asked to remember something. + if isGuest { + app.RespondJSON(w, RunResponse{Answer: answer, Tools: toolsUsed}) + return + } + go extractMemory(acc.ID, req.Prompt) - // Save as a flow so it appears in the agent history at /agent. var steps []FlowStep for _, tu := range toolsUsed { steps = append(steps, FlowStep{Tool: tu.Name}) diff --git a/home/guest.go b/home/guest.go new file mode 100644 index 00000000..9a39be69 --- /dev/null +++ b/home/guest.go @@ -0,0 +1,68 @@ +package home + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "mu/internal/app" +) + +func serveGuestHome(w http.ResponseWriter, r *http.Request) { + RefreshCards() + + var b strings.Builder + + // AI prompt — guest trial (3 free queries tracked by IP) + b.WriteString(`
`) + b.WriteString(`

Your personal AI

`) + b.WriteString(`

Ask anything. Try it free.

`) + b.WriteString(`
`) + + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(``) + b.WriteString(``) + b.WriteString(`
`) + + // Suggestion pills + suggestions := []string{"Today's news", "Bitcoin price", "What is Mu?"} + b.WriteString(`
`) + for _, s := range suggestions { + b.WriteString(fmt.Sprintf(`%s`, htmlEsc(url.QueryEscape(s)), htmlEsc(s))) + } + b.WriteString(`
`) + b.WriteString(`
`) + + // Public content cards — show a taste of what's available + b.WriteString(`
`) + + // News preview + cacheMutex.RLock() + for _, card := range Cards { + if card.ID == "news" || card.ID == "markets" || card.ID == "blog" || card.ID == "reminder" { + content := card.CachedHTML + if strings.TrimSpace(content) == "" { + continue + } + if card.Link != "" { + content += app.Link("More", card.Link) + } + b.WriteString(fmt.Sprintf(app.CardTemplate, card.ID, card.ID, card.Title, content)) + } + } + cacheMutex.RUnlock() + + b.WriteString(`
`) + + // CTA + b.WriteString(`
`) + b.WriteString(`

Get the full experience — AI agent with memory, mail, web search, and more.

`) + b.WriteString(`View pricing`) + b.WriteString(`Sign up`) + b.WriteString(`
`) + + html := app.RenderHTMLWithLangAndBody("Mu — Your Personal AI", "News, mail, markets, search and more through one AI", b.String(), "en", ` class="page-home"`) + w.Write([]byte(html)) +} diff --git a/home/home.go b/home/home.go index a22e917c..19763e75 100644 --- a/home/home.go +++ b/home/home.go @@ -370,6 +370,13 @@ func Handler(w http.ResponseWriter, r *http.Request) { return } + // Guest landing page for unauthenticated visitors. + _, viewerAcc := auth.TrySession(r) + if viewerAcc == nil { + serveGuestHome(w, r) + return + } + // Refresh cards if cache expired (2 minute TTL) RefreshCards() @@ -378,7 +385,6 @@ func Handler(w http.ResponseWriter, r *http.Request) { // Date header + weather + invite — Overview only, built here // and injected into the cards div below. now := time.Now() - _, viewerAcc := auth.TrySession(r) var dateLine strings.Builder inviteHTML := "" if viewerAcc != nil { diff --git a/home/pricing.go b/home/pricing.go new file mode 100644 index 00000000..ba9e7827 --- /dev/null +++ b/home/pricing.go @@ -0,0 +1,93 @@ +package home + +import ( + "fmt" + "net/http" + "strings" + + "mu/internal/app" + "mu/wallet" +) + +func PricingHandler(w http.ResponseWriter, r *http.Request) { + var b strings.Builder + + // Hero + b.WriteString(`
`) + b.WriteString(`

Your personal AI

`) + b.WriteString(`

News, mail, markets, search, weather, and more — all through one AI that knows what you care about.

`) + b.WriteString(`
`) + + // Plans + b.WriteString(`
`) + + // Starter plan + b.WriteString(`
`) + b.WriteString(`

Starter

`) + b.WriteString(`

£5/month

`) + b.WriteString(`

500 credits

`) + b.WriteString(``) + b.WriteString(`Get started`) + b.WriteString(`
`) + + // Pro plan + b.WriteString(`
`) + b.WriteString(`

Pro

`) + b.WriteString(`

£10/month

`) + b.WriteString(`

1,200 credits

`) + b.WriteString(``) + b.WriteString(`Get started`) + b.WriteString(`
`) + + b.WriteString(`
`) + + // What's included (free) + b.WriteString(`
`) + b.WriteString(`

Included for everyone

`) + b.WriteString(`

Browse without an account. No ads, no tracking.

`) + b.WriteString(``) + b.WriteString(`
`) + + // Credit costs + b.WriteString(`
`) + b.WriteString(`

Credit costs

`) + b.WriteString(`

1 credit = 1p. Pay for what you use.

`) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(`
AI agent` + fmt.Sprintf("%d", wallet.CostAgentQuery) + `
Chat` + fmt.Sprintf("%d", wallet.CostChatQuery) + `
Web search` + fmt.Sprintf("%d", wallet.CostWebSearch) + `
Weather` + fmt.Sprintf("%d", wallet.CostWeatherForecast) + `
Mail` + fmt.Sprintf("%d", wallet.CostMailSend) + `
News search` + fmt.Sprintf("%d", wallet.CostNewsSearch) + `
`) + b.WriteString(`
`) + + // Self-host + b.WriteString(`
`) + b.WriteString(`

Self-host

`) + b.WriteString(`

Run your own instance with no limits. Single Go binary, bring your own AI provider.

`) + b.WriteString(`

github.com/micro/mu

`) + b.WriteString(`
`) + + // Login link + b.WriteString(`

Already have an account? Log in

`) + + html := app.RenderHTML("Pricing", "Personal AI — plans and pricing", b.String()) + w.Write([]byte(html)) +} diff --git a/main.go b/main.go index 48435e83..712aa5b4 100644 --- a/main.go +++ b/main.go @@ -715,6 +715,7 @@ func main() { "/web/read": false, // Public page, auth checked in handler (proxied reader) "/status": false, // Public - server health status + "/pricing": false, // Public - pricing page "/docs": false, // Public - documentation "/whitepaper": false, // Public - whitepaper "/mcp": false, // Public - MCP tools page @@ -829,6 +830,7 @@ func main() { // serve the home screen http.HandleFunc("/home", home.Handler) + http.HandleFunc("/pricing", home.PricingHandler) // serve the agent http.HandleFunc("/agent", agent.Handler)