feat(web): comprehensive web UI and backend refactoring

This commit introduces a major overhaul of both the frontend web UI and the Go backend API, transitioning to a highly modular architecture and integrating new core features.
Backend:
- Refactored monolithic API endpoints into domain-specific modules (config, gateway, log, models, pico, session).
- Cleaned up obsolete files (`server.go`, `status.go`, WebSocket handlers) and outdated tests.
- Implemented Gateway process lifecycle management (start/stop/restart) and real-time log streaming.
Frontend:
- Integrated Shadcn UI components to establish a modern, consistent design system.
- Introduced a new application layout featuring a responsive sidebar (`app-sidebar`) and header.
- Implemented internationalization (i18n) with initial support for English and Chinese.
- Restructured API clients, hooks, and Zustand stores into logical domains.
- Added new management pages for Settings, Logs, Models, Providers, and Credentials.
- Upgraded the Pico chat interface with session history management and dynamic model selection.
Build & Config:
- Updated frontend dependencies, Vite configuration, and lockfiles.
- Refined routing setup and overarching application stylesheets.
This commit is contained in:
wenjie 2026-03-05 16:13:45 +08:00
parent 3c747766b7
commit 7b6725e7fd
65 changed files with 6808 additions and 337 deletions

221
web/backend/api/config.go Normal file
View file

@ -0,0 +1,221 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
}
// loadFilteredConfig loads the configuration and filters out default placeholder credentials
// (like API limits/keys) if the configuration file has not been created yet by the user.
func (h *Handler) loadFilteredConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return nil, err
}
configExists := false
if h.configPath != "" {
if _, err := os.Stat(h.configPath); err == nil {
configExists = true
}
}
if !configExists {
for i := range cfg.ModelList {
cfg.ModelList[i].APIKey = ""
cfg.ModelList[i].AuthMethod = ""
}
}
return cfg, nil
}
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(cfg); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// handleUpdateConfig updates the complete system configuration.
//
// PUT /api/config
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var cfg config.Config
if err := json.Unmarshal(body, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
// Only the fields present in the request body will be updated; all other fields remain unchanged.
//
// PATCH /api/config
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Validate the patch is valid JSON
var patch map[string]any
if err = json.Unmarshal(patchBody, &patch); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
// Load existing config and marshal to a map for merging
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
return
}
var base map[string]any
if err = json.Unmarshal(existing, &base); err != nil {
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
return
}
// Recursively merge patch into base
mergeMap(base, patch)
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
if err != nil {
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
return
}
var newCfg config.Config
if err := json.Unmarshal(merged, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
return
}
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string {
var errs []string
// Validate model_list entries
if err := cfg.ValidateModelList(); err != nil {
errs = append(errs, err.Error())
}
// Gateway port range
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
}
// Pico channel: token required when enabled
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
}
// Telegram: token required when enabled
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
}
// Discord: token required when enabled
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
return errs
}
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
// - If a key in src has a null value, it is deleted from dst.
// - If both dst and src have a nested object for the same key, merge recursively.
// - Otherwise the value from src overwrites dst.
func mergeMap(dst, src map[string]any) {
for key, srcVal := range src {
if srcVal == nil {
delete(dst, key)
continue
}
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dst[key].(map[string]any)
if srcIsMap && dstIsMap {
mergeMap(dstMap, srcMap)
} else {
dst[key] = srcVal
}
}
}

62
web/backend/api/events.go Normal file
View file

@ -0,0 +1,62 @@
package api
import (
"encoding/json"
"sync"
)
// GatewayEvent represents a state change event for the gateway process.
type GatewayEvent struct {
Status string `json:"gateway_status"` // "running", "starting", "stopped", "error"
PID int `json:"pid,omitempty"`
}
// EventBroadcaster manages SSE client subscriptions and broadcasts events.
type EventBroadcaster struct {
mu sync.RWMutex
clients map[chan string]struct{}
}
// NewEventBroadcaster creates a new broadcaster.
func NewEventBroadcaster() *EventBroadcaster {
return &EventBroadcaster{
clients: make(map[chan string]struct{}),
}
}
// Subscribe adds a new listener channel and returns it.
// The caller must call Unsubscribe when done.
func (b *EventBroadcaster) Subscribe() chan string {
ch := make(chan string, 8)
b.mu.Lock()
b.clients[ch] = struct{}{}
b.mu.Unlock()
return ch
}
// Unsubscribe removes a listener channel and closes it.
func (b *EventBroadcaster) Unsubscribe(ch chan string) {
b.mu.Lock()
delete(b.clients, ch)
b.mu.Unlock()
close(ch)
}
// Broadcast sends a GatewayEvent to all connected SSE clients.
func (b *EventBroadcaster) Broadcast(event GatewayEvent) {
data, err := json.Marshal(event)
if err != nil {
return
}
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.clients {
// Non-blocking send; drop event if client is slow
select {
case ch <- string(data):
default:
}
}
}

423
web/backend/api/gateway.go Normal file
View file

@ -0,0 +1,423 @@
package api
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync"
"syscall"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// gateway holds the state for the managed gateway process.
var gateway = struct {
mu sync.Mutex
cmd *exec.Cmd
logs *LogBuffer
events *EventBroadcaster
}{
logs: NewLogBuffer(200),
events: NewEventBroadcaster(),
}
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
}
// handleGatewayStart starts the picoclaw gateway subprocess.
//
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
// Prevent duplicate starts
if gateway.cmd != nil && gateway.cmd.Process != nil {
// Check if process is still alive (signal 0 doesn't kill, just checks)
if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]any{
"status": "already_running",
"pid": gateway.cmd.Process.Pid,
})
return
}
// Process is dead, clean up
gateway.cmd = nil
}
// Locate the picoclaw executable
execPath := findPicoclawBinary()
cmd := exec.Command(execPath, "gateway")
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create stdout pipe: %v", err), http.StatusInternalServerError)
return
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create stderr pipe: %v", err), http.StatusInternalServerError)
return
}
// Clear old logs for this new run
gateway.logs.Reset()
// Ensure Pico Channel is configured before starting gateway
if _, err := h.ensurePicoChannel(); err != nil {
log.Printf("Warning: failed to ensure pico channel: %v", err)
// Non-fatal: gateway can still start without pico channel
}
if err := cmd.Start(); err != nil {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
gateway.cmd = cmd
pid := cmd.Process.Pid
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
// Broadcast starting event
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid})
// Capture stdout/stderr in background
go scanPipe(stdoutPipe, gateway.logs)
go scanPipe(stderrPipe, gateway.logs)
// Wait for exit in background and clean up
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("Gateway process exited: %v", err)
} else {
log.Printf("Gateway process exited normally")
}
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.cmd = nil
}
gateway.mu.Unlock()
// Broadcast stopped event
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
}()
// Start a goroutine to probe health and broadcast "running" once ready
go func() {
for i := 0; i < 30; i++ { // try for up to 15 seconds
time.Sleep(500 * time.Millisecond)
gateway.mu.Lock()
stillOurs := gateway.cmd == cmd
gateway.mu.Unlock()
if !stillOurs {
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
continue
}
healthHost := "127.0.0.1"
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
healthHost = cfg.Gateway.Host
}
healthPort := cfg.Gateway.Port
if healthPort == 0 {
healthPort = 18790
}
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
client := http.Client{Timeout: 1 * time.Second}
resp, err := client.Get(healthURL)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid})
return
}
}
}
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": cmd.Process.Pid,
})
}
// handleGatewayStop stops the running gateway subprocess gracefully.
//
// POST /api/gateway/stop
func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if gateway.cmd == nil || gateway.cmd.Process == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "not_running",
})
return
}
pid := gateway.cmd.Process.Pid
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
if runtime.GOOS == "windows" {
sigErr = gateway.cmd.Process.Kill()
} else {
sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM)
}
if sigErr != nil {
http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, sigErr), http.StatusInternalServerError)
return
}
log.Printf("Sent stop signal to gateway (PID: %d)", pid)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": pid,
})
}
// handleGatewayRestart stops the gateway (if running) and starts a new instance.
//
// POST /api/gateway/restart
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
// Stop existing process if running
if gateway.cmd != nil && gateway.cmd.Process != nil {
if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil {
// Process is alive, send SIGTERM
if runtime.GOOS == "windows" {
gateway.cmd.Process.Kill()
} else {
gateway.cmd.Process.Signal(syscall.SIGTERM)
}
// Wait briefly for it to exit
gateway.mu.Unlock()
time.Sleep(2 * time.Second)
gateway.mu.Lock()
}
gateway.cmd = nil
}
gateway.mu.Unlock()
// Start fresh via the existing handler
h.handleGatewayStart(w, r)
}
// handleGatewayStatus returns the gateway run status, health info, and logs.
//
// GET /api/gateway/status
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
data := map[string]any{}
// Check process state
gateway.mu.Lock()
processAlive := false
if gateway.cmd != nil && gateway.cmd.Process != nil {
if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil {
processAlive = true
data["pid"] = gateway.cmd.Process.Pid
}
}
gateway.mu.Unlock()
if !processAlive {
data["gateway_status"] = "stopped"
} else {
// Process is alive — probe its health endpoint
cfg, err := config.LoadConfig(h.configPath)
host := "127.0.0.1"
port := 18790
if err == nil && cfg != nil {
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
host = cfg.Gateway.Host
}
if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port
}
}
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
client := http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
if err != nil {
data["gateway_status"] = "starting"
} else {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data["gateway_status"] = "error"
data["status_code"] = resp.StatusCode
} else {
var healthData map[string]any
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
data["gateway_status"] = "error"
} else {
for k, v := range healthData {
data[k] = v
}
data["gateway_status"] = "running"
}
}
}
}
// Append incremental log data
appendGatewayLogs(r, data)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// appendGatewayLogs reads log_offset and log_run_id query params from the request
// and populates the response data map with incremental log lines.
func appendGatewayLogs(r *http.Request, data map[string]any) {
clientOffset := 0
clientRunID := -1
if v := r.URL.Query().Get("log_offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientOffset = n
}
}
if v := r.URL.Query().Get("log_run_id"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientRunID = n
}
}
runID := gateway.logs.RunID()
if runID == 0 {
data["logs"] = []string{}
data["log_total"] = 0
data["log_run_id"] = 0
return
}
// If runID changed, reset offset to get all logs from new run
offset := clientOffset
if clientRunID != runID {
offset = 0
}
lines, total, runID := gateway.logs.LinesSince(offset)
if lines == nil {
lines = []string{}
}
data["logs"] = lines
data["log_total"] = total
data["log_run_id"] = runID
}
// handleGatewayEvents serves an SSE stream of gateway state change events.
//
// GET /api/gateway/events
func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Subscribe to gateway events
ch := gateway.events.Subscribe()
defer gateway.events.Unsubscribe(ch)
// Send initial status so the client doesn't start blank
initial := h.currentGatewayStatus()
fmt.Fprintf(w, "data: %s\n\n", initial)
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case data, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
// currentGatewayStatus returns the current gateway status as a JSON string.
func (h *Handler) currentGatewayStatus() string {
gateway.mu.Lock()
defer gateway.mu.Unlock()
event := GatewayEvent{Status: "stopped"}
if gateway.cmd != nil && gateway.cmd.Process != nil {
if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil {
event.Status = "running"
event.PID = gateway.cmd.Process.Pid
}
}
data, _ := json.Marshal(event)
return string(data)
}
// findPicoclawBinary locates the picoclaw executable.
// Tries the same directory as the current executable first, then falls back to $PATH.
func findPicoclawBinary() string {
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidate := filepath.Join(dir, "picoclaw")
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return "picoclaw"
}
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
func scanPipe(r io.Reader, buf *LogBuffer) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
buf.Append(scanner.Text())
}
}

91
web/backend/api/log.go Normal file
View file

@ -0,0 +1,91 @@
package api
import "sync"
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
// It supports incremental reads via LinesSince and tracks a runID that increments
// on each Reset (used to detect gateway restarts).
type LogBuffer struct {
mu sync.RWMutex
lines []string
cap int
total int // total lines ever appended in current run
runID int
}
// NewLogBuffer creates a LogBuffer with the given capacity.
func NewLogBuffer(capacity int) *LogBuffer {
return &LogBuffer{
lines: make([]string, 0, capacity),
cap: capacity,
}
}
// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.
func (b *LogBuffer) Append(line string) {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.lines) < b.cap {
b.lines = append(b.lines, line)
} else {
b.lines[b.total%b.cap] = line
}
b.total++
}
// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.
func (b *LogBuffer) Reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.lines = b.lines[:0]
b.total = 0
b.runID++
}
// LinesSince returns lines appended after the given offset, the current total count, and the runID.
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
b.mu.RLock()
defer b.mu.RUnlock()
total = b.total
runID = b.runID
if offset >= b.total {
return nil, total, runID
}
buffered := len(b.lines)
// How many new lines since offset
newCount := b.total - offset
if newCount > buffered {
newCount = buffered
}
result := make([]string, newCount)
if b.total <= b.cap {
// Buffer hasn't wrapped yet — simple slice
copy(result, b.lines[buffered-newCount:])
} else {
// Buffer has wrapped — read from ring
start := (b.total - newCount) % b.cap
for i := range newCount {
result[i] = b.lines[(start+i)%b.cap]
}
}
return result, total, runID
}
// RunID returns the current run identifier.
func (b *LogBuffer) RunID() int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.runID
}

264
web/backend/api/models.go Normal file
View file

@ -0,0 +1,264 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerModelRoutes binds model list management endpoints to the ServeMux.
func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/models", h.handleListModels)
mux.HandleFunc("POST /api/models", h.handleAddModel)
mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel)
mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel)
mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel)
}
// modelResponse is the JSON structure returned for each model in the list.
type modelResponse struct {
Index int `json:"index"`
ModelName string `json:"model_name"`
Model string `json:"model"`
APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key"`
Proxy string `json:"proxy,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
Configured bool `json:"configured"`
IsDefault bool `json:"is_default"`
}
// handleListModels returns all model_list entries with masked API keys.
//
// GET /api/models
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
defaultModel := cfg.Agents.Defaults.GetModelName()
models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList {
models = append(models, modelResponse{
Index: i,
ModelName: m.ModelName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: maskAPIKey(m.APIKey),
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
Configured: m.APIKey != "" || m.AuthMethod != "",
IsDefault: m.ModelName == defaultModel,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"models": models,
"total": len(models),
"default_model": defaultModel,
})
}
// handleAddModel appends a new model configuration entry.
//
// POST /api/models
func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var mc config.ModelConfig
if err = json.Unmarshal(body, &mc); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err = mc.Validate(); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
cfg.ModelList = append(cfg.ModelList, mc)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"index": len(cfg.ModelList) - 1,
})
}
// handleUpdateModel replaces a model configuration entry at the given index.
//
// PUT /api/models/{index}
func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var mc config.ModelConfig
if err = json.Unmarshal(body, &mc); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err = mc.Validate(); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
if idx < 0 || idx >= len(cfg.ModelList) {
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
return
}
cfg.ModelList[idx] = mc
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleDeleteModel removes a model configuration entry at the given index.
//
// DELETE /api/models/{index}
func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
if idx < 0 || idx >= len(cfg.ModelList) {
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
return
}
cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleSetDefaultModel sets the default model for all agents.
//
// POST /api/models/default
func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
ModelName string `json:"model_name"`
}
if err = json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if req.ModelName == "" {
http.Error(w, "model_name is required", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
// Verify the model_name exists in model_list
found := false
for _, m := range cfg.ModelList {
if m.ModelName == req.ModelName {
found = true
break
}
}
if !found {
http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound)
return
}
cfg.Agents.Defaults.ModelName = req.ModelName
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"default_model": req.ModelName,
})
}
// maskAPIKey returns a masked version of an API key for safe display.
// Keys longer than 8 chars show prefix + last 4 chars: "sk-****abcd"
// Shorter keys are fully masked as "****".
// Empty keys return empty string.
func maskAPIKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 8 {
return "****"
}
// Show first 3 chars and last 4 chars
return key[:3] + "****" + key[len(key)-4:]
}

161
web/backend/api/pico.go Normal file
View file

@ -0,0 +1,161 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken)
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
}
// handleGetPicoToken returns the current WS token and URL for the frontend.
//
// GET /api/pico/token
func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
wsURL := buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token,
"ws_url": wsURL,
"enabled": cfg.Channels.Pico.Enabled,
})
}
// handleRegenPicoToken generates a new Pico WebSocket token and saves it.
//
// POST /api/pico/token
func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
token := generateSecureToken()
cfg.Channels.Pico.Token = token
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
wsURL := fmt.Sprintf("ws://%s/pico/ws", net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": token,
"ws_url": wsURL,
})
}
// ensurePicoChannel checks if the Pico Channel is properly configured and
// enables it with sensible defaults if not. Returns true if config was changed.
func (h *Handler) ensurePicoChannel() (bool, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false, fmt.Errorf("failed to load config: %w", err)
}
changed := false
if !cfg.Channels.Pico.Enabled {
cfg.Channels.Pico.Enabled = true
changed = true
}
if cfg.Channels.Pico.Token == "" {
cfg.Channels.Pico.Token = generateSecureToken()
changed = true
}
if !cfg.Channels.Pico.AllowTokenQuery {
cfg.Channels.Pico.AllowTokenQuery = true
changed = true
}
// Make sure origins are allowed (frontend might be running on a different port like 5173 during dev)
if len(cfg.Channels.Pico.AllowOrigins) == 0 {
cfg.Channels.Pico.AllowOrigins = []string{"*"}
changed = true
}
if changed {
if err := config.SaveConfig(h.configPath, cfg); err != nil {
return false, fmt.Errorf("failed to save config: %w", err)
}
}
return changed, nil
}
// handlePicoSetup automatically configures everything needed for the Pico Channel to work.
//
// POST /api/pico/setup
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
changed, err := h.ensurePicoChannel()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
wsURL := buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token,
"ws_url": wsURL,
"enabled": true,
"changed": changed,
})
}
// buildWsURL creates a WebSocket URL for the Pico Channel.
// When the gateway host is "0.0.0.0" or empty, it uses the hostname from the
// incoming HTTP request so the browser gets a connectable address.
func buildWsURL(r *http.Request, cfg *config.Config) string {
host := cfg.Gateway.Host
if host == "" || host == "0.0.0.0" {
// Use the hostname the browser used to reach this backend
reqHost, _, err := net.SplitHostPort(r.Host)
if err != nil {
reqHost = r.Host // r.Host might not have a port
}
host = reqHost
}
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
}
// generateSecureToken creates a random 32-character hex string.
func generateSecureToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback to something pseudo-random if crypto/rand fails
return fmt.Sprintf("pico_%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}

View file

@ -3,15 +3,31 @@ package api
import "net/http"
// Handler serves HTTP API requests.
type Handler struct{}
type Handler struct {
configPath string
}
// NewHandler creates an instance of the API handler.
func NewHandler() *Handler {
return &Handler{}
func NewHandler(configPath string) *Handler {
return &Handler{
configPath: configPath,
}
}
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
// All routes are registered under the /api/ prefix.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/status", h.handleStatus)
// Config CRUD
h.registerConfigRoutes(mux)
// Pico Channel (WebSocket chat)
h.registerPicoRoutes(mux)
// Gateway process lifecycle
h.registerGatewayRoutes(mux)
// Session history
h.registerSessionRoutes(mux)
// Model list management
h.registerModelRoutes(mux)
}

View file

@ -1,22 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRegisterRoutes(t *testing.T) {
handler := NewHandler()
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// Verify that registered routes respond correctly
req := httptest.NewRequest("GET", "/api/status", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("RegisterRoutes: /api/status returned status %d, want %d", status, http.StatusOK)
}
}

286
web/backend/api/session.go Normal file
View file

@ -0,0 +1,286 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
func (h *Handler) registerSessionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/sessions", h.handleListSessions)
mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession)
mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession)
}
// sessionFile mirrors the on-disk session JSON structure from pkg/session.
type sessionFile struct {
Key string `json:"key"`
Messages []providers.Message `json:"messages"`
Summary string `json:"summary,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// sessionListItem is a lightweight summary returned by GET /api/sessions.
type sessionListItem struct {
ID string `json:"id"`
Preview string `json:"preview"`
MessageCount int `json:"message_count"`
Created string `json:"created"`
Updated string `json:"updated"`
}
// picoSessionPrefix is the key prefix used by the gateway's routing for Pico
// channel sessions. The full key format is:
//
// agent:main:pico:direct:pico:<session-uuid>
//
// The sanitized filename replaces ':' with '_', so on disk it becomes:
//
// agent_main_pico_direct_pico_<session-uuid>.json
const picoSessionPrefix = "agent:main:pico:direct:pico:"
// extractPicoSessionID extracts the session UUID from a full session key.
// Returns the UUID and true if the key matches the Pico session pattern.
func extractPicoSessionID(key string) (string, bool) {
if strings.HasPrefix(key, picoSessionPrefix) {
return strings.TrimPrefix(key, picoSessionPrefix), true
}
return "", false
}
// sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return "", err
}
workspace := cfg.Agents.Defaults.Workspace
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
}
// Expand ~ prefix
if len(workspace) > 0 && workspace[0] == '~' {
home, _ := os.UserHomeDir()
if len(workspace) > 1 && workspace[1] == '/' {
workspace = home + workspace[1:]
} else {
workspace = home
}
}
return filepath.Join(workspace, "sessions"), nil
}
// handleListSessions returns a list of Pico session summaries.
//
// GET /api/sessions
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
dir, err := h.sessionsDir()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(dir)
if err != nil {
// Directory doesn't exist yet = no sessions
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]sessionListItem{})
return
}
items := []sessionListItem{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
var sess sessionFile
if err := json.Unmarshal(data, &sess); err != nil {
continue
}
// Only include Pico channel sessions
sessionID, ok := extractPicoSessionID(sess.Key)
if !ok {
continue
}
// Build a preview from the first user message
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
preview = msg.Content
break
}
}
if len([]rune(preview)) > 60 {
preview = string([]rune(preview)[:60]) + "..."
}
if preview == "" {
preview = "(empty)"
}
// Only count non-empty user and assistant messages
validMessageCount := 0
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
validMessageCount++
}
}
items = append(items, sessionListItem{
ID: sessionID,
Preview: preview,
MessageCount: validMessageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
})
}
// Sort by updated descending (most recent first)
sort.Slice(items, func(i, j int) bool {
return items[i].Updated > items[j].Updated
})
// Pagination parameters
offsetStr := r.URL.Query().Get("offset")
limitStr := r.URL.Query().Get("limit")
offset := 0
limit := 20 // Default limit
if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 {
offset = val
}
if val, err := strconv.Atoi(limitStr); err == nil && val > 0 {
limit = val
}
totalItems := len(items)
end := offset + limit
if offset >= totalItems {
items = []sessionListItem{} // Out of bounds, return empty
} else {
if end > totalItems {
end = totalItems
}
items = items[offset:end]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}
// handleGetSession returns the full message history for a specific session.
//
// GET /api/sessions/{id}
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
sessionID := r.PathValue("id")
if sessionID == "" {
http.Error(w, "missing session id", http.StatusBadRequest)
return
}
dir, err := h.sessionsDir()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
// The sanitized filename replaces ':' with '_':
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
data, err := os.ReadFile(filepath.Join(dir, filename))
if err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
var sess sessionFile
if err := json.Unmarshal(data, &sess); err != nil {
http.Error(w, "failed to parse session", http.StatusInternalServerError)
return
}
// Convert to a simpler format for the frontend
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
messages := make([]chatMessage, 0, len(sess.Messages))
for _, msg := range sess.Messages {
// Only include user and assistant messages that have actual content
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messages = append(messages, chatMessage{
Role: msg.Role,
Content: msg.Content,
})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": sessionID,
"messages": messages,
"summary": sess.Summary,
"created": sess.Created.Format(time.RFC3339),
"updated": sess.Updated.Format(time.RFC3339),
})
}
// handleDeleteSession deletes a specific session.
//
// DELETE /api/sessions/{id}
func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
sessionID := r.PathValue("id")
if sessionID == "" {
http.Error(w, "missing session id", http.StatusBadRequest)
return
}
dir, err := h.sessionsDir()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
}
// The sanitized filename replaces ':' with '_':
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
filePath := filepath.Join(dir, filename)
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
http.Error(w, "session not found", http.StatusNotFound)
} else {
http.Error(w, "failed to delete session", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -1,33 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/sipeed/picoclaw/web/backend/model"
)
// startTime records when the server was started, used to calculate uptime.
var startTime = time.Now()
// Version is set at build time via -ldflags.
var Version = "dev"
// handleStatus returns the current server status, version, and uptime.
//
// GET /api/status
// Response: 200 OK
// {
// "status": "online",
// "version": "dev",
// "uptime": "2h30m15s"
// }
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
resp := model.StatusResponse{
Status: "online",
Version: Version,
Uptime: time.Since(startTime).Round(time.Second).String(),
}
json.NewEncoder(w).Encode(resp)
}

View file

@ -1,37 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/sipeed/picoclaw/web/backend/model"
)
func TestHandleStatus(t *testing.T) {
handler := NewHandler()
req := httptest.NewRequest("GET", "/api/status", nil)
rr := httptest.NewRecorder()
handler.handleStatus(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handleStatus returned status %d, want %d", status, http.StatusOK)
}
var resp model.StatusResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to decode response JSON: %v", err)
}
if resp.Status != "online" {
t.Errorf("Expected status 'online', got %q", resp.Status)
}
if resp.Version == "" {
t.Error("Expected non-empty version")
}
if resp.Uptime == "" {
t.Error("Expected non-empty uptime")
}
}

View file

@ -1,3 +0,0 @@
module github.com/sipeed/picoclaw/web/backend
go 1.25.7

View file

@ -1,23 +1,114 @@
// PicoClaw Web Console - Web-based chat and management interface
//
// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket,
// with configuration management and gateway process control.
//
// Usage:
//
// go build -o picoclaw-web ./web/backend/
// ./picoclaw-web [config.json]
// ./picoclaw-web -public config.json
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/ws"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func main() {
log.Println("Starting picoclaw Web Console...")
port := flag.String("port", "18800", "Port to listen on")
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Arguments:\n")
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
fmt.Fprintf(
os.Stderr,
" %s -public ./config.json Allow access from other devices on the network\n",
os.Args[0],
)
}
flag.Parse()
// Resolve config path
configPath := getDefaultConfigPath()
if flag.NArg() > 0 {
configPath = flag.Arg(0)
}
absPath, err := filepath.Abs(configPath)
if err != nil {
log.Fatalf("Failed to resolve config path: %v", err)
}
// Determine listen address
var addr string
if *public {
addr = "0.0.0.0:" + *port
} else {
addr = "127.0.0.1:" + *port
}
// Initialize Server components
srv := NewServer(
api.NewHandler(),
ws.NewHandler(),
mux := http.NewServeMux()
// API Routes (e.g. /api/status)
apiHandler := api.NewHandler(absPath)
apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
registerEmbedRoutes(mux)
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(mux),
),
)
// Print startup banner
fmt.Print(banner)
fmt.Println()
fmt.Println(" Open the following URL in your browser:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", *port)
if *public {
if ip := getLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, *port)
}
}
fmt.Println()
// Auto-open browser
if !*noBrowser {
go func() {
time.Sleep(500 * time.Millisecond)
url := "http://localhost:" + *port
if err := openBrowser(url); err != nil {
log.Printf("Warning: Failed to auto-open browser: %v", err)
}
}()
}
// Start the Server
if err := srv.Start(":8080"); err != nil {
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

View file

@ -4,14 +4,18 @@ import (
"log"
"net/http"
"runtime/debug"
"strings"
"time"
)
// JSONContentType sets the Content-Type header to application/json for all
// requests handled by the wrapped handler.
// JSONContentType sets the Content-Type header to application/json for
// API requests handled by the wrapped handler.
// SSE endpoints (text/event-stream) are excluded.
func JSONContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasSuffix(r.URL.Path, "/events") {
w.Header().Set("Content-Type", "application/json")
}
next.ServeHTTP(w, r)
})
}
@ -27,6 +31,20 @@ func (rr *responseRecorder) WriteHeader(code int) {
rr.ResponseWriter.WriteHeader(code)
}
// Flush delegates to the underlying ResponseWriter if it implements http.Flusher.
// This is required for SSE (Server-Sent Events) to work through the middleware.
func (rr *responseRecorder) Flush() {
if f, ok := rr.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap returns the underlying ResponseWriter so that http.ResponseController
// and interface checks (like http.Flusher) can see through the wrapper.
func (rr *responseRecorder) Unwrap() http.ResponseWriter {
return rr.ResponseWriter
}
// Logger logs each HTTP request with method, path, status code, and duration.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -1,48 +0,0 @@
package main
import (
"fmt"
"net/http"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/ws"
)
// Server holds the components necessary to run the web UI backend.
type Server struct {
apiHandler *api.Handler
wsHandler *ws.Handler
}
// NewServer initializes a new Server instance.
func NewServer(apiHandler *api.Handler, wsHandler *ws.Handler) *Server {
return &Server{
apiHandler: apiHandler,
wsHandler: wsHandler,
}
}
// Start attaches the routes and begins listening on the specified address.
func (s *Server) Start(addr string) error {
mux := http.NewServeMux()
// API Routes (e.g. /api/status)
s.apiHandler.RegisterRoutes(mux)
// WebSocket Routes
s.wsHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
registerEmbedRoutes(mux)
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(mux),
),
)
fmt.Printf("WebUI listening on %s\n", addr)
return http.ListenAndServe(addr, handler)
}

View file

@ -1,37 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/ws"
)
func TestNewServer(t *testing.T) {
apiHandler := api.NewHandler()
wsHandler := ws.NewHandler()
srv := NewServer(apiHandler, wsHandler)
if srv == nil {
t.Fatal("Expected NewServer to return a valid instance, got nil")
}
if srv.apiHandler == nil || srv.wsHandler == nil {
t.Error("Not all server components were correctly initialized")
}
}
func TestEmbedRoutes(t *testing.T) {
mux := http.NewServeMux()
registerEmbedRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
}

61
web/backend/utils.go Normal file
View file

@ -0,0 +1,61 @@
package main
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
)
const (
colorBlue = "\x1b[38;2;62;93;185m"
colorRed = "\x1b[38;2;213;70;70m"
colorReset = "\x1b[0m"
banner = "\r\n" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" +
colorReset
)
// getDefaultConfigPath returns the default path to the picoclaw config file.
func getDefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".picoclaw", "config.json")
}
// getLocalIP returns the local IP address of the machine.
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
return ""
}
// openBrowser automatically opens the given URL in the default browser.
func openBrowser(url string) error {
switch runtime.GOOS {
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
default:
return fmt.Errorf("unsupported platform")
}
}

View file

@ -1,23 +0,0 @@
package ws
import (
"fmt"
"net/http"
)
// Handler serves WebSocket requests.
type Handler struct{}
// NewHandler creates an instance of the WebSocket handler.
func NewHandler() *Handler {
return &Handler{}
}
// RegisterRoutes binds the WebSocket routes to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/ws/chat", h.handleWebSocket)
}
func (h *Handler) handleWebSocket(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "WebSocket chat functionality placeholder")
}

View file

@ -1,25 +0,0 @@
package ws
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandleWebSocket(t *testing.T) {
handler := NewHandler()
req := httptest.NewRequest("GET", "/ws/chat", nil)
rr := httptest.NewRecorder()
handler.handleWebSocket(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handleWebSocket returned status %d, want %d", status, http.StatusOK)
}
body := rr.Body.String()
if !strings.Contains(body, "WebSocket chat functionality placeholder") {
t.Errorf("Response body did not contain placeholder text, got: %s", body)
}
}

View file

@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.tanstack

View file

@ -20,9 +20,17 @@
"@tanstack/react-router-devtools": "^1.163.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.18.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"shadcn": "^3.8.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
@ -30,6 +38,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^24.10.1",

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View file

@ -0,0 +1,60 @@
// API client for gateway process management.
interface GatewayStatusResponse {
gateway_status: "running" | "starting" | "stopped" | "error"
pid?: number
logs?: string[]
log_total?: number
log_run_id?: number
[key: string]: unknown
}
interface GatewayActionResponse {
status: string
pid?: number
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getGatewayStatus(options?: {
log_offset?: number
log_run_id?: number
}): Promise<GatewayStatusResponse> {
const params = new URLSearchParams()
if (options?.log_offset !== undefined) {
params.set("log_offset", options.log_offset.toString())
}
if (options?.log_run_id !== undefined) {
params.set("log_run_id", options.log_run_id.toString())
}
const queryString = params.toString() ? `?${params.toString()}` : ""
return request<GatewayStatusResponse>(`/api/gateway/status${queryString}`)
}
export async function startGateway(): Promise<GatewayActionResponse> {
return request<GatewayActionResponse>("/api/gateway/start", {
method: "POST",
})
}
export async function stopGateway(): Promise<GatewayActionResponse> {
return request<GatewayActionResponse>("/api/gateway/stop", {
method: "POST",
})
}
export async function restartGateway(): Promise<GatewayActionResponse> {
return request<GatewayActionResponse>("/api/gateway/restart", {
method: "POST",
})
}
export type { GatewayStatusResponse, GatewayActionResponse }

View file

@ -0,0 +1,78 @@
// API client for model list management.
export interface ModelInfo {
index: number
model_name: string
model: string
api_base?: string
api_key: string
proxy?: string
auth_method?: string
configured: boolean
is_default: boolean
}
interface ModelsListResponse {
models: ModelInfo[]
total: number
default_model: string
}
interface ModelActionResponse {
status: string
index?: number
default_model?: string
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getModels(): Promise<ModelsListResponse> {
return request<ModelsListResponse>("/api/models")
}
export async function addModel(
model: Partial<ModelInfo>,
): Promise<ModelActionResponse> {
return request<ModelActionResponse>("/api/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(model),
})
}
export async function updateModel(
index: number,
model: Partial<ModelInfo>,
): Promise<ModelActionResponse> {
return request<ModelActionResponse>(`/api/models/${index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(model),
})
}
export async function deleteModel(index: number): Promise<ModelActionResponse> {
return request<ModelActionResponse>(`/api/models/${index}`, {
method: "DELETE",
})
}
export async function setDefaultModel(
modelName: string,
): Promise<ModelActionResponse> {
return request<ModelActionResponse>("/api/models/default", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName }),
})
}
export type { ModelsListResponse, ModelActionResponse }

View file

@ -0,0 +1,38 @@
// API client for Pico Channel configuration.
interface PicoTokenResponse {
token: string
ws_url: string
enabled: boolean
}
interface PicoSetupResponse {
token: string
ws_url: string
enabled: boolean
changed: boolean
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getPicoToken(): Promise<PicoTokenResponse> {
return request<PicoTokenResponse>("/api/pico/token")
}
export async function regenPicoToken(): Promise<PicoTokenResponse> {
return request<PicoTokenResponse>("/api/pico/token", { method: "POST" })
}
export async function setupPico(): Promise<PicoSetupResponse> {
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
}
export type { PicoTokenResponse, PicoSetupResponse }

View file

@ -0,0 +1,50 @@
// Sessions API — list and retrieve chat session history
export interface SessionSummary {
id: string
preview: string
message_count: number
created: string
updated: string
}
export interface SessionDetail {
id: string
messages: { role: "user" | "assistant"; content: string }[]
summary: string
created: string
updated: string
}
export async function getSessions(
offset: number = 0,
limit: number = 20,
): Promise<SessionSummary[]> {
const params = new URLSearchParams({
offset: offset.toString(),
limit: limit.toString(),
})
const res = await fetch(`/api/sessions?${params.toString()}`)
if (!res.ok) {
throw new Error(`Failed to fetch sessions: ${res.status}`)
}
return res.json()
}
export async function getSessionHistory(id: string): Promise<SessionDetail> {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
if (!res.ok) {
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
}
return res.json()
}
export async function deleteSession(id: string): Promise<void> {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
})
if (!res.ok) {
throw new Error(`Failed to delete session ${id}: ${res.status}`)
}
}

View file

@ -1,21 +0,0 @@
// API client for the picoclaw web backend.
const BASE_URL = ""
interface StatusResponse {
status: string
version: string
uptime: string
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getStatus(): Promise<StatusResponse> {
return request<StatusResponse>("/api/status")
}

View file

@ -0,0 +1,199 @@
import {
IconBook,
IconLanguage,
IconLoader2,
IconMenu2,
IconMoon,
IconPlayerPlay,
IconPower,
IconSun,
} from "@tabler/icons-react"
import { Link } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts"
export function AppHeader() {
const { i18n, t } = useTranslation()
const { theme, toggleTheme } = useTheme()
const {
state: gwState,
loading: gwLoading,
isInitialized,
start,
stop,
} = useGateway()
const isRunning = gwState === "running"
const isStarting = gwState === "starting"
const isStopped = gwState === "stopped" || gwState === "unknown"
const [showStopDialog, setShowStopDialog] = React.useState(false)
const handleGatewayToggle = () => {
if (gwLoading) return
if (isRunning) {
setShowStopDialog(true)
} else {
start()
}
}
const confirmStop = () => {
setShowStopDialog(false)
stop()
}
return (
<header className="bg-background/95 supports-backdrop-filter:bg-background/60 border-b-border/50 sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between border-b px-4 backdrop-blur">
<div className="flex items-center gap-2">
<SidebarTrigger className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-9 w-9 items-center justify-center rounded-lg sm:hidden [&>svg]:size-5">
<IconMenu2 />
</SidebarTrigger>
<div className="hidden w-36 shrink-0 items-center sm:flex">
<Link to="/">
<img className="w-full" src="/logo_with_text.png" alt="Logo" />
</Link>
</div>
</div>
{/* Center prominent connection status */}
<div className="pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex">
{isInitialized && !isRunning && !isStarting && (
<div className="text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md">
<span className="bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full">
<span className="bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75"></span>
</span>
{t(
"chat.notConnected",
"Gateway is not running. Start it to chat.",
)}
</div>
)}
</div>
<AlertDialog open={showStopDialog} onOpenChange={setShowStopDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("header.gateway.stopDialog.title", "Stop Gateway Service?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"header.gateway.stopDialog.description",
"Are you sure you want to stop the gateway? This will disconnect your active chat sessions and halt inference.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmStop}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("header.gateway.stopDialog.confirm", "Stop Gateway")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
{/* Gateway Start/Stop */}
<Button
variant={isStarting ? "secondary" : "default"}
size="sm"
className={`h-8 gap-2 px-3 ${
isRunning
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
: isStopped
? "bg-green-500 text-white hover:bg-green-600"
: ""
}`}
onClick={handleGatewayToggle}
disabled={gwLoading || isStarting}
>
{gwLoading || isStarting ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : isRunning ? (
<IconPower className="h-4 w-4 opacity-80" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isRunning
? t("header.gateway.action.stop", "Stop Gateway")
: isStarting
? t("header.gateway.status.starting", "Starting Gateway...")
: t("header.gateway.action.start", "Start Gateway")}
</span>
</Button>
<Separator
className="mx-4 my-2 hidden md:block"
orientation="vertical"
/>
{/* Docs Link */}
<Button variant="ghost" size="icon" className="size-8" asChild>
<a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer">
<IconBook className="size-4.5" />
</a>
</Button>
{/* Language Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<IconLanguage className="size-4.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleTheme}
>
{theme === "dark" ? (
<IconSun className="size-4.5" />
) : (
<IconMoon className="size-4.5" />
)}
</Button>
</div>
</header>
)
}

View file

@ -0,0 +1,25 @@
import type { ReactNode } from "react"
import { AppHeader } from "@/components/app-header"
import { AppSidebar } from "@/components/app-sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { TooltipProvider } from "@/components/ui/tooltip"
export function AppLayout({ children }: { children: ReactNode }) {
return (
<TooltipProvider>
<SidebarProvider className="flex h-dvh flex-col overflow-hidden">
<AppHeader />
<div className="flex flex-1 overflow-hidden">
<AppSidebar />
<div className="flex w-full flex-col overflow-hidden">
<main className="flex min-h-0 w-full max-w-full flex-1 flex-col overflow-hidden">
{children}
</main>
</div>
</div>
</SidebarProvider>
</TooltipProvider>
)
}

View file

@ -0,0 +1,119 @@
import { IconChevronRight } from "@tabler/icons-react"
import {
IconCloud,
IconCpu,
IconKey,
IconListDetails,
IconMessageCircle,
IconSettings,
} from "@tabler/icons-react"
import { Link, useRouterState } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
// Navigation data with real routes
const navGroups = [
{
label: "navigation.chat",
defaultOpen: true,
items: [{ title: "navigation.chat", url: "/", icon: IconMessageCircle }],
},
{
label: "navigation.model_group",
defaultOpen: true,
items: [
{ title: "navigation.providers", url: "/providers", icon: IconCloud },
{ title: "navigation.models", url: "/models", icon: IconCpu },
{ title: "navigation.credentials", url: "/credentials", icon: IconKey },
],
},
{
label: "navigation.service",
defaultOpen: true,
items: [
{ title: "navigation.config", url: "/config", icon: IconSettings },
{ title: "navigation.logs", url: "/logs", icon: IconListDetails },
],
},
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const routerState = useRouterState()
const { t } = useTranslation()
const currentPath = routerState.location.pathname
return (
<Sidebar
{...props}
className="bg-background border-r-border/20 border-r pt-3"
>
<SidebarContent className="bg-background">
{navGroups.map((group) => (
<Collapsible
key={group.label}
defaultOpen={group.defaultOpen}
className="group/collapsible mb-1"
>
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="hover:bg-muted/60 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 transition-colors">
<span>{t(group.label)}</span>
<IconChevronRight className="size-3.5 opacity-50 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pt-1">
<SidebarMenu>
{group.items.map((item) => {
const isActive = currentPath === item.url
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isActive}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
>
<Link to={item.url}>
<item.icon
className={`size-4 ${isActive ? "opacity-100" : "opacity-60"}`}
/>
<span
className={
isActive ? "opacity-100" : "opacity-80"
}
>
{t(item.title)}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}

View file

@ -0,0 +1,27 @@
import { IconMenu2 } from "@tabler/icons-react"
import type { ReactNode } from "react"
import { SidebarTrigger } from "@/components/ui/sidebar"
interface PageHeaderProps {
title: string
titleExtra?: ReactNode
children?: ReactNode
}
export function PageHeader({ title, titleExtra, children }: PageHeaderProps) {
return (
<div className="flex h-14 shrink-0 items-center justify-between px-6 pt-2">
<div className="flex items-center gap-4">
<SidebarTrigger className="border-border/60 bg-background text-muted-foreground hover:bg-accent hover:text-foreground hidden h-9 w-9 rounded-lg border sm:flex [&>svg]:size-5">
<IconMenu2 />
</SidebarTrigger>
<h2 className="text-foreground/90 text-xl font-medium tracking-tight">
{title}
</h2>
{titleExtra}
</div>
{children && <div className="flex items-center gap-2">{children}</div>}
</div>
)
}

View file

@ -0,0 +1,197 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View file

@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconCheck, IconChevronRight } from "@tabler/icons-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View file

@ -0,0 +1,190 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<IconSelector className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<IconChevronUp
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<IconChevronDown
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<IconX
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -0,0 +1,700 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { IconLayoutSidebar } from "@tabler/icons-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<IconLayoutSidebar />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View file

@ -1,23 +0,0 @@
import { useCallback, useState } from "react"
import { getStatus } from "@/api/status"
export function useApiStatus() {
const [status, setStatus] = useState<string>("Unknown")
const [loading, setLoading] = useState(false)
const check = useCallback(async () => {
setLoading(true)
try {
const data = await getStatus()
setStatus(data.status || "Success")
} catch (err) {
setStatus("Fetch failed")
console.error(err)
} finally {
setLoading(false)
}
}, [])
return { status, loading, check }
}

View file

@ -0,0 +1,83 @@
import { useAtom } from "jotai"
import { useCallback, useEffect, useState } from "react"
import { getGatewayStatus, startGateway, stopGateway } from "@/api/gateway"
import { gatewayAtom } from "@/store"
// Global variable to ensure we only have one SSE connection
let sseInitialized = false
export function useGateway() {
const [{ status: state, isInitialized }, setGateway] = useAtom(gatewayAtom)
const [loading, setLoading] = useState(false)
// Initialize global SSE connection once
useEffect(() => {
if (sseInitialized) return
sseInitialized = true
getGatewayStatus()
.then((data) => {
setGateway({
status: data.gateway_status ?? "unknown",
isInitialized: true,
})
})
.catch(() => {
setGateway({
status: "unknown",
isInitialized: true,
})
})
// Subscribe to SSE for real-time updates globally
const es = new EventSource("/api/gateway/events")
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.gateway_status) {
setGateway((prev) => ({ ...prev, status: data.gateway_status }))
}
} catch {
// ignore
}
}
es.onerror = () => {
// EventSource will auto-reconnect
setGateway((prev) => ({ ...prev, status: "unknown" }))
}
return () => {
es.close()
sseInitialized = false
}
}, [setGateway])
const start = useCallback(async () => {
setLoading(true)
try {
await startGateway()
// SSE will push the real state changes, but set optimistic state
setGateway((prev) => ({ ...prev, status: "starting" }))
} catch (err) {
console.error("Failed to start gateway:", err)
} finally {
setLoading(false)
}
}, [setGateway])
const stop = useCallback(async () => {
setLoading(true)
try {
await stopGateway()
} catch (err) {
console.error("Failed to stop gateway:", err)
} finally {
setLoading(false)
}
}, [])
return { state, loading, isInitialized, start, stop }
}

View file

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View file

@ -0,0 +1,334 @@
import dayjs from "dayjs"
import { useAtomValue } from "jotai"
import { useCallback, useEffect, useRef, useState } from "react"
import { getPicoToken } from "@/api/pico"
import { getSessionHistory } from "@/api/sessions"
import { gatewayAtom } from "@/store"
// Pico Protocol message types
interface PicoMessage {
type: string
id?: string
session_id?: string
timestamp?: number
payload?: Record<string, unknown>
}
export interface ChatMessage {
id: string
role: "user" | "assistant"
content: string
timestamp: number | string
}
type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
function generateSessionId(): string {
return crypto.randomUUID()
}
// Helper to format message timestamps
export function formatMessageTime(dateRaw: number | string | Date): string {
const date = dayjs(dateRaw)
const now = dayjs()
const isToday = date.isSame(now, "day")
const isThisYear = date.isSame(now, "year")
if (isToday) {
return date.format("LT")
}
// Cross-day formatting
if (isThisYear) {
return date.format("MMM D LT")
}
return date.format("ll LT")
}
export function usePicoChat() {
const { status: gatewayState } = useAtomValue(gatewayAtom)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected")
const [isTyping, setIsTyping] = useState(false)
const [activeSessionId, setActiveSessionId] =
useState<string>(generateSessionId)
const wsRef = useRef<WebSocket | null>(null)
const isConnectingRef = useRef(false)
const msgIdCounter = useRef(0)
const activeSessionIdRef = useRef(activeSessionId)
// Keep ref in sync
useEffect(() => {
activeSessionIdRef.current = activeSessionId
}, [activeSessionId])
const handlePicoMessage = useCallback((msg: PicoMessage) => {
const payload = msg.payload || {}
switch (msg.type) {
case "message.create": {
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
// Use provided timestamp or current time
const timestampRaw = msg.timestamp ? msg.timestamp * 1000 : Date.now()
setMessages((prev) => [
...prev,
{
id: messageId,
role: "assistant",
content,
timestamp: timestampRaw,
},
])
setIsTyping(false)
break
}
case "message.update": {
const content = (payload.content as string) || ""
const messageId = payload.message_id as string
if (!messageId) break
setMessages((prev) =>
prev.map((m) => (m.id === messageId ? { ...m, content } : m)),
)
break
}
case "typing.start":
setIsTyping(true)
break
case "typing.stop":
setIsTyping(false)
break
case "error":
console.error("Pico error:", payload)
setIsTyping(false)
break
case "pong":
// heartbeat response, ignore
break
default:
console.log("Unknown pico message type:", msg.type)
}
}, [])
const connect = useCallback(async () => {
if (
isConnectingRef.current ||
(wsRef.current &&
(wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING))
) {
return
}
isConnectingRef.current = true
setConnectionState("connecting")
try {
const { token, ws_url } = await getPicoToken()
if (!token) {
console.error("No pico token available")
setConnectionState("error")
isConnectingRef.current = false
return
}
// If the backend returns a localhost URL but we are accessing it via a LAN IP
// (e.g., from a mobile device during dev), rewrite the hostname to match.
let finalWsUrl = ws_url
try {
const parsedUrl = new URL(ws_url)
const isLocalHost =
parsedUrl.hostname === "localhost" ||
parsedUrl.hostname === "127.0.0.1" ||
parsedUrl.hostname === "0.0.0.0"
const isBrowserLocal =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1"
if (isLocalHost && !isBrowserLocal) {
parsedUrl.hostname = window.location.hostname
finalWsUrl = parsedUrl.toString()
}
} catch (e) {
console.warn("Could not parse ws_url:", e)
}
// Build WebSocket URL with session_id
const sessionId = activeSessionIdRef.current
const url = `${finalWsUrl}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(sessionId)}`
const socket = new WebSocket(url)
socket.onopen = () => {
setConnectionState("connected")
isConnectingRef.current = false
}
socket.onmessage = (event) => {
try {
const msg: PicoMessage = JSON.parse(event.data)
handlePicoMessage(msg)
} catch {
console.warn("Non-JSON message from pico:", event.data)
}
}
socket.onclose = () => {
setConnectionState("disconnected")
wsRef.current = null
isConnectingRef.current = false
}
socket.onerror = () => {
setConnectionState("error")
isConnectingRef.current = false
}
wsRef.current = socket
} catch (err) {
console.error("Failed to connect to pico:", err)
setConnectionState("error")
isConnectingRef.current = false
}
}, [handlePicoMessage])
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setConnectionState("disconnected")
isConnectingRef.current = false
}, [])
// Auto connect/disconnect based on gateway state
useEffect(() => {
// Wrap in setTimeout to avoid React calling setState synchronously during render
const timerId = setTimeout(() => {
if (gatewayState === "running") {
connect()
} else {
disconnect()
}
}, 0)
return () => clearTimeout(timerId)
}, [gatewayState, connect, disconnect])
// Cleanup on unmount
useEffect(() => {
return () => disconnect()
}, [disconnect])
const sendMessage = useCallback((content: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
console.warn("WebSocket not connected")
return
}
const id = `msg-${++msgIdCounter.current}-${Date.now()}`
const timestampRaw = Date.now()
// Add user message to local state
setMessages((prev) => [
...prev,
{ id, role: "user", content, timestamp: timestampRaw },
])
// Show typing indicator immediately
setIsTyping(true)
// Send via Pico Protocol
const picoMsg: PicoMessage = {
type: "message.send",
id,
payload: { content },
}
wsRef.current.send(JSON.stringify(picoMsg))
}, [])
// Switch to a historical session
const switchSession = useCallback(
async (sessionId: string) => {
// Disconnect current WebSocket
disconnect()
// Set new session ID
setActiveSessionId(sessionId)
setIsTyping(false)
// Load history from backend
try {
const detail = await getSessionHistory(sessionId)
// Set all history messages timestamp from the session updated time as fallback,
// since currently the backend doesn't return per-message timestamp in the history API.
// We'll use the session's updated time for now.
const fallbackTime = detail.updated
setMessages(
detail.messages.map((m, i) => ({
id: `hist-${i}-${Date.now()}`,
role: m.role as "user" | "assistant",
content: m.content,
timestamp: fallbackTime,
})),
)
} catch (err) {
console.error("Failed to load session history:", err)
setMessages([])
}
// Reconnect with new session ID (will use the updated ref)
// Small delay to ensure state has settled
setTimeout(() => {
if (gatewayState === "running") {
connect()
}
}, 100)
},
[disconnect, connect, gatewayState],
)
// Start a new empty chat
const newChat = useCallback(() => {
if (messages.length === 0) {
return
}
disconnect()
const newId = generateSessionId()
setActiveSessionId(newId)
setMessages([])
setIsTyping(false)
// Reconnect with the fresh session
setTimeout(() => {
if (gatewayState === "running") {
connect()
}
}, 100)
}, [disconnect, connect, gatewayState, messages.length])
return {
messages,
connectionState,
isTyping,
activeSessionId,
sendMessage,
switchSession,
newChat,
}
}

View file

@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from "react"
type Theme = "light" | "dark"
function getStoredTheme(): Theme {
if (typeof window === "undefined") return "dark"
return (localStorage.getItem("theme") as Theme) || "dark"
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getStoredTheme)
useEffect(() => {
const root = document.documentElement
if (theme === "dark") {
root.classList.add("dark")
} else {
root.classList.remove("dark")
}
localStorage.setItem("theme", theme)
}, [theme])
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === "dark" ? "light" : "dark"))
}, [])
return { theme, toggleTheme }
}

View file

@ -0,0 +1,49 @@
import dayjs from "dayjs"
import "dayjs/locale/en"
import "dayjs/locale/zh-cn"
import localizedFormat from "dayjs/plugin/localizedFormat"
import relativeTime from "dayjs/plugin/relativeTime"
import i18n from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import { initReactI18next } from "react-i18next"
import en from "./locales/en.json"
import zh from "./locales/zh.json"
dayjs.extend(relativeTime)
dayjs.extend(localizedFormat)
i18n
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
resources: {
en: {
translation: en,
},
zh: {
translation: zh,
},
},
fallbackLng: "en",
debug: false,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
})
i18n.on("languageChanged", (lng) => {
if (lng.startsWith("zh")) {
dayjs.locale("zh-cn")
} else {
dayjs.locale("en")
}
})
export default i18n

View file

@ -0,0 +1,88 @@
{
"navigation": {
"chat": "Chat",
"model_group": "Models",
"providers": "Providers",
"models": "Models",
"credentials": "Credentials",
"services": "Services",
"config": "Config",
"logs": "Logs"
},
"chat": {
"welcome": "How can I help you today?",
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
"model": "Model",
"user": "User",
"placeholder": "Start a new message...",
"attach": "Attach file",
"voice": "Voice input",
"newChat": "New Chat",
"connecting": "Connecting...",
"connectFirst": "Connect to gateway to start chatting",
"notConnected": "Gateway is not running. Start it to chat.",
"thinking": {
"step1": "Thinking...",
"step2": "Analyzing your request...",
"step3": "Preparing response...",
"step4": "Almost there..."
},
"time": {
"justNow": "just now",
"minsAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago"
},
"history": "History",
"noHistory": "No chat history yet",
"messagesCount": "{{count}} messages",
"noModel": "Select model",
"configureModelPrompt": "Configure models to use",
"setupModel": {
"title": "No Model Configured",
"description": "You need to configure at least one AI model with an API key before you can start chatting.",
"action": "Configure Models"
},
"modelGroup": {
"apikey": "API Key",
"oauth": "OAuth",
"local": "Local"
}
},
"header": {
"gateway": {
"stopDialog": {
"title": "Stop Gateway Service?",
"description": "Are you sure you want to stop the gateway? This will disconnect your active chat sessions and halt inference.",
"confirm": "Stop Gateway"
},
"action": {
"start": "Start Gateway",
"stop": "Stop Gateway"
},
"status": {
"starting": "Starting Gateway..."
}
}
},
"common": {
"cancel": "Cancel"
},
"pages": {
"providers": {
"description": "Manage AI model providers and configurations."
},
"models": {
"description": "Manage AI models here."
},
"credentials": {
"description": "Securely manage your API keys and credentials."
},
"config": {
"description": "System configuration and preferences."
},
"logs": {
"description": "System logs and monitoring."
}
}
}

View file

@ -0,0 +1,88 @@
{
"navigation": {
"chat": "对话",
"model_group": "模型",
"providers": "服务商",
"models": "模型",
"credentials": "凭据",
"services": "服务",
"config": "配置",
"logs": "日志"
},
"chat": {
"welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
"model": "模型",
"user": "用户",
"placeholder": "输入新消息...",
"attach": "附加文件",
"voice": "语音输入",
"newChat": "新建对话",
"connecting": "连接中...",
"connectFirst": "请先启动服务以开始对话",
"notConnected": "服务未运行,请先启动以进行对话。",
"thinking": {
"step1": "思考中...",
"step2": "分析您的请求...",
"step3": "准备回复...",
"step4": "马上就好..."
},
"time": {
"justNow": "刚刚",
"minsAgo": "{{count}}分钟前",
"hoursAgo": "{{count}}小时前",
"daysAgo": "{{count}}天前"
},
"history": "历史记录",
"noHistory": "暂无对话历史",
"messagesCount": "{{count}} 条消息",
"noModel": "选择模型",
"configureModelPrompt": "配置模型后使用",
"setupModel": {
"title": "尚未配置模型",
"description": "请先配置至少一个带有 API Key 的 AI 模型,才能开始对话。",
"action": "配置模型"
},
"modelGroup": {
"apikey": "API Key",
"oauth": "OAuth",
"local": "本地模型"
}
},
"header": {
"gateway": {
"stopDialog": {
"title": "停止服务?",
"description": "您确定要停止服务吗?这将断开您当前活动的聊天会话并停止推理。",
"confirm": "停止服务"
},
"action": {
"start": "启动服务",
"stop": "停止服务"
},
"status": {
"starting": "服务启动中..."
}
}
},
"common": {
"cancel": "取消"
},
"pages": {
"providers": {
"description": "管理各个 AI 模型服务商的接入配置。"
},
"models": {
"description": "在此管理您下载或借用的 AI 模型。"
},
"credentials": {
"description": "安全管理您的 API 密钥与访问凭据。"
},
"config": {
"description": "系统配置和偏好设置。"
},
"logs": {
"description": "系统日志和监控。"
}
}
}

View file

@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@ -118,9 +119,63 @@
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
body {
@apply bg-background text-foreground;
}
/* WebKit Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
}
/* Offset sidebar below the full-width header */
[data-slot="sidebar-container"] {
top: 3.5rem !important;
height: calc(100svh - 3.5rem) !important;
}
[data-slot="sidebar-gap"] {
height: calc(100svh - 3.5rem);
}
/* Typing indicator animations */
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes fadeSlideIn {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View file

@ -2,6 +2,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"
import { StrictMode } from "react"
import ReactDOM from "react-dom/client"
import "./i18n"
import "./index.css"
import { routeTree } from "./routeTree.gen"

View file

@ -9,8 +9,38 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ProvidersRouteImport } from './routes/providers'
import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs'
import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
import { Route as IndexRouteImport } from './routes/index'
const ProvidersRoute = ProvidersRouteImport.update({
id: '/providers',
path: '/providers',
getParentRoute: () => rootRouteImport,
} as any)
const ModelsRoute = ModelsRouteImport.update({
id: '/models',
path: '/models',
getParentRoute: () => rootRouteImport,
} as any)
const LogsRoute = LogsRouteImport.update({
id: '/logs',
path: '/logs',
getParentRoute: () => rootRouteImport,
} as any)
const CredentialsRoute = CredentialsRouteImport.update({
id: '/credentials',
path: '/credentials',
getParentRoute: () => rootRouteImport,
} as any)
const ConfigRoute = ConfigRouteImport.update({
id: '/config',
path: '/config',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@ -19,28 +49,96 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths:
| '/'
| '/config'
| '/credentials'
| '/logs'
| '/models'
| '/providers'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/config' | '/credentials' | '/logs' | '/models' | '/providers'
id:
| '__root__'
| '/'
| '/config'
| '/credentials'
| '/logs'
| '/models'
| '/providers'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ConfigRoute: typeof ConfigRoute
CredentialsRoute: typeof CredentialsRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
ProvidersRoute: typeof ProvidersRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/providers': {
id: '/providers'
path: '/providers'
fullPath: '/providers'
preLoaderRoute: typeof ProvidersRouteImport
parentRoute: typeof rootRouteImport
}
'/models': {
id: '/models'
path: '/models'
fullPath: '/models'
preLoaderRoute: typeof ModelsRouteImport
parentRoute: typeof rootRouteImport
}
'/logs': {
id: '/logs'
path: '/logs'
fullPath: '/logs'
preLoaderRoute: typeof LogsRouteImport
parentRoute: typeof rootRouteImport
}
'/credentials': {
id: '/credentials'
path: '/credentials'
fullPath: '/credentials'
preLoaderRoute: typeof CredentialsRouteImport
parentRoute: typeof rootRouteImport
}
'/config': {
id: '/config'
path: '/config'
fullPath: '/config'
preLoaderRoute: typeof ConfigRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@ -53,6 +151,11 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ConfigRoute: ConfigRoute,
CredentialsRoute: CredentialsRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,
ProvidersRoute: ProvidersRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -1,11 +1,15 @@
import { Outlet, createRootRoute } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
const RootLayout = () => (
<>
import { AppLayout } from "@/components/app-layout"
const RootLayout = () => {
return (
<AppLayout>
<Outlet />
<TanStackRouterDevtools />
</>
</AppLayout>
)
}
export const Route = createRootRoute({ component: RootLayout })

View file

@ -0,0 +1,30 @@
import { createFileRoute } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
export const Route = createFileRoute("/config")({
component: ConfigPage,
})
function ConfigPage() {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.config", "Config")} />
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.config", "Config")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t(
"pages.config.description",
"System configuration and preferences.",
)}
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,30 @@
import { createFileRoute } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
export const Route = createFileRoute("/credentials")({
component: CredentialsPage,
})
function CredentialsPage() {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.credentials", "Credentials")} />
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.credentials", "Credentials")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t(
"pages.credentials.description",
"Securely manage your API keys and credentials.",
)}
</p>
</div>
</div>
</div>
)
}

View file

@ -1,57 +1,603 @@
import { IconMessageCircle, IconServer } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router"
import {
IconArrowUp,
IconCheck,
IconCopy,
IconHistory,
IconMicrophone,
IconPaperclip,
IconPlus,
IconSparkles,
IconTrash,
} from "@tabler/icons-react"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import dayjs from "dayjs"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import TextareaAutosize from "react-textarea-autosize"
import remarkGfm from "remark-gfm"
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { useApiStatus } from "@/hooks/use-api-status"
import { useWebSocket } from "@/hooks/use-websocket"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useGateway } from "@/hooks/use-gateway"
import { formatMessageTime, usePicoChat } from "@/hooks/use-pico-chat"
// Assistant Message Component
function AssistantMessage({
content,
timestamp = "",
}: {
content: string
timestamp?: string | number
}) {
const [isCopied, setIsCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(content).then(() => {
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
})
}
return (
<div className="group flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<div className="flex items-center gap-2">
<span>PicoClaw</span>
{timestamp && (
<>
<span className="opacity-50"></span>
<span>{formatMessageTime(timestamp)}</span>
</>
)}
</div>
</div>
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
<Button
variant="ghost"
size="icon"
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
</div>
)
}
// User Message Component
function UserMessage({ content }: { content: string }) {
return (
<div className="flex w-full flex-col items-end gap-1.5">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm">
{content}
</div>
</div>
)
}
function TypingIndicator() {
const { t } = useTranslation()
const thinkingSteps = [
t("chat.thinking.step1"),
t("chat.thinking.step2"),
t("chat.thinking.step3"),
t("chat.thinking.step4"),
]
const [stepIndex, setStepIndex] = useState(0)
useEffect(() => {
const stepsCount = thinkingSteps.length
const interval = setInterval(() => {
setStepIndex((prev) => (prev + 1) % stepsCount)
}, 3000)
return () => clearInterval(interval)
}, [thinkingSteps.length])
return (
<div className="flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center gap-2 px-1 text-xs opacity-70">
<span>PicoClaw</span>
</div>
<div className="bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
{/* Bouncing dots */}
<div className="flex items-center gap-1.5">
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70" />
</div>
{/* Shimmer progress bar */}
<div className="bg-muted relative h-1 w-36 overflow-hidden rounded-full">
<div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-violet-500/60 via-violet-400/80 to-violet-500/60 bg-[length:200%_100%]" />
</div>
{/* Rotating status text */}
<p
key={stepIndex}
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs"
>
{thinkingSteps[stepIndex]}
</p>
</div>
</div>
)
}
export const Route = createFileRoute("/")({
component: Index,
})
const LIMIT = 20
function Index() {
const { status: apiStatus, loading, check: checkApiStatus } = useApiStatus()
const { message: wsMessage, connect: connectWebSocket } =
useWebSocket("/ws/chat")
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<HTMLDivElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [input, setInput] = useState("")
const [sessions, setSessions] = useState<SessionSummary[]>([])
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [modelList, setModelList] = useState<ModelInfo[]>([])
const [defaultModelName, setDefaultModelName] = useState("")
const {
messages,
isTyping,
activeSessionId,
sendMessage,
switchSession,
newChat,
} = usePicoChat()
const { state: gwState, isInitialized } = useGateway()
const isConnected = gwState === "running"
const navigate = useNavigate()
const hasConfiguredModels = modelList.some((m) => m.configured)
const oauthModels = modelList.filter(
(m) => m.configured && m.auth_method === "oauth",
)
const localModels = modelList.filter(
(m) =>
m.configured &&
(m.auth_method === "local" ||
(!m.auth_method &&
(m.api_base?.includes("localhost") ||
m.api_base?.includes("127.0.0.1")))),
)
const apiKeyModels = modelList.filter(
(m) => m.configured && !oauthModels.includes(m) && !localModels.includes(m),
)
// Load models list
const loadModels = useCallback(async () => {
try {
const data = await getModels()
setModelList(data.models)
setDefaultModelName(data.default_model)
} catch {
// silently fail
}
}, [])
// Fetch models on mount and when gateway connects
useEffect(() => {
loadModels()
}, [isConnected, loadModels])
const handleSetDefault = async (modelName: string) => {
try {
await setDefaultModel(modelName)
setDefaultModelName(modelName)
setModelList((prev) =>
prev.map((m) => ({ ...m, is_default: m.model_name === modelName })),
)
} catch (err) {
console.error("Failed to set default model:", err)
}
}
const loadSessions = useCallback(
async (reset = true) => {
try {
const currentOffset = reset ? 0 : offset
if (reset) {
setHasMore(true)
setOffset(0)
}
const data = await getSessions(currentOffset, LIMIT)
if (data.length < LIMIT) {
setHasMore(false)
}
if (reset) {
setSessions(data)
} else {
setSessions((prev) => {
// Filter out duplicates just in case
const existingIds = new Set(prev.map((s) => s.id))
const newItems = data.filter((s) => !existingIds.has(s.id))
return [...prev, ...newItems]
})
}
setOffset(currentOffset + data.length)
} catch {
// silently fail
} finally {
setIsLoadingMore(false)
}
},
[offset],
)
// Intersection Observer for infinite scrolling
useEffect(() => {
if (!observerRef.current || !hasMore || isLoadingMore) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
setIsLoadingMore(true)
loadSessions(false)
}
},
{ threshold: 0.1 },
)
observer.observe(observerRef.current)
return () => observer.disconnect()
}, [hasMore, isLoadingMore, loadSessions])
const handleDeleteSession = async (id: string) => {
try {
await deleteSession(id)
setSessions((prev) => prev.filter((s) => s.id !== id))
if (id === activeSessionId) {
newChat()
}
} catch (err) {
console.error("Failed to delete session:", err)
}
}
// Track if user has naturally scrolled away from the bottom
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
}
// Auto-scroll to bottom when new messages arrive (if already at bottom)
useEffect(() => {
if (isAtBottom && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isTyping, isAtBottom])
const handleSend = () => {
if (!input.trim() || !isConnected) return
sendMessage(input.trim())
setInput("")
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="flex w-full flex-col items-center justify-center gap-10 py-20">
<div className="bg-card w-full max-w-sm rounded-xl border p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold tracking-tight">
Backend API Status
</h2>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
Status:{" "}
<span className="text-foreground font-medium">{apiStatus}</span>
<div className="bg-background/95 flex h-full flex-col">
<PageHeader
title="Chat"
titleExtra={
hasConfiguredModels ? (
<Select value={defaultModelName} onValueChange={handleSetDefault}>
<SelectTrigger
size="sm"
className="text-muted-foreground hover:text-foreground h-8 max-w-[160px] bg-transparent shadow-none focus-visible:border-transparent focus-visible:ring-0 sm:max-w-[220px]"
>
<SelectValue placeholder={t("chat.noModel")} />
</SelectTrigger>
<SelectContent>
{apiKeyModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.apikey", "API Key")}
</SelectLabel>
{apiKeyModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{apiKeyModels.length > 0 &&
(oauthModels.length > 0 || localModels.length > 0) && (
<SelectSeparator />
)}
{oauthModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.oauth", "OAuth")}
</SelectLabel>
{oauthModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{oauthModels.length > 0 &&
(localModels.length > 0 || apiKeyModels.length > 0) && (
<SelectSeparator />
)}
{localModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t("chat.modelGroup.local", "Local")}
</SelectLabel>
{localModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
) : (
<Button
variant="link"
size="sm"
className="text-muted-foreground hover:text-foreground h-8 px-0 text-xs font-normal text-red-500"
onClick={() => navigate({ to: "/models" })}
>
{t("chat.configureModelPrompt")}
</Button>
)
}
>
<Button
variant="outline"
size="sm"
onClick={newChat}
className="h-9 gap-2"
>
<IconPlus className="size-4" />
<span className="hidden sm:inline">{t("chat.newChat")}</span>
</Button>
<DropdownMenu
onOpenChange={(open) => {
if (open) {
loadSessions(true)
}
}}
>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2">
<IconHistory className="size-4" />
<span className="hidden sm:inline">{t("chat.history")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<ScrollArea className="max-h-[300px]">
{sessions.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-muted-foreground text-xs">
{t("chat.noHistory")}
</span>
</DropdownMenuItem>
) : (
sessions.map((session) => (
<DropdownMenuItem
key={session.id}
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-8 ${
session.id === activeSessionId ? "bg-accent" : ""
}`}
onClick={() => switchSession(session.id)}
>
<span className="line-clamp-1 text-sm font-medium">
{session.preview}
</span>
<span className="text-muted-foreground text-xs">
{t("chat.messagesCount", {
count: session.message_count,
})}{" "}
· {dayjs(session.updated).fromNow()}
</span>
<Button
onClick={checkApiStatus}
size="sm"
variant="secondary"
disabled={loading}
variant="ghost"
size="icon"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDeleteSession(session.id)
}}
>
<IconServer className="mr-2 h-4 w-4" /> Check
<IconTrash className="h-4 w-4" />
</Button>
</DropdownMenuItem>
))
)}
{hasMore && sessions.length > 0 && (
<div ref={observerRef} className="py-2 text-center">
<span className="text-muted-foreground animate-pulse text-xs">
Loading more...
</span>
</div>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
{/* Chat Messages Area */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
>
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && isConnected && (
<div className="flex flex-col items-center justify-center py-20 opacity-70">
{!hasConfiguredModels ? (
<>
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
<IconSparkles className="h-8 w-8" />
</div>
<h3 className="mb-2 text-xl font-medium">
{t("chat.setupModel.title")}
</h3>
<p className="text-muted-foreground mb-4 max-w-sm text-center text-sm">
{t("chat.setupModel.description")}
</p>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => navigate({ to: "/models" })}
>
{t("chat.setupModel.action")}
</Button>
</>
) : (
<>
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500">
<IconMicrophone className="h-8 w-8" />
</div>
<h3 className="mb-2 text-xl font-medium">
{t("chat.welcome")}
</h3>
<p className="text-muted-foreground max-w-sm text-center text-sm">
{t("chat.welcomeDesc")}
</p>
</>
)}
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className="flex w-full">
{msg.role === "assistant" ? (
<AssistantMessage
content={msg.content}
timestamp={msg.timestamp}
/>
) : (
<UserMessage content={msg.content} />
)}
</div>
))}
{isTyping && <TypingIndicator />}
</div>
</div>
<div className="bg-card w-full max-w-sm rounded-xl border p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold tracking-tight">
WebSocket Chat
</h2>
<div className="flex flex-col gap-4">
<div className="bg-muted text-muted-foreground min-h-24 rounded-md p-3 text-sm whitespace-pre-wrap">
{wsMessage}
</div>
{/* Input Area */}
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
<TextareaAutosize
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
!isInitialized
? t("chat.connecting")
: isConnected
? t("chat.placeholder")
: t("chat.connectFirst")
}
disabled={!isConnected}
className="max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent"
minRows={1}
maxRows={8}
/>
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={connectWebSocket}
variant="outline"
className="w-full"
disabled
variant="ghost"
size="icon"
className="text-muted-foreground size-8 rounded-full"
disabled={!isConnected}
>
<IconMessageCircle className="mr-2 h-4 w-4" /> Connect to Chat
<IconPaperclip className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("chat.attach")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground size-8 rounded-full"
disabled={!isConnected}
>
<IconMicrophone className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("chat.voice")}</TooltipContent>
</Tooltip>
</div>
<Button
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={handleSend}
disabled={!input.trim() || !isConnected}
>
<IconArrowUp className="size-4" />
</Button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,123 @@
import { createFileRoute } from "@tanstack/react-router"
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { getGatewayStatus } from "@/api/gateway"
import { PageHeader } from "@/components/page-header"
import { ScrollArea } from "@/components/ui/scroll-area"
import { gatewayAtom } from "@/store/gateway"
export const Route = createFileRoute("/logs")({
component: LogsPage,
})
function LogsPage() {
const { t } = useTranslation()
const [logs, setLogs] = useState<string[]>([])
const logOffsetRef = useRef<number>(0)
const logRunIdRef = useRef<number>(-1)
const scrollRef = useRef<HTMLDivElement>(null)
const gateway = useAtomValue(gatewayAtom)
useEffect(() => {
let mounted = true
let timeout: ReturnType<typeof setTimeout>
const fetchLogs = async () => {
// Only fetch logs if the gateway is running or starting
if (
!mounted ||
(gateway.status !== "running" && gateway.status !== "starting")
) {
if (mounted) {
// Still poll the state, but maybe at a slower rate, or we just rely on SSE for status
// and restart fast polling when it's running. Let's just re-evaluate every second
timeout = setTimeout(fetchLogs, 1000)
}
return
}
try {
const data = await getGatewayStatus({
log_offset: logOffsetRef.current,
log_run_id: logRunIdRef.current,
})
if (!mounted) return
if (
data.log_run_id !== undefined &&
data.log_run_id !== logRunIdRef.current
) {
logRunIdRef.current = data.log_run_id
logOffsetRef.current = 0
if (data.logs) {
setLogs(data.logs)
logOffsetRef.current = data.log_total || data.logs.length
}
} else if (data.logs && data.logs.length > 0) {
setLogs((prev) => [...prev, ...data.logs!])
logOffsetRef.current =
data.log_total || logOffsetRef.current + data.logs.length
}
} catch {
// Ignore simple fetch errors during polling
} finally {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
}
}
fetchLogs()
return () => {
mounted = false
clearTimeout(timeout)
}
}, [gateway.status])
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [logs])
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.logs", "Logs")} />
<div className="flex flex-1 flex-col overflow-hidden p-4 sm:p-8">
<div className="mb-4">
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.logs", "Logs")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("pages.logs.description", "System logs and monitoring.")}
</p>
</div>
<div className="bg-muted/30 relative flex-1 overflow-hidden rounded-lg border">
<ScrollArea className="h-full">
<div className="p-4 font-mono text-sm leading-relaxed">
{logs.length === 0 ? (
<div className="text-muted-foreground italic">
Waiting for logs...
</div>
) : (
logs.map((log, i) => (
<div key={i} className="break-all whitespace-pre-wrap">
{log}
</div>
))
)}
<div ref={scrollRef} />
</div>
</ScrollArea>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
export const Route = createFileRoute("/models")({
component: ModelsPage,
})
function ModelsPage() {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.models", "Models")} />
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.models", "Models")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("pages.models.description", "Manage AI models here.")}
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,30 @@
import { createFileRoute } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
export const Route = createFileRoute("/providers")({
component: ProvidersPage,
})
function ProvidersPage() {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.providers", "Providers")} />
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.providers", "Providers")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t(
"pages.providers.description",
"Manage AI model providers and configurations.",
)}
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,19 @@
import { atom } from "jotai"
export type GatewayState =
| "running"
| "starting"
| "stopped"
| "error"
| "unknown"
export interface GatewayStoreState {
isInitialized: boolean
status: GatewayState
}
// Global atom for gateway state
export const gatewayAtom = atom<GatewayStoreState>({
isInitialized: false,
status: "unknown",
})

View file

@ -0,0 +1 @@
export * from "./gateway"

View file

@ -20,14 +20,17 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
build: {
chunkSizeWarningLimit: 2048,
},
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
target: "http://localhost:18800",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:8080",
target: "ws://localhost:18800",
ws: true,
},
},