Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,11 @@ form.addEventListener('submit',function(e){
.then(function(resp){
if(!resp.ok&&resp.status===401){
prog.style.display='none';
result.innerHTML='<div class="card"><p>Please <a href="/login?redirect=/agent">login</a> to use the agent.</p></div>';
resp.json().then(function(j){
result.innerHTML='<div class="card"><p>'+(j.error||'Authentication required')+'</p><p style="margin-top:8px"><a href="/signup" class="btn">Sign up</a> <a href="/login?redirect=/agent" style="margin-left:8px">or log in</a></p></div>';
}).catch(function(){
result.innerHTML='<div class="card"><p>Please <a href="/login?redirect=/agent">login</a> to use the agent.</p></div>';
});
btn.disabled=false;btn.textContent='Do';
return;
}
Expand Down Expand Up @@ -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
Expand All @@ -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."
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions agent/guest.go
Original file line number Diff line number Diff line change
@@ -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++
}
31 changes: 19 additions & 12 deletions agent/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,28 @@ 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 {
model = m
break
}
}
if QuotaCheck != nil {
if !isGuest && QuotaCheck != nil {
canProceed, _, err := QuotaCheck(r, model.WalletOp)
if !canProceed {
w.WriteHeader(402)
Expand All @@ -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" +
Expand Down Expand Up @@ -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})
Expand Down
68 changes: 68 additions & 0 deletions home/guest.go
Original file line number Diff line number Diff line change
@@ -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(`<div style="text-align:center;padding:32px 0 0">`)
b.WriteString(`<h2 style="font-size:1.5rem;margin:0 0 6px">Your personal AI</h2>`)
b.WriteString(`<p style="color:#666;font-size:14px;margin:0 0 20px">Ask anything. Try it free.</p>`)
b.WriteString(`</div>`)

b.WriteString(`<div style="max-width:560px;margin:0 auto 24px">`)
b.WriteString(`<form action="/agent" method="GET" style="position:relative">`)
b.WriteString(`<textarea name="prompt" placeholder="What do you need?" maxlength="512" rows="1" style="width:100%;padding:14px 44px 14px 16px;border:1px solid #ddd;border-radius:14px;font-size:16px;font-family:inherit;resize:none;box-sizing:border-box;line-height:1.4;overflow:hidden;background:#fff" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.form.submit()}" oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px'"></textarea>`)
b.WriteString(`<button type="submit" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);width:32px;height:32px;background:#000;color:#fff;border:none;border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;padding:0">&#x2192;</button>`)
b.WriteString(`</form>`)

// Suggestion pills
suggestions := []string{"Today's news", "Bitcoin price", "What is Mu?"}
b.WriteString(`<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center;margin-top:10px">`)
for _, s := range suggestions {
b.WriteString(fmt.Sprintf(`<a href="/agent?prompt=%s" style="padding:6px 12px;border:1px solid #e0e0e0;border-radius:20px;background:#fff;font-size:13px;color:#555;text-decoration:none;white-space:nowrap">%s</a>`, htmlEsc(url.QueryEscape(s)), htmlEsc(s)))
}
b.WriteString(`</div>`)
b.WriteString(`</div>`)

// Public content cards — show a taste of what's available
b.WriteString(`<div id="home-cards">`)

// 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(`</div>`)

// CTA
b.WriteString(`<div style="text-align:center;padding:24px 0;border-top:1px solid #eee;margin-top:16px">`)
b.WriteString(`<p style="font-size:15px;color:#555;margin:0 0 12px">Get the full experience — AI agent with memory, mail, web search, and more.</p>`)
b.WriteString(`<a href="/pricing" style="display:inline-block;padding:10px 24px;background:#000;color:#fff;text-decoration:none;border-radius:8px;font-size:15px;margin-right:8px">View pricing</a>`)
b.WriteString(`<a href="/signup" style="display:inline-block;padding:10px 24px;border:1px solid #000;color:#000;text-decoration:none;border-radius:8px;font-size:15px">Sign up</a>`)
b.WriteString(`</div>`)

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))
}
8 changes: 7 additions & 1 deletion home/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 {
Expand Down
93 changes: 93 additions & 0 deletions home/pricing.go
Original file line number Diff line number Diff line change
@@ -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(`<div style="max-width:560px;margin:0 auto;text-align:center;padding:24px 0 0">`)
b.WriteString(`<h2 style="font-size:1.6rem;margin:0 0 8px">Your personal AI</h2>`)
b.WriteString(`<p style="color:#666;font-size:15px;margin:0 0 24px">News, mail, markets, search, weather, and more — all through one AI that knows what you care about.</p>`)
b.WriteString(`</div>`)

// Plans
b.WriteString(`<div style="display:flex;gap:16px;flex-wrap:wrap;justify-content:center;margin:0 0 24px">`)

// Starter plan
b.WriteString(`<div class="card" style="flex:1;min-width:240px;max-width:300px;text-align:center">`)
b.WriteString(`<h3 style="margin:0 0 4px">Starter</h3>`)
b.WriteString(`<p style="font-size:2rem;font-weight:700;margin:8px 0">£5<span style="font-size:14px;font-weight:400;color:#888">/month</span></p>`)
b.WriteString(`<p style="color:#666;font-size:14px;margin:0 0 16px">500 credits</p>`)
b.WriteString(`<ul style="text-align:left;list-style:none;padding:0;margin:0 0 16px;font-size:14px;line-height:2">`)
b.WriteString(`<li>&#10003; AI agent with memory</li>`)
b.WriteString(`<li>&#10003; News, markets, weather</li>`)
b.WriteString(`<li>&#10003; Mail and messaging</li>`)
b.WriteString(`<li>&#10003; Web search</li>`)
b.WriteString(`<li>&#10003; Build apps with AI</li>`)
b.WriteString(`</ul>`)
b.WriteString(`<a href="/signup" class="btn" style="display:block">Get started</a>`)
b.WriteString(`</div>`)

// Pro plan
b.WriteString(`<div class="card" style="flex:1;min-width:240px;max-width:300px;text-align:center;border:2px solid #000">`)
b.WriteString(`<h3 style="margin:0 0 4px">Pro</h3>`)
b.WriteString(`<p style="font-size:2rem;font-weight:700;margin:8px 0">£10<span style="font-size:14px;font-weight:400;color:#888">/month</span></p>`)
b.WriteString(`<p style="color:#666;font-size:14px;margin:0 0 16px">1,200 credits</p>`)
b.WriteString(`<ul style="text-align:left;list-style:none;padding:0;margin:0 0 16px;font-size:14px;line-height:2">`)
b.WriteString(`<li>&#10003; Everything in Starter</li>`)
b.WriteString(`<li>&#10003; More credits per month</li>`)
b.WriteString(`<li>&#10003; Priority AI models</li>`)
b.WriteString(`</ul>`)
b.WriteString(`<a href="/signup" class="btn" style="display:block">Get started</a>`)
b.WriteString(`</div>`)

b.WriteString(`</div>`)

// What's included (free)
b.WriteString(`<div class="card" style="max-width:560px;margin:0 auto 16px">`)
b.WriteString(`<h3>Included for everyone</h3>`)
b.WriteString(`<p style="font-size:14px;color:#666">Browse without an account. No ads, no tracking.</p>`)
b.WriteString(`<ul style="list-style:none;padding:0;font-size:14px;line-height:2;margin:8px 0 0">`)
b.WriteString(`<li>&#10003; News headlines and feeds</li>`)
b.WriteString(`<li>&#10003; Market prices</li>`)
b.WriteString(`<li>&#10003; Blog posts and social</li>`)
b.WriteString(`<li>&#10003; Video</li>`)
b.WriteString(`<li>&#10003; 3 free AI questions as a guest</li>`)
b.WriteString(`</ul>`)
b.WriteString(`</div>`)

// Credit costs
b.WriteString(`<div class="card" style="max-width:560px;margin:0 auto 16px">`)
b.WriteString(`<h3>Credit costs</h3>`)
b.WriteString(`<p style="font-size:14px;color:#666;margin:0 0 8px">1 credit = 1p. Pay for what you use.</p>`)
b.WriteString(`<table class="stats-table" style="font-size:14px">`)
b.WriteString(`<tr><td>AI agent</td><td>` + fmt.Sprintf("%d", wallet.CostAgentQuery) + `</td></tr>`)
b.WriteString(`<tr><td>Chat</td><td>` + fmt.Sprintf("%d", wallet.CostChatQuery) + `</td></tr>`)
b.WriteString(`<tr><td>Web search</td><td>` + fmt.Sprintf("%d", wallet.CostWebSearch) + `</td></tr>`)
b.WriteString(`<tr><td>Weather</td><td>` + fmt.Sprintf("%d", wallet.CostWeatherForecast) + `</td></tr>`)
b.WriteString(`<tr><td>Mail</td><td>` + fmt.Sprintf("%d", wallet.CostMailSend) + `</td></tr>`)
b.WriteString(`<tr><td>News search</td><td>` + fmt.Sprintf("%d", wallet.CostNewsSearch) + `</td></tr>`)
b.WriteString(`</table>`)
b.WriteString(`</div>`)

// Self-host
b.WriteString(`<div class="card" style="max-width:560px;margin:0 auto 16px">`)
b.WriteString(`<h3>Self-host</h3>`)
b.WriteString(`<p style="font-size:14px;color:#666">Run your own instance with no limits. Single Go binary, bring your own AI provider.</p>`)
b.WriteString(`<p style="font-size:14px"><a href="https://github.com/micro/mu">github.com/micro/mu</a></p>`)
b.WriteString(`</div>`)

// Login link
b.WriteString(`<p style="text-align:center;font-size:14px;color:#888;margin:16px 0">Already have an account? <a href="/login">Log in</a></p>`)

html := app.RenderHTML("Pricing", "Personal AI — plans and pricing", b.String())
w.Write([]byte(html))
}
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading