diff --git a/web/backend/api/config.go b/web/backend/api/config.go new file mode 100644 index 000000000..f160b42b6 --- /dev/null +++ b/web/backend/api/config.go @@ -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 + } + } +} diff --git a/web/backend/api/events.go b/web/backend/api/events.go new file mode 100644 index 000000000..0a8d4a9bb --- /dev/null +++ b/web/backend/api/events.go @@ -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: + } + } +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go new file mode 100644 index 000000000..46c638a8b --- /dev/null +++ b/web/backend/api/gateway.go @@ -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()) + } +} diff --git a/web/backend/api/log.go b/web/backend/api/log.go new file mode 100644 index 000000000..ecf7d422f --- /dev/null +++ b/web/backend/api/log.go @@ -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 +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go new file mode 100644 index 000000000..6c8c8a8fc --- /dev/null +++ b/web/backend/api/models.go @@ -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:] +} diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go new file mode 100644 index 000000000..fc942d51c --- /dev/null +++ b/web/backend/api/pico.go @@ -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) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index a69a30337..b6550c212 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -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) } diff --git a/web/backend/api/router_test.go b/web/backend/api/router_test.go deleted file mode 100644 index 4ab19bfad..000000000 --- a/web/backend/api/router_test.go +++ /dev/null @@ -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) - } -} diff --git a/web/backend/api/session.go b/web/backend/api/session.go new file mode 100644 index 000000000..e3cf674fc --- /dev/null +++ b/web/backend/api/session.go @@ -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: +// +// The sanitized filename replaces ':' with '_', so on disk it becomes: +// +// agent_main_pico_direct_pico_.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: -> agent_main_pico_direct_pico_.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: -> agent_main_pico_direct_pico_.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) +} diff --git a/web/backend/api/status.go b/web/backend/api/status.go deleted file mode 100644 index 9ae82917e..000000000 --- a/web/backend/api/status.go +++ /dev/null @@ -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) -} diff --git a/web/backend/api/status_test.go b/web/backend/api/status_test.go deleted file mode 100644 index 5269d3ab8..000000000 --- a/web/backend/api/status_test.go +++ /dev/null @@ -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") - } -} diff --git a/web/backend/go.mod b/web/backend/go.mod deleted file mode 100644 index aea08437b..000000000 --- a/web/backend/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/sipeed/picoclaw/web/backend - -go 1.25.7 diff --git a/web/backend/main.go b/web/backend/main.go index e5e438d8f..281930fd2 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -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) } } diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go index bcd603c78..de9e6d870 100644 --- a/web/backend/middleware/middleware.go +++ b/web/backend/middleware/middleware.go @@ -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) { - w.Header().Set("Content-Type", "application/json") + 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) { diff --git a/web/backend/server.go b/web/backend/server.go deleted file mode 100644 index 7921ce6cb..000000000 --- a/web/backend/server.go +++ /dev/null @@ -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) -} diff --git a/web/backend/server_test.go b/web/backend/server_test.go deleted file mode 100644 index c221fca31..000000000 --- a/web/backend/server_test.go +++ /dev/null @@ -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) - } -} diff --git a/web/backend/utils.go b/web/backend/utils.go new file mode 100644 index 000000000..6fa734aeb --- /dev/null +++ b/web/backend/utils.go @@ -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") + } +} diff --git a/web/backend/ws/handler.go b/web/backend/ws/handler.go deleted file mode 100644 index b6000a643..000000000 --- a/web/backend/ws/handler.go +++ /dev/null @@ -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") -} diff --git a/web/backend/ws/handler_test.go b/web/backend/ws/handler_test.go deleted file mode 100644 index 5f1ced3a2..000000000 --- a/web/backend/ws/handler_test.go +++ /dev/null @@ -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) - } -} diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore index a547bf36d..4811cdd9b 100644 --- a/web/frontend/.gitignore +++ b/web/frontend/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.tanstack \ No newline at end of file diff --git a/web/frontend/package.json b/web/frontend/package.json index da828aa86..be2c0c5cb 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -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", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 1309d0483..0b824fde7 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -29,6 +29,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dayjs: + specifier: ^1.11.19 + version: 1.11.19 + i18next: + specifier: ^25.8.14 + version: 25.8.14(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 + jotai: + specifier: ^2.18.0 + version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -38,6 +50,18 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^16.5.4 + version: 16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + react-textarea-autosize: + specifier: ^8.5.9 + version: 8.5.9(@types/react@19.2.14)(react@19.2.4) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 shadcn: specifier: ^3.8.5 version: 3.8.5(@types/node@24.11.0)(typescript@5.9.3) @@ -54,6 +78,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.3 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.2.1) '@tanstack/router-plugin': specifier: ^1.164.0 version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) @@ -241,6 +268,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1513,6 +1544,11 @@ packages: resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} engines: {node: '>= 20'} + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tailwindcss/vite@4.2.1': resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} peerDependencies: @@ -1631,12 +1667,27 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.11.0': resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} @@ -1651,6 +1702,12 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -1713,6 +1770,9 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1785,6 +1845,9 @@ packages: babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1847,6 +1910,9 @@ packages: caniuse-lite@1.0.30001775: resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1855,6 +1921,18 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1892,6 +1970,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -1958,6 +2039,9 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1967,6 +2051,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -1998,6 +2085,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2005,6 +2096,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -2076,6 +2170,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -2140,6 +2238,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2174,6 +2275,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2346,6 +2450,12 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} @@ -2359,6 +2469,12 @@ packages: resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} engines: {node: '>=16.9.0'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2375,6 +2491,17 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@25.8.14: + resolution: {integrity: sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2398,6 +2525,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -2406,6 +2536,12 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -2413,6 +2549,9 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2430,6 +2569,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -2506,6 +2648,24 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jotai@2.18.0: + resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2646,16 +2806,67 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -2671,6 +2882,90 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2815,6 +3110,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-imports-exports@0.2.4: resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} @@ -2872,6 +3170,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -2956,6 +3258,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2997,6 +3302,28 @@ packages: peerDependencies: react: ^19.2.4 + react-i18next@16.5.4: + resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -3031,6 +3358,12 @@ packages: '@types/react': optional: true + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -3043,6 +3376,18 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3171,6 +3516,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3190,6 +3538,9 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -3218,6 +3569,12 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3269,6 +3626,12 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -3324,6 +3687,24 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -3358,6 +3739,33 @@ packages: '@types/react': optional: true + use-composed-ref@1.4.0: + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.3.0: + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -3384,6 +3792,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3424,6 +3838,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -3504,6 +3922,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@antfu/ni@25.0.0': @@ -3686,6 +4107,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -4886,6 +5309,11 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.1)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.2.1 + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.2.1 @@ -5036,10 +5464,28 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@24.11.0': dependencies: undici-types: 7.16.0 @@ -5054,6 +5500,10 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': @@ -5147,6 +5597,8 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -5224,6 +5676,8 @@ snapshots: transitivePeerDependencies: - supports-color + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -5291,6 +5745,8 @@ snapshots: caniuse-lite@1.0.30001775: {} + ccount@2.0.1: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5298,6 +5754,14 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -5338,6 +5802,8 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@11.1.0: {} commander@14.0.3: {} @@ -5384,10 +5850,16 @@ snapshots: data-uri-to-buffer@4.0.1: {} + dayjs@1.11.19: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + dedent@1.7.2: {} deep-is@0.1.4: {} @@ -5405,10 +5877,16 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@8.0.3: {} dotenv@17.3.1: {} @@ -5490,6 +5968,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)): dependencies: eslint: 9.39.3(jiti@2.6.1) @@ -5579,6 +6059,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + esutils@2.0.3: {} etag@1.8.1: {} @@ -5654,6 +6136,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -5810,6 +6294,30 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + headers-polyfill@4.0.3: {} hermes-estree@0.25.1: {} @@ -5820,6 +6328,12 @@ snapshots: hono@4.12.3: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + html-url-attributes@3.0.1: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5839,6 +6353,16 @@ snapshots: human-signals@8.0.1: {} + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.28.6 + + i18next@25.8.14(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5856,16 +6380,27 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + ip-address@10.0.1: {} ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arrayish@0.2.1: {} is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 + is-decimal@2.0.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -5876,6 +6411,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -5920,6 +6457,13 @@ snapshots: jose@6.1.3: {} + jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@babel/core': 7.29.0 + '@babel/template': 7.28.6 + '@types/react': 19.2.14 + react: 19.2.4 + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -6025,6 +6569,8 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + longest-streak@3.1.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6033,8 +6579,163 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -6043,6 +6744,197 @@ snapshots: merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -6196,6 +7088,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-imports-exports@0.2.4: dependencies: parse-statements: 1.0.11 @@ -6235,6 +7137,11 @@ snapshots: pkce-challenge@5.0.1: {} + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -6267,6 +7174,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + property-information@7.1.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -6357,6 +7266,35 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-i18next@16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 25.8.14(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -6386,6 +7324,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@babel/runtime': 7.28.6 + react: 19.2.4 + use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) + use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + react@19.2.4: {} readdirp@3.6.0: @@ -6400,6 +7347,40 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -6595,6 +7576,8 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -6613,6 +7596,11 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 @@ -6635,6 +7623,14 @@ snapshots: strip-json-comments@3.1.1: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6674,6 +7670,10 @@ snapshots: dependencies: tldts: 7.0.23 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6731,6 +7731,39 @@ snapshots: unicorn-magic@0.3.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -6761,6 +7794,25 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): dependencies: detect-node-es: 1.1.0 @@ -6779,6 +7831,16 @@ snapshots: vary@1.1.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -6794,6 +7856,8 @@ snapshots: lightningcss: 1.31.1 tsx: 4.21.0 + void-elements@3.1.0: {} + web-streams-polyfill@3.3.3: {} webpack-virtual-modules@0.6.2: {} @@ -6860,3 +7924,5 @@ snapshots: zod@3.25.76: {} zod@4.3.6: {} + + zwitch@2.0.4: {} diff --git a/web/frontend/public/logo_with_text.png b/web/frontend/public/logo_with_text.png new file mode 100644 index 000000000..70f26788c Binary files /dev/null and b/web/frontend/public/logo_with_text.png differ diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts new file mode 100644 index 000000000..497c510cc --- /dev/null +++ b/web/frontend/src/api/gateway.ts @@ -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(path: string, options?: RequestInit): Promise { + 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 +} + +export async function getGatewayStatus(options?: { + log_offset?: number + log_run_id?: number +}): Promise { + 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(`/api/gateway/status${queryString}`) +} + +export async function startGateway(): Promise { + return request("/api/gateway/start", { + method: "POST", + }) +} + +export async function stopGateway(): Promise { + return request("/api/gateway/stop", { + method: "POST", + }) +} + +export async function restartGateway(): Promise { + return request("/api/gateway/restart", { + method: "POST", + }) +} + +export type { GatewayStatusResponse, GatewayActionResponse } diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts new file mode 100644 index 000000000..0766be265 --- /dev/null +++ b/web/frontend/src/api/models.ts @@ -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(path: string, options?: RequestInit): Promise { + 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 +} + +export async function getModels(): Promise { + return request("/api/models") +} + +export async function addModel( + model: Partial, +): Promise { + return request("/api/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(model), + }) +} + +export async function updateModel( + index: number, + model: Partial, +): Promise { + return request(`/api/models/${index}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(model), + }) +} + +export async function deleteModel(index: number): Promise { + return request(`/api/models/${index}`, { + method: "DELETE", + }) +} + +export async function setDefaultModel( + modelName: string, +): Promise { + return request("/api/models/default", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model_name: modelName }), + }) +} + +export type { ModelsListResponse, ModelActionResponse } diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts new file mode 100644 index 000000000..9a1a553d5 --- /dev/null +++ b/web/frontend/src/api/pico.ts @@ -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(path: string, options?: RequestInit): Promise { + 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 +} + +export async function getPicoToken(): Promise { + return request("/api/pico/token") +} + +export async function regenPicoToken(): Promise { + return request("/api/pico/token", { method: "POST" }) +} + +export async function setupPico(): Promise { + return request("/api/pico/setup", { method: "POST" }) +} + +export type { PicoTokenResponse, PicoSetupResponse } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts new file mode 100644 index 000000000..56ef148db --- /dev/null +++ b/web/frontend/src/api/sessions.ts @@ -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 { + 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 { + 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 { + const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { + method: "DELETE", + }) + if (!res.ok) { + throw new Error(`Failed to delete session ${id}: ${res.status}`) + } +} diff --git a/web/frontend/src/api/status.ts b/web/frontend/src/api/status.ts deleted file mode 100644 index f219fbc09..000000000 --- a/web/frontend/src/api/status.ts +++ /dev/null @@ -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(path: string, options?: RequestInit): Promise { - 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 -} - -export async function getStatus(): Promise { - return request("/api/status") -} diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx new file mode 100644 index 000000000..0c715819c --- /dev/null +++ b/web/frontend/src/components/app-header.tsx @@ -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 ( +
+
+ + + +
+ + Logo + +
+
+ + {/* Center prominent connection status */} +
+ {isInitialized && !isRunning && !isStarting && ( +
+ + + + {t( + "chat.notConnected", + "Gateway is not running. Start it to chat.", + )} +
+ )} +
+ + + + + + {t("header.gateway.stopDialog.title", "Stop Gateway Service?")} + + + {t( + "header.gateway.stopDialog.description", + "Are you sure you want to stop the gateway? This will disconnect your active chat sessions and halt inference.", + )} + + + + + {t("common.cancel", "Cancel")} + + + {t("header.gateway.stopDialog.confirm", "Stop Gateway")} + + + + + +
+ {/* Gateway Start/Stop */} + + + + + {/* Docs Link */} + + + {/* Language Switcher */} + + + + + + i18n.changeLanguage("en")}> + English + + i18n.changeLanguage("zh")}> + 简体中文 + + + + + {/* Theme Toggle */} + +
+
+ ) +} diff --git a/web/frontend/src/components/app-layout.tsx b/web/frontend/src/components/app-layout.tsx new file mode 100644 index 000000000..fee061abd --- /dev/null +++ b/web/frontend/src/components/app-layout.tsx @@ -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 ( + + + + +
+ +
+
+ {children} +
+
+
+
+
+ ) +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx new file mode 100644 index 000000000..ce94f9d3b --- /dev/null +++ b/web/frontend/src/components/app-sidebar.tsx @@ -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) { + const routerState = useRouterState() + const { t } = useTranslation() + const currentPath = routerState.location.pathname + + return ( + + + {navGroups.map((group) => ( + + + + + {t(group.label)} + + + + + + + {group.items.map((item) => { + const isActive = currentPath === item.url + return ( + + + + + + {t(item.title)} + + + + + ) + })} + + + + + + ))} + + + + ) +} diff --git a/web/frontend/src/components/page-header.tsx b/web/frontend/src/components/page-header.tsx new file mode 100644 index 000000000..9d4aa6975 --- /dev/null +++ b/web/frontend/src/components/page-header.tsx @@ -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 ( +
+
+ + + +

+ {title} +

+ {titleExtra} +
+ {children &&
{children}
} +
+ ) +} diff --git a/web/frontend/src/components/ui/alert-dialog.tsx b/web/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..d20de09a3 --- /dev/null +++ b/web/frontend/src/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/web/frontend/src/components/ui/collapsible.tsx b/web/frontend/src/components/ui/collapsible.tsx new file mode 100644 index 000000000..63fc8eff3 --- /dev/null +++ b/web/frontend/src/components/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import { Collapsible as CollapsiblePrimitive } from "radix-ui" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/web/frontend/src/components/ui/dropdown-menu.tsx b/web/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..566e2c861 --- /dev/null +++ b/web/frontend/src/components/ui/dropdown-menu.tsx @@ -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) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/web/frontend/src/components/ui/input.tsx b/web/frontend/src/components/ui/input.tsx new file mode 100644 index 000000000..17beb1e93 --- /dev/null +++ b/web/frontend/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/web/frontend/src/components/ui/scroll-area.tsx b/web/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 000000000..f49b0a81c --- /dev/null +++ b/web/frontend/src/components/ui/scroll-area.tsx @@ -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) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/web/frontend/src/components/ui/select.tsx b/web/frontend/src/components/ui/select.tsx new file mode 100644 index 000000000..86427f351 --- /dev/null +++ b/web/frontend/src/components/ui/select.tsx @@ -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) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/web/frontend/src/components/ui/separator.tsx b/web/frontend/src/components/ui/separator.tsx new file mode 100644 index 000000000..ca1150151 --- /dev/null +++ b/web/frontend/src/components/ui/separator.tsx @@ -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) { + return ( + + ) +} + +export { Separator } diff --git a/web/frontend/src/components/ui/sheet.tsx b/web/frontend/src/components/ui/sheet.tsx new file mode 100644 index 000000000..a438ca69d --- /dev/null +++ b/web/frontend/src/components/ui/sheet.tsx @@ -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) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/web/frontend/src/components/ui/sidebar.tsx b/web/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 000000000..06dab36ef --- /dev/null +++ b/web/frontend/src/components/ui/sidebar.tsx @@ -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(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( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +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 ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +