From 06ca0f422c5802bf2dcc9adc8dee15be9010ceb0 Mon Sep 17 00:00:00 2001 From: Stefan Rinke Date: Sun, 15 Feb 2026 11:42:31 +0100 Subject: [PATCH] Added webui for chat --- .gitignore | 3 +- Makefile | 14 +- README.md | 26 + cmd/picoclaw/main.go | 6 +- config/config.example.json | 261 +++++----- go.mod | 7 +- go.sum | 6 +- pkg/channels/manager.go | 13 + pkg/channels/webui.go | 279 +++++++++++ pkg/config/config.go | 20 +- pkg/config/gateway.go | 81 +++ pkg/migrate/config.go | 6 + ui/README.md | 53 ++ ui/index.html | 12 + ui/package-lock.json | 987 +++++++++++++++++++++++++++++++++++++ ui/package.json | 14 + ui/src/main.js | 106 ++++ ui/src/style.css | 127 +++++ ui/vite.config.js | 12 + 19 files changed, 1889 insertions(+), 144 deletions(-) create mode 100644 pkg/channels/webui.go create mode 100644 pkg/config/gateway.go create mode 100644 ui/README.md create mode 100644 ui/index.html create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/src/main.js create mode 100644 ui/src/style.css create mode 100644 ui/vite.config.js diff --git a/.gitignore b/.gitignore index ce30d749e..9ca2cf77c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,8 @@ build/ /picoclaw /picoclaw-test cmd/picoclaw/workspace - +ui/node_modules +ui/dist # Picoclaw specific # PicoClaw diff --git a/Makefile b/Makefile index 058fdb790..7c56b243c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build install uninstall clean help test +.PHONY: all build install uninstall clean help test ui-deps ui-build # Build variables BINARY_NAME=picoclaw @@ -63,6 +63,18 @@ BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) # Default target all: build +## ui-deps: Install UI dependencies (ui/) +ui-deps: + @echo "Installing UI dependencies..." + @cd ui && npm install + @echo "UI dependencies installed" + +## ui-build: Build UI assets to ui/dist (ui/) +ui-build: ui-deps + @echo "Building UI..." + @cd ui && npm run build + @echo "UI build complete: ui/dist" + ## generate: Run generate generate: @echo "Run generate..." diff --git a/README.md b/README.md index a61a1abf4..3422693e5 100644 --- a/README.md +++ b/README.md @@ -766,6 +766,32 @@ picoclaw agent -m "Hello" } ``` +## 🌐 Web UI (Browser Chat) + +When running in `gateway` mode, PicoClaw can serve a simple browser-based chat UI over HTTP. + +**1. Build UI assets** + +```bash +make ui-build +``` + +**2. Run gateway** + +```bash +picoclaw gateway +``` + +**3. Open in browser** + +`http://:18790/` + +If you set `gateway.token`, open: + +`http://:18790/?token=` + +The listen address is controlled by `gateway.bind` (`local`, `tailnet`, `all`). + ## CLI Reference diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 2129662d7..8fb53faf3 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -626,7 +626,11 @@ func gatewayCmd() { fmt.Println("⚠ Warning: No channels enabled") } - fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + if addr, err := cfg.Gateway.ResolvedAddr(); err == nil { + fmt.Printf("✓ Gateway started on %s\n", addr) + } else { + fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + } fmt.Println("Press Ctrl+C to stop") ctx, cancel := context.WithCancel(context.Background()) diff --git a/config/config.example.json b/config/config.example.json index aa75c8338..13fa03b9c 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -1,132 +1,133 @@ { - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true, - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "channels": { - "telegram": { - "enabled": false, - "token": "YOUR_TELEGRAM_BOT_TOKEN", - "proxy": "", - "allow_from": ["YOUR_USER_ID"] - }, - "discord": { - "enabled": false, - "token": "YOUR_DISCORD_BOT_TOKEN", - "allow_from": [] - }, - "maixcam": { - "enabled": false, - "host": "0.0.0.0", - "port": 18790, - "allow_from": [] - }, - "whatsapp": { - "enabled": false, - "bridge_url": "ws://localhost:3001", - "allow_from": [] - }, - "feishu": { - "enabled": false, - "app_id": "", - "app_secret": "", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "dingtalk": { - "enabled": false, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - }, - "slack": { - "enabled": false, - "bot_token": "xoxb-YOUR-BOT-TOKEN", - "app_token": "xapp-YOUR-APP-TOKEN", - "allow_from": [] - }, - "line": { - "enabled": false, - "channel_secret": "YOUR_LINE_CHANNEL_SECRET", - "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, - "webhook_path": "/webhook/line", - "allow_from": [] - }, - "onebot": { - "enabled": false, - "ws_url": "ws://127.0.0.1:3001", - "access_token": "", - "reconnect_interval": 5, - "group_trigger_prefix": [], - "allow_from": [] - } - }, - "providers": { - "anthropic": { - "api_key": "", - "api_base": "" - }, - "openai": { - "api_key": "", - "api_base": "" - }, - "openrouter": { - "api_key": "sk-or-v1-xxx", - "api_base": "" - }, - "groq": { - "api_key": "gsk_xxx", - "api_base": "" - }, - "zhipu": { - "api_key": "YOUR_ZHIPU_API_KEY", - "api_base": "" - }, - "gemini": { - "api_key": "", - "api_base": "" - }, - "vllm": { - "api_key": "", - "api_base": "" - }, - "nvidia": { - "api_key": "nvapi-xxx", - "api_base": "", - "proxy": "http://127.0.0.1:7890" - }, - "moonshot": { - "api_key": "sk-xxx", - "api_base": "" - } - }, - "tools": { - "web": { - "search": { - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - } - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - }, - "devices": { - "enabled": false, - "monitor_usb": true - }, - "gateway": { - "host": "0.0.0.0", - "port": 18790 - } + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true, + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "channels": { + "telegram": { + "enabled": false, + "token": "YOUR_TELEGRAM_BOT_TOKEN", + "proxy": "", + "allow_from": ["YOUR_USER_ID"] + }, + "discord": { + "enabled": false, + "token": "YOUR_DISCORD_BOT_TOKEN", + "allow_from": [] + }, + "maixcam": { + "enabled": false, + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + }, + "whatsapp": { + "enabled": false, + "bridge_url": "ws://localhost:3001", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "app_id": "", + "app_secret": "", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "dingtalk": { + "enabled": false, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + }, + "slack": { + "enabled": false, + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + }, + "line": { + "enabled": false, + "channel_secret": "YOUR_LINE_CHANNEL_SECRET", + "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + }, + "onebot": { + "enabled": false, + "ws_url": "ws://127.0.0.1:3001", + "access_token": "", + "reconnect_interval": 5, + "group_trigger_prefix": [], + "allow_from": [] + } + }, + "providers": { + "anthropic": { + "api_key": "", + "api_base": "" + }, + "openai": { + "api_key": "", + "api_base": "" + }, + "openrouter": { + "api_key": "sk-or-v1-xxx", + "api_base": "" + }, + "groq": { + "api_key": "gsk_xxx", + "api_base": "" + }, + "zhipu": { + "api_key": "YOUR_ZHIPU_API_KEY", + "api_base": "" + }, + "gemini": { + "api_key": "", + "api_base": "" + }, + "vllm": { + "api_key": "", + "api_base": "" + }, + "nvidia": { + "api_key": "nvapi-xxx", + "api_base": "", + "proxy": "http://127.0.0.1:7890" + }, + "moonshot": { + "api_key": "sk-xxx", + "api_base": "" + } + }, + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false, + "monitor_usb": true + }, + "gateway": { + "bind": "all", + "port": 18790, + "token": "" + } } diff --git a/go.mod b/go.mod index 98aecd6ab..8b7b75bbd 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,6 @@ require ( golang.org/x/oauth2 v0.35.0 ) - - require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect @@ -28,9 +26,9 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/github/copilot-sdk/go v0.1.23 - github.com/google/jsonschema-go v0.4.2 // indirect - github.com/go-resty/resty/v2 v2.17.1 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -47,5 +45,4 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - ) diff --git a/go.sum b/go.sum index 6a565b93e..84a94df31 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQ github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= -github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= -github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 15f8c6037..411d801cc 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -46,6 +46,19 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error func (m *Manager) initChannels() error { logger.InfoC("channels", "Initializing channel manager") + if m.config.Channels.WebUI.Enabled { + logger.DebugC("channels", "Attempting to initialize WebUI channel") + webui, err := NewWebUIChannel(m.config.Gateway, m.bus) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize WebUI channel", map[string]interface{}{ + "error": err.Error(), + }) + } else { + m.channels["webui"] = webui + logger.InfoC("channels", "WebUI channel enabled successfully") + } + } + if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" { logger.DebugC("channels", "Attempting to initialize Telegram channel") telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus) diff --git a/pkg/channels/webui.go b/pkg/channels/webui.go new file mode 100644 index 000000000..229799de0 --- /dev/null +++ b/pkg/channels/webui.go @@ -0,0 +1,279 @@ +package channels + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type WebUIChannel struct { + *BaseChannel + cfg config.GatewayConfig + httpServer *http.Server + mu sync.RWMutex + clients map[*webUIClient]struct{} +} + +type webUIClient struct { + conn *websocket.Conn + chatID string + sender string + writeMu sync.Mutex +} + +type webUIInboundMessage struct { + ChatID string `json:"chat_id"` + SenderID string `json:"sender_id"` + Content string `json:"content"` +} + +type webUIOutboundMessage struct { + Type string `json:"type"` + ChatID string `json:"chat_id"` + Content string `json:"content"` +} + +func NewWebUIChannel(cfg config.GatewayConfig, messageBus *bus.MessageBus) (*WebUIChannel, error) { + base := NewBaseChannel("webui", cfg, messageBus, nil) + return &WebUIChannel{ + BaseChannel: base, + cfg: cfg, + clients: make(map[*webUIClient]struct{}), + }, nil +} + +func (c *WebUIChannel) Start(ctx context.Context) error { + addr, err := c.cfg.ResolvedAddr() + if err != nil { + return err + } + + mux := http.NewServeMux() + mux.HandleFunc("/ws", c.handleWS) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + mux.Handle("/", c.staticHandler()) + + c.httpServer = &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + logger.InfoCF("webui", "WebUI server listening", map[string]interface{}{ + "addr": addr, + "bind": c.cfg.Bind, + }) + if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("webui", "WebUI server error", map[string]interface{}{"error": err.Error()}) + } + }() + + c.setRunning(true) + return nil +} + +func (c *WebUIChannel) Stop(ctx context.Context) error { + c.setRunning(false) + + c.mu.Lock() + for cl := range c.clients { + cl.conn.Close() + delete(c.clients, cl) + } + c.mu.Unlock() + + if c.httpServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + _ = c.httpServer.Shutdown(shutdownCtx) + } + return nil +} + +func (c *WebUIChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("webui channel not running") + } + + out := webUIOutboundMessage{Type: "message", ChatID: msg.ChatID, Content: msg.Content} + payload, err := json.Marshal(out) + if err != nil { + return err + } + + c.mu.RLock() + clients := make([]*webUIClient, 0, len(c.clients)) + for cl := range c.clients { + clients = append(clients, cl) + } + c.mu.RUnlock() + + var sendErr error + for _, cl := range clients { + if msg.ChatID != "" && cl.chatID != "" && cl.chatID != msg.ChatID { + continue + } + cl.writeMu.Lock() + err := cl.conn.WriteMessage(websocket.TextMessage, payload) + cl.writeMu.Unlock() + if err != nil { + sendErr = err + c.removeClient(cl) + } + } + + return sendErr +} + +func (c *WebUIChannel) removeClient(cl *webUIClient) { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.clients[cl]; ok { + _ = cl.conn.Close() + delete(c.clients, cl) + } +} + +func (c *WebUIChannel) isAuthorized(u *url.URL) bool { + expected := strings.TrimSpace(c.cfg.Token) + if expected == "" { + return true + } + provided := strings.TrimSpace(u.Query().Get("token")) + return provided != "" && provided == expected +} + +func (c *WebUIChannel) handleWS(w http.ResponseWriter, r *http.Request) { + if !c.isAuthorized(r.URL) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + upgrader := websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // If token auth is enabled, only allow same-origin WS to reduce cross-site hijacking. + // If token is not set, keep permissive behavior for local/LAN usage. + if strings.TrimSpace(c.cfg.Token) == "" { + return true + } + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(u.Host, r.Host) + }, + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + client := &webUIClient{ + conn: conn, + chatID: r.URL.Query().Get("chat_id"), + sender: r.RemoteAddr, + } + if client.chatID == "" { + client.chatID = "browser" + } + + c.mu.Lock() + c.clients[client] = struct{}{} + c.mu.Unlock() + + defer c.removeClient(client) + + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + + var in webUIInboundMessage + if err := json.Unmarshal(data, &in); err != nil { + continue + } + content := strings.TrimSpace(in.Content) + if content == "" { + continue + } + + chatID := strings.TrimSpace(in.ChatID) + if chatID == "" { + chatID = client.chatID + } + senderID := strings.TrimSpace(in.SenderID) + if senderID == "" { + senderID = client.sender + } + + client.chatID = chatID + client.sender = senderID + + c.HandleMessage(senderID, chatID, content, nil, map[string]string{"source": "webui"}) + } +} + +func (c *WebUIChannel) staticHandler() http.Handler { + root := c.findUIRoot() + fs := http.FileServer(http.Dir(root)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + fs.ServeHTTP(w, r) + return + } + + reqPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) + full := filepath.Join(root, reqPath) + if st, err := os.Stat(full); err == nil && !st.IsDir() { + fs.ServeHTTP(w, r) + return + } + + index := filepath.Join(root, "index.html") + if _, err := os.Stat(index); err == nil { + http.ServeFile(w, r, index) + return + } + + http.NotFound(w, r) + }) +} + +func (c *WebUIChannel) findUIRoot() string { + candidates := []string{ + filepath.Join("ui", "dist"), + "ui", + } + for _, p := range candidates { + if st, err := os.Stat(p); err == nil && st.IsDir() { + return p + } + } + return "ui" +} diff --git a/pkg/config/config.go b/pkg/config/config.go index d76ec8095..ba5f0b41c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -79,6 +79,7 @@ type ChannelsConfig struct { Slack SlackConfig `json:"slack"` LINE LINEConfig `json:"line"` OneBot OneBotConfig `json:"onebot"` + WebUI WebUIConfig `json:"webui"` } type WhatsAppConfig struct { @@ -156,6 +157,10 @@ type OneBotConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } +type WebUIConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEBUI_ENABLED"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 @@ -190,8 +195,10 @@ type ProviderConfig struct { } type GatewayConfig struct { - Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` - Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` + Bind string `json:"bind" env:"PICOCLAW_GATEWAY_BIND"` + Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + Token string `json:"token" env:"PICOCLAW_GATEWAY_TOKEN"` } type BraveConfig struct { @@ -292,6 +299,9 @@ func DefaultConfig() *Config { GroupTriggerPrefix: []string{}, AllowFrom: FlexibleStringSlice{}, }, + WebUI: WebUIConfig{ + Enabled: true, + }, }, Providers: ProvidersConfig{ Anthropic: ProviderConfig{}, @@ -306,8 +316,10 @@ func DefaultConfig() *Config { ShengSuanYun: ProviderConfig{}, }, Gateway: GatewayConfig{ - Host: "0.0.0.0", - Port: 18790, + Host: "0.0.0.0", + Bind: "all", + Port: 18790, + Token: "", }, Tools: ToolsConfig{ Web: WebToolsConfig{ diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go new file mode 100644 index 000000000..2a8863707 --- /dev/null +++ b/pkg/config/gateway.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "net" +) + +func (g GatewayConfig) ResolvedHost() (string, error) { + switch g.Bind { + case "", "all": + return "0.0.0.0", nil + case "local": + return "127.0.0.1", nil + case "tailnet": + ip, err := findTailnetIPv4() + if err != nil { + return "", err + } + return ip, nil + default: + return "", fmt.Errorf("unknown gateway bind mode: %s", g.Bind) + } +} + +func (g GatewayConfig) ResolvedAddr() (string, error) { + host, err := g.ResolvedHost() + if err != nil { + return "", err + } + return fmt.Sprintf("%s:%d", host, g.Port), nil +} + +func findTailnetIPv4() (string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return "", err + } + + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + var ip net.IP + switch v := a.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil { + continue + } + ip4 := ip.To4() + if ip4 == nil { + continue + } + if ip4.IsLoopback() { + continue + } + // Support both 100.64.0.0/10 (Tailscale default) and 10.0.0.0/8 (user-requested range) + if isCIDR(ip4, "100.64.0.0/10") || isCIDR(ip4, "10.0.0.0/8") { + return ip4.String(), nil + } + } + } + + return "", fmt.Errorf("no tailnet IPv4 address found") +} + +func isCIDR(ip net.IP, cidr string) bool { + _, block, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + return block.Contains(ip) +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 9c1e36359..fb47bda98 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -205,9 +205,15 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error if v, ok := getString(gateway, "host"); ok { cfg.Gateway.Host = v } + if v, ok := getString(gateway, "bind"); ok { + cfg.Gateway.Bind = v + } if v, ok := getFloat(gateway, "port"); ok { cfg.Gateway.Port = int(v) } + if v, ok := getString(gateway, "token"); ok { + cfg.Gateway.Token = v + } } if tools, ok := getMap(data, "tools"); ok { diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..1e02ff9ac --- /dev/null +++ b/ui/README.md @@ -0,0 +1,53 @@ +# PicoClaw Web UI + +This folder contains a small browser-based chat UI for PicoClaw. + +The gateway process serves the built assets and exposes a WebSocket endpoint for chat. + +## Build + +From the repository root: + +```bash +make ui-build +``` + +Or directly: + +```bash +cd ui +npm install +npm run build +``` + +The production build output is written to `ui/dist/`. + +## Run + +Start the gateway: + +```bash +picoclaw gateway +``` + +Then open in your browser: + +- `http://:18790/` + +## Authentication (shared token) + +If `gateway.token` is set, you must provide it as a query param when opening the UI: + +- `http://:18790/?token=` + +The UI reads the `token` from the page URL and passes it to the WebSocket connection. + +## Bind mode + +The gateway listen address is controlled by `gateway.bind`: + +- `local`: binds to `127.0.0.1` +- `tailnet`: binds to the host's Tailscale address (IPv4 in `100.64.0.0/10` or `10.0.0.0/8`) +- `all`: binds to `0.0.0.0` + +See the main config example (`config/config.example.json`) for configuration keys. diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..d90de7fbb --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + PicoClaw Web Chat + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 000000000..9a5555486 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,987 @@ +{ + "name": "picoclaw-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "picoclaw-ui", + "version": "0.0.0", + "devDependencies": { + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..e606400a6 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,14 @@ +{ + "name": "picoclaw-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/ui/src/main.js b/ui/src/main.js new file mode 100644 index 000000000..e037981a8 --- /dev/null +++ b/ui/src/main.js @@ -0,0 +1,106 @@ +import './style.css' + +const app = document.querySelector('#app') + +app.innerHTML = ` +
+
+
PicoClaw
+
disconnected
+
+ +
+ +
+ + +
+
+` + +const chat = document.querySelector('#chat') +const statusEl = document.querySelector('#status') +const form = document.querySelector('#form') +const input = document.querySelector('#input') + +const chatId = 'browser' +const pageToken = new URLSearchParams(location.search).get('token') || '' + +function addMessage(role, text) { + const item = document.createElement('div') + item.className = `msg ${role}` + + const bubble = document.createElement('div') + bubble.className = 'bubble' + bubble.textContent = text + + item.appendChild(bubble) + chat.appendChild(item) + chat.scrollTop = chat.scrollHeight +} + +function wsUrl() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const tokenPart = pageToken ? `&token=${encodeURIComponent(pageToken)}` : '' + return `${proto}//${location.host}/ws?chat_id=${encodeURIComponent(chatId)}${tokenPart}` +} + +let ws +let reconnectTimer + +function setStatus(s) { + statusEl.textContent = s + statusEl.dataset.state = s +} + +function connect() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return + + setStatus('connecting') + ws = new WebSocket(wsUrl()) + + ws.addEventListener('open', () => { + setStatus('connected') + }) + + ws.addEventListener('close', () => { + setStatus('disconnected') + if (!reconnectTimer) { + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + }, 1000) + } + }) + + ws.addEventListener('message', (ev) => { + try { + const msg = JSON.parse(ev.data) + if (msg && msg.type === 'message' && typeof msg.content === 'string') { + addMessage('assistant', msg.content) + } + } catch { + } + }) +} + +form.addEventListener('submit', (e) => { + e.preventDefault() + const text = input.value.trim() + if (!text) return + input.value = '' + + addMessage('user', text) + + if (!ws || ws.readyState !== WebSocket.OPEN) { + connect() + } + + const payload = { chat_id: chatId, content: text } + try { + ws.send(JSON.stringify(payload)) + } catch { + } +}) + +connect() diff --git a/ui/src/style.css b/ui/src/style.css new file mode 100644 index 000000000..fbc439e17 --- /dev/null +++ b/ui/src/style.css @@ -0,0 +1,127 @@ +:root { + color-scheme: light dark; + --bg: #0b1020; + --panel: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.10); + --text: rgba(255, 255, 255, 0.92); + --muted: rgba(255, 255, 255, 0.60); + --accent: #7c5cff; +} + +* { box-sizing: border-box; } + +html, body { + height: 100%; + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; + background: radial-gradient(1200px 800px at 10% 10%, rgba(124, 92, 255, 0.25), transparent 60%), + radial-gradient(1000px 600px at 80% 30%, rgba(0, 209, 255, 0.14), transparent 60%), + var(--bg); + color: var(--text); +} + +.wrap { + height: 100%; + max-width: 920px; + margin: 0 auto; + padding: 18px; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 12px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); + backdrop-filter: blur(10px); +} + +.title { + font-weight: 700; + letter-spacing: 0.2px; +} + +.status { + font-size: 12px; + color: var(--muted); +} + +.status[data-state="connected"] { color: #57f287; } +.status[data-state="connecting"] { color: #faa61a; } +.status[data-state="disconnected"] { color: #ed4245; } + +.chat { + overflow: auto; + padding: 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); + backdrop-filter: blur(10px); +} + +.msg { + display: flex; + margin: 10px 0; +} + +.msg.user { justify-content: flex-end; } +.msg.assistant { justify-content: flex-start; } + +.bubble { + max-width: min(720px, 92%); + padding: 10px 12px; + border-radius: 12px; + line-height: 1.4; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.05); + white-space: pre-wrap; +} + +.msg.user .bubble { + background: rgba(124, 92, 255, 0.22); + border-color: rgba(124, 92, 255, 0.42); +} + +.composer { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); + backdrop-filter: blur(10px); +} + +.input { + width: 100%; + padding: 12px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.25); + color: var(--text); + outline: none; +} + +.input:focus { + border-color: rgba(124, 92, 255, 0.8); + box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.25); +} + +.btn { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(124, 92, 255, 0.6); + background: rgba(124, 92, 255, 0.30); + color: var(--text); + cursor: pointer; +} + +.btn:hover { + background: rgba(124, 92, 255, 0.42); +} diff --git a/ui/vite.config.js b/ui/vite.config.js new file mode 100644 index 000000000..82812cf81 --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true + }, + server: { + port: 5173, + strictPort: true + } +})