From 5ae8044c7217ad262644fc3fc9126cd8eabf53da Mon Sep 17 00:00:00 2001 From: Guoguo Date: Fri, 6 Mar 2026 03:28:27 -0800 Subject: [PATCH] feat(web): add channel management page for web console Add a complete channel management UI that allows users to configure messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly from the web console instead of manually editing config.json. Backend: GET/PUT/PATCH API endpoints for listing, updating, and toggling channels with secret field masking. Frontend: Channel cards grid with enable/disable toggles, per-channel configuration sheets with dedicated forms for major platforms and a generic fallback for others. Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- web/backend/api/channels.go | 609 ++++++++++++++++++ web/backend/api/router.go | 3 + web/frontend/src/api/channels.ts | 55 ++ web/frontend/src/components/app-sidebar.tsx | 2 + .../src/components/channels/channel-card.tsx | 64 ++ .../channels/channel-forms/discord-form.tsx | 72 +++ .../channels/channel-forms/feishu-form.tsx | 92 +++ .../channels/channel-forms/generic-form.tsx | 132 ++++ .../channels/channel-forms/slack-form.tsx | 81 +++ .../channels/channel-forms/telegram-form.tsx | 79 +++ .../src/components/channels/channels-page.tsx | 137 ++++ .../channels/edit-channel-sheet.tsx | 196 ++++++ web/frontend/src/i18n/locales/en.json | 42 ++ web/frontend/src/i18n/locales/zh.json | 42 ++ web/frontend/src/routeTree.gen.ts | 29 +- web/frontend/src/routes/channels.tsx | 7 + 17 files changed, 1642 insertions(+), 2 deletions(-) create mode 100644 web/backend/api/channels.go create mode 100644 web/frontend/src/api/channels.ts create mode 100644 web/frontend/src/components/channels/channel-card.tsx create mode 100644 web/frontend/src/components/channels/channel-forms/discord-form.tsx create mode 100644 web/frontend/src/components/channels/channel-forms/feishu-form.tsx create mode 100644 web/frontend/src/components/channels/channel-forms/generic-form.tsx create mode 100644 web/frontend/src/components/channels/channel-forms/slack-form.tsx create mode 100644 web/frontend/src/components/channels/channel-forms/telegram-form.tsx create mode 100644 web/frontend/src/components/channels/channels-page.tsx create mode 100644 web/frontend/src/components/channels/edit-channel-sheet.tsx create mode 100644 web/frontend/src/routes/channels.tsx diff --git a/Makefile b/Makefile index 3fb825bb6..2e11e38e6 100644 --- a/Makefile +++ b/Makefile @@ -91,7 +91,7 @@ build: generate build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @if [ ! -d web/backend/dist ]; then \ + @if [ ! -f web/backend/dist/index.html ]; then \ echo "Building frontend..."; \ cd web/frontend && pnpm install && pnpm build:backend; \ fi diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go new file mode 100644 index 000000000..162ef04f2 --- /dev/null +++ b/web/backend/api/channels.go @@ -0,0 +1,609 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// registerChannelRoutes binds channel management endpoints to the ServeMux. +func (h *Handler) registerChannelRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/channels", h.handleListChannels) + mux.HandleFunc("PUT /api/channels/{name}", h.handleUpdateChannel) + mux.HandleFunc("PATCH /api/channels/{name}/toggle", h.handleToggleChannel) +} + +// channelMeta holds static metadata for each supported channel. +type channelMeta struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` +} + +var channelRegistry = []channelMeta{ + {Name: "telegram", DisplayName: "Telegram"}, + {Name: "discord", DisplayName: "Discord"}, + {Name: "slack", DisplayName: "Slack"}, + {Name: "feishu", DisplayName: "Feishu"}, + {Name: "dingtalk", DisplayName: "DingTalk"}, + {Name: "line", DisplayName: "LINE"}, + {Name: "qq", DisplayName: "QQ"}, + {Name: "onebot", DisplayName: "OneBot"}, + {Name: "wecom", DisplayName: "WeCom"}, + {Name: "wecom_app", DisplayName: "WeCom App"}, + {Name: "wecom_aibot", DisplayName: "WeCom AI Bot"}, + {Name: "whatsapp", DisplayName: "WhatsApp"}, + {Name: "pico", DisplayName: "Pico (Web)"}, + {Name: "maixcam", DisplayName: "MaixCAM"}, +} + +// channelResponse is the JSON structure returned for each channel in the list. +type channelResponse struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + Configured bool `json:"configured"` + Config map[string]any `json:"config"` +} + +// handleListChannels returns all channels with their enabled status and masked secrets. +// +// GET /api/channels +func (h *Handler) handleListChannels(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 + } + + channels := make([]channelResponse, 0, len(channelRegistry)) + for _, meta := range channelRegistry { + cr := channelResponse{ + Name: meta.Name, + DisplayName: meta.DisplayName, + } + cr.Enabled, cr.Configured, cr.Config = extractChannelInfo(meta.Name, &cfg.Channels) + channels = append(channels, cr) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "channels": channels, + }) +} + +// handleUpdateChannel replaces a channel's configuration. +// Secret fields sent as empty strings are preserved from the existing config. +// +// PUT /api/channels/{name} +func (h *Handler) handleUpdateChannel(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !isValidChannel(name) { + http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound) + 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 incoming map[string]any + if err = json.Unmarshal(body, &incoming); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %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 + } + + applyChannelUpdate(name, &cfg.Channels, incoming) + + 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"}) +} + +// handleToggleChannel enables or disables a channel. +// +// PATCH /api/channels/{name}/toggle +func (h *Handler) handleToggleChannel(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !isValidChannel(name) { + http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound) + 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 req struct { + Enabled bool `json:"enabled"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %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 + } + + setChannelEnabled(name, &cfg.Channels, req.Enabled) + + 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"}) +} + +func isValidChannel(name string) bool { + for _, m := range channelRegistry { + if m.Name == name { + return true + } + } + return false +} + +// extractChannelInfo returns enabled, configured status and masked config for a channel. +func extractChannelInfo(name string, ch *config.ChannelsConfig) (enabled, configured bool, cfg map[string]any) { + cfg = make(map[string]any) + switch name { + case "telegram": + c := ch.Telegram + enabled = c.Enabled + configured = c.Token != "" + cfg["token"] = maskAPIKey(c.Token) + cfg["base_url"] = c.BaseURL + cfg["proxy"] = c.Proxy + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + cfg["typing"] = c.Typing + cfg["placeholder"] = c.Placeholder + case "discord": + c := ch.Discord + enabled = c.Enabled + configured = c.Token != "" + cfg["token"] = maskAPIKey(c.Token) + cfg["proxy"] = c.Proxy + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + cfg["typing"] = c.Typing + cfg["placeholder"] = c.Placeholder + case "slack": + c := ch.Slack + enabled = c.Enabled + configured = c.BotToken != "" + cfg["bot_token"] = maskAPIKey(c.BotToken) + cfg["app_token"] = maskAPIKey(c.AppToken) + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + cfg["typing"] = c.Typing + cfg["placeholder"] = c.Placeholder + case "feishu": + c := ch.Feishu + enabled = c.Enabled + configured = c.AppID != "" && c.AppSecret != "" + cfg["app_id"] = c.AppID + cfg["app_secret"] = maskAPIKey(c.AppSecret) + cfg["encrypt_key"] = maskAPIKey(c.EncryptKey) + cfg["verification_token"] = maskAPIKey(c.VerificationToken) + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + cfg["placeholder"] = c.Placeholder + case "dingtalk": + c := ch.DingTalk + enabled = c.Enabled + configured = c.ClientID != "" && c.ClientSecret != "" + cfg["client_id"] = c.ClientID + cfg["client_secret"] = maskAPIKey(c.ClientSecret) + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + case "line": + c := ch.LINE + enabled = c.Enabled + configured = c.ChannelSecret != "" && c.ChannelAccessToken != "" + cfg["channel_secret"] = maskAPIKey(c.ChannelSecret) + cfg["channel_access_token"] = maskAPIKey(c.ChannelAccessToken) + cfg["webhook_host"] = c.WebhookHost + cfg["webhook_port"] = c.WebhookPort + cfg["webhook_path"] = c.WebhookPath + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + cfg["typing"] = c.Typing + cfg["placeholder"] = c.Placeholder + case "qq": + c := ch.QQ + enabled = c.Enabled + configured = c.AppID != "" && c.AppSecret != "" + cfg["app_id"] = c.AppID + cfg["app_secret"] = maskAPIKey(c.AppSecret) + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + case "onebot": + c := ch.OneBot + enabled = c.Enabled + configured = c.WSUrl != "" + cfg["ws_url"] = c.WSUrl + cfg["access_token"] = maskAPIKey(c.AccessToken) + cfg["reconnect_interval"] = c.ReconnectInterval + cfg["allow_from"] = []string(c.AllowFrom) + cfg["group_trigger"] = c.GroupTrigger + cfg["typing"] = c.Typing + cfg["placeholder"] = c.Placeholder + case "wecom": + c := ch.WeCom + enabled = c.Enabled + configured = c.Token != "" + cfg["token"] = maskAPIKey(c.Token) + cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey) + cfg["webhook_url"] = c.WebhookURL + cfg["webhook_host"] = c.WebhookHost + cfg["webhook_port"] = c.WebhookPort + cfg["webhook_path"] = c.WebhookPath + cfg["allow_from"] = []string(c.AllowFrom) + cfg["reply_timeout"] = c.ReplyTimeout + cfg["group_trigger"] = c.GroupTrigger + case "wecom_app": + c := ch.WeComApp + enabled = c.Enabled + configured = c.CorpID != "" && c.CorpSecret != "" + cfg["corp_id"] = c.CorpID + cfg["corp_secret"] = maskAPIKey(c.CorpSecret) + cfg["agent_id"] = c.AgentID + cfg["token"] = maskAPIKey(c.Token) + cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey) + cfg["webhook_host"] = c.WebhookHost + cfg["webhook_port"] = c.WebhookPort + cfg["webhook_path"] = c.WebhookPath + cfg["allow_from"] = []string(c.AllowFrom) + cfg["reply_timeout"] = c.ReplyTimeout + cfg["group_trigger"] = c.GroupTrigger + case "wecom_aibot": + c := ch.WeComAIBot + enabled = c.Enabled + configured = c.Token != "" + cfg["token"] = maskAPIKey(c.Token) + cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey) + cfg["webhook_path"] = c.WebhookPath + cfg["allow_from"] = []string(c.AllowFrom) + cfg["reply_timeout"] = c.ReplyTimeout + cfg["max_steps"] = c.MaxSteps + cfg["welcome_message"] = c.WelcomeMessage + case "whatsapp": + c := ch.WhatsApp + enabled = c.Enabled + configured = c.BridgeURL != "" || c.UseNative + cfg["bridge_url"] = c.BridgeURL + cfg["use_native"] = c.UseNative + cfg["session_store_path"] = c.SessionStorePath + cfg["allow_from"] = []string(c.AllowFrom) + case "pico": + c := ch.Pico + enabled = c.Enabled + configured = true // Always considered configured (built-in WebSocket channel) + cfg["token"] = maskAPIKey(c.Token) + cfg["allow_token_query"] = c.AllowTokenQuery + cfg["allow_origins"] = c.AllowOrigins + cfg["ping_interval"] = c.PingInterval + cfg["read_timeout"] = c.ReadTimeout + cfg["write_timeout"] = c.WriteTimeout + cfg["max_connections"] = c.MaxConnections + cfg["allow_from"] = []string(c.AllowFrom) + cfg["placeholder"] = c.Placeholder + case "maixcam": + c := ch.MaixCam + enabled = c.Enabled + configured = c.Host != "" + cfg["host"] = c.Host + cfg["port"] = c.Port + cfg["allow_from"] = []string(c.AllowFrom) + } + return +} + +// setChannelEnabled sets the enabled flag for a channel. +func setChannelEnabled(name string, ch *config.ChannelsConfig, enabled bool) { + switch name { + case "telegram": + ch.Telegram.Enabled = enabled + case "discord": + ch.Discord.Enabled = enabled + case "slack": + ch.Slack.Enabled = enabled + case "feishu": + ch.Feishu.Enabled = enabled + case "dingtalk": + ch.DingTalk.Enabled = enabled + case "line": + ch.LINE.Enabled = enabled + case "qq": + ch.QQ.Enabled = enabled + case "onebot": + ch.OneBot.Enabled = enabled + case "wecom": + ch.WeCom.Enabled = enabled + case "wecom_app": + ch.WeComApp.Enabled = enabled + case "wecom_aibot": + ch.WeComAIBot.Enabled = enabled + case "whatsapp": + ch.WhatsApp.Enabled = enabled + case "pico": + ch.Pico.Enabled = enabled + case "maixcam": + ch.MaixCam.Enabled = enabled + } +} + +// applyChannelUpdate applies incoming config fields to the corresponding channel. +// Empty secret fields are preserved from the existing config. +func applyChannelUpdate(name string, ch *config.ChannelsConfig, incoming map[string]any) { + getString := func(key string) string { + if v, ok := incoming[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" + } + getBool := func(key string) bool { + if v, ok := incoming[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false + } + getInt := func(key string) int { + if v, ok := incoming[key]; ok { + if f, ok := v.(float64); ok { + return int(f) + } + } + return 0 + } + getInt64 := func(key string) int64 { + if v, ok := incoming[key]; ok { + if f, ok := v.(float64); ok { + return int64(f) + } + } + return 0 + } + getStringSlice := func(key string) config.FlexibleStringSlice { + if v, ok := incoming[key]; ok { + if arr, ok := v.([]any); ok { + result := make(config.FlexibleStringSlice, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + } + } + return nil + } + getGroupTrigger := func() config.GroupTriggerConfig { + if v, ok := incoming["group_trigger"]; ok { + if m, ok := v.(map[string]any); ok { + gt := config.GroupTriggerConfig{} + if b, ok := m["mention_only"].(bool); ok { + gt.MentionOnly = b + } + if arr, ok := m["prefixes"].([]any); ok { + for _, item := range arr { + if s, ok := item.(string); ok { + gt.Prefixes = append(gt.Prefixes, s) + } + } + } + return gt + } + } + return config.GroupTriggerConfig{} + } + getTyping := func() config.TypingConfig { + if v, ok := incoming["typing"]; ok { + if m, ok := v.(map[string]any); ok { + if b, ok := m["enabled"].(bool); ok { + return config.TypingConfig{Enabled: b} + } + } + } + return config.TypingConfig{} + } + getPlaceholder := func() config.PlaceholderConfig { + if v, ok := incoming["placeholder"]; ok { + if m, ok := v.(map[string]any); ok { + pc := config.PlaceholderConfig{} + if b, ok := m["enabled"].(bool); ok { + pc.Enabled = b + } + if s, ok := m["text"].(string); ok { + pc.Text = s + } + return pc + } + } + return config.PlaceholderConfig{} + } + + // preserveSecret returns the incoming value if non-empty, or keeps existing. + preserveSecret := func(incoming, existing string) string { + if incoming == "" { + return existing + } + return incoming + } + + switch name { + case "telegram": + c := &ch.Telegram + c.Enabled = getBool("enabled") + c.Token = preserveSecret(getString("token"), c.Token) + c.BaseURL = getString("base_url") + c.Proxy = getString("proxy") + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + c.Typing = getTyping() + c.Placeholder = getPlaceholder() + case "discord": + c := &ch.Discord + c.Enabled = getBool("enabled") + c.Token = preserveSecret(getString("token"), c.Token) + c.Proxy = getString("proxy") + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + c.Typing = getTyping() + c.Placeholder = getPlaceholder() + case "slack": + c := &ch.Slack + c.Enabled = getBool("enabled") + c.BotToken = preserveSecret(getString("bot_token"), c.BotToken) + c.AppToken = preserveSecret(getString("app_token"), c.AppToken) + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + c.Typing = getTyping() + c.Placeholder = getPlaceholder() + case "feishu": + c := &ch.Feishu + c.Enabled = getBool("enabled") + c.AppID = getString("app_id") + c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret) + c.EncryptKey = preserveSecret(getString("encrypt_key"), c.EncryptKey) + c.VerificationToken = preserveSecret(getString("verification_token"), c.VerificationToken) + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + c.Placeholder = getPlaceholder() + case "dingtalk": + c := &ch.DingTalk + c.Enabled = getBool("enabled") + c.ClientID = getString("client_id") + c.ClientSecret = preserveSecret(getString("client_secret"), c.ClientSecret) + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + case "line": + c := &ch.LINE + c.Enabled = getBool("enabled") + c.ChannelSecret = preserveSecret(getString("channel_secret"), c.ChannelSecret) + c.ChannelAccessToken = preserveSecret(getString("channel_access_token"), c.ChannelAccessToken) + c.WebhookHost = getString("webhook_host") + c.WebhookPort = getInt("webhook_port") + c.WebhookPath = getString("webhook_path") + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + c.Typing = getTyping() + c.Placeholder = getPlaceholder() + case "qq": + c := &ch.QQ + c.Enabled = getBool("enabled") + c.AppID = getString("app_id") + c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret) + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + case "onebot": + c := &ch.OneBot + c.Enabled = getBool("enabled") + c.WSUrl = getString("ws_url") + c.AccessToken = preserveSecret(getString("access_token"), c.AccessToken) + c.ReconnectInterval = getInt("reconnect_interval") + c.AllowFrom = getStringSlice("allow_from") + c.GroupTrigger = getGroupTrigger() + c.Typing = getTyping() + c.Placeholder = getPlaceholder() + case "wecom": + c := &ch.WeCom + c.Enabled = getBool("enabled") + c.Token = preserveSecret(getString("token"), c.Token) + c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey) + c.WebhookURL = getString("webhook_url") + c.WebhookHost = getString("webhook_host") + c.WebhookPort = getInt("webhook_port") + c.WebhookPath = getString("webhook_path") + c.AllowFrom = getStringSlice("allow_from") + c.ReplyTimeout = getInt("reply_timeout") + c.GroupTrigger = getGroupTrigger() + case "wecom_app": + c := &ch.WeComApp + c.Enabled = getBool("enabled") + c.CorpID = getString("corp_id") + c.CorpSecret = preserveSecret(getString("corp_secret"), c.CorpSecret) + c.AgentID = getInt64("agent_id") + c.Token = preserveSecret(getString("token"), c.Token) + c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey) + c.WebhookHost = getString("webhook_host") + c.WebhookPort = getInt("webhook_port") + c.WebhookPath = getString("webhook_path") + c.AllowFrom = getStringSlice("allow_from") + c.ReplyTimeout = getInt("reply_timeout") + c.GroupTrigger = getGroupTrigger() + case "wecom_aibot": + c := &ch.WeComAIBot + c.Enabled = getBool("enabled") + c.Token = preserveSecret(getString("token"), c.Token) + c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey) + c.WebhookPath = getString("webhook_path") + c.AllowFrom = getStringSlice("allow_from") + c.ReplyTimeout = getInt("reply_timeout") + c.MaxSteps = getInt("max_steps") + c.WelcomeMessage = getString("welcome_message") + case "whatsapp": + c := &ch.WhatsApp + c.Enabled = getBool("enabled") + c.BridgeURL = getString("bridge_url") + c.UseNative = getBool("use_native") + c.SessionStorePath = getString("session_store_path") + c.AllowFrom = getStringSlice("allow_from") + case "pico": + c := &ch.Pico + c.Enabled = getBool("enabled") + c.Token = preserveSecret(getString("token"), c.Token) + c.AllowTokenQuery = getBool("allow_token_query") + if v, ok := incoming["allow_origins"]; ok { + if arr, ok := v.([]any); ok { + origins := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + origins = append(origins, s) + } + } + c.AllowOrigins = origins + } + } + c.PingInterval = getInt("ping_interval") + c.ReadTimeout = getInt("read_timeout") + c.WriteTimeout = getInt("write_timeout") + c.MaxConnections = getInt("max_connections") + c.AllowFrom = getStringSlice("allow_from") + c.Placeholder = getPlaceholder() + case "maixcam": + c := &ch.MaixCam + c.Enabled = getBool("enabled") + c.Host = getString("host") + c.Port = getInt("port") + c.AllowFrom = getStringSlice("allow_from") + } +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index b92356a1d..a13d71a3a 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -41,4 +41,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Model list management h.registerModelRoutes(mux) + + // Channel management + h.registerChannelRoutes(mux) } diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts new file mode 100644 index 000000000..3610b7ed4 --- /dev/null +++ b/web/frontend/src/api/channels.ts @@ -0,0 +1,55 @@ +// API client for channel management. + +export interface ChannelInfo { + name: string + display_name: string + enabled: boolean + configured: boolean + config: Record +} + +interface ChannelsListResponse { + channels: ChannelInfo[] +} + +interface ChannelActionResponse { + status: 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 getChannels(): Promise { + return request("/api/channels") +} + +export async function updateChannel( + name: string, + config: Record, +): Promise { + return request(`/api/channels/${name}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }) +} + +export async function toggleChannel( + name: string, + enabled: boolean, +): Promise { + return request(`/api/channels/${name}/toggle`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }) +} + +export type { ChannelsListResponse, ChannelActionResponse } diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 42be60555..41fd548fa 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import { IconKey, IconListDetails, IconMessageCircle, + IconPlug, IconSettings, } from "@tabler/icons-react" import { Link, useRouterState } from "@tanstack/react-router" @@ -46,6 +47,7 @@ const navGroups = [ label: "navigation.services", defaultOpen: true, items: [ + { title: "navigation.channels", url: "/channels", icon: IconPlug }, { title: "navigation.config", url: "/config", icon: IconSettings }, { title: "navigation.logs", url: "/logs", icon: IconListDetails }, ], diff --git a/web/frontend/src/components/channels/channel-card.tsx b/web/frontend/src/components/channels/channel-card.tsx new file mode 100644 index 000000000..fa844ede4 --- /dev/null +++ b/web/frontend/src/components/channels/channel-card.tsx @@ -0,0 +1,64 @@ +import { IconSettings } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { ChannelInfo } from "@/api/channels" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" + +interface ChannelCardProps { + channel: ChannelInfo + onToggle: (name: string, enabled: boolean) => void + onEdit: (channel: ChannelInfo) => void + toggling: boolean +} + +export function ChannelCard({ + channel, + onToggle, + onEdit, + toggling, +}: ChannelCardProps) { + const { t } = useTranslation() + + return ( +
+
+
+
+ + {channel.display_name} + + {channel.configured ? ( + + {t("channels.status.configured")} + + ) : ( + + {t("channels.status.unconfigured")} + + )} +
+ {channel.name} +
+
+
+ onToggle(channel.name, checked)} + disabled={toggling} + /> + +
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx new file mode 100644 index 000000000..7e0a98018 --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { + AdvancedSection, + Field, + KeyInput, +} from "@/components/models/shared-form" + +interface DiscordFormProps { + config: Record + onChange: (key: string, value: any) => void + isEdit: boolean +} + +export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) { + const { t } = useTranslation() + + return ( +
+ + onChange("_token", v)} + placeholder={ + isEdit && config.token + ? t("channels.field.secretPlaceholderSet") + : t("channels.field.tokenPlaceholder") + } + /> + + + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx new file mode 100644 index 000000000..c7befe29d --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { + AdvancedSection, + Field, + KeyInput, +} from "@/components/models/shared-form" + +interface FeishuFormProps { + config: Record + onChange: (key: string, value: any) => void + isEdit: boolean +} + +export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) { + const { t } = useTranslation() + + return ( +
+ + onChange("app_id", e.target.value)} + placeholder="cli_xxxx" + /> + + + + onChange("_app_secret", v)} + placeholder={ + isEdit && config.app_secret + ? t("channels.field.secretPlaceholderSet") + : t("channels.field.secretPlaceholder") + } + /> + + + + + onChange("_verification_token", v)} + placeholder={ + isEdit && config.verification_token + ? t("channels.field.secretPlaceholderSet") + : t("channels.field.secretPlaceholder") + } + /> + + + onChange("_encrypt_key", v)} + placeholder={ + isEdit && config.encrypt_key + ? t("channels.field.secretPlaceholderSet") + : t("channels.field.secretPlaceholder") + } + /> + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx new file mode 100644 index 000000000..441b2d1e9 --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -0,0 +1,132 @@ +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { Field, KeyInput } from "@/components/models/shared-form" + +interface GenericFormProps { + channelName: string + config: Record + onChange: (key: string, value: any) => void + isEdit: boolean +} + +// Secret field names that should use masked input. +const SECRET_FIELDS = new Set([ + "token", + "app_secret", + "client_secret", + "corp_secret", + "channel_secret", + "channel_access_token", + "access_token", + "bot_token", + "app_token", + "encoding_aes_key", + "encrypt_key", + "verification_token", +]) + +// Fields to skip in the generic form (handled by enabled toggle or internal). +const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"]) + +// Fields that are objects/nested — show as JSON or skip. +const OBJECT_FIELDS = new Set([ + "group_trigger", + "typing", + "placeholder", + "allow_from", +]) + +function formatLabel(key: string): string { + return key + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ") +} + +export function GenericForm({ + config, + onChange, + isEdit, +}: GenericFormProps) { + const { t } = useTranslation() + + const fields = Object.keys(config).filter( + (k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && !OBJECT_FIELDS.has(k), + ) + + return ( +
+ {fields.map((key) => { + if (SECRET_FIELDS.has(key)) { + const editKey = `_${key}` + return ( + + onChange(editKey, v)} + placeholder={ + isEdit && config[key] + ? t("channels.field.secretPlaceholderSet") + : "" + } + /> + + ) + } + + const value = config[key] + if (typeof value === "boolean") { + return null // Booleans are less common in generic; skip for now + } + + return ( + + { + // Attempt to preserve number types + const v = e.target.value + if (typeof config[key] === "number") { + onChange(key, v === "" ? 0 : Number(v)) + } else { + onChange(key, v) + } + }} + /> + + ) + })} + + {/* Allow From field */} + {config.allow_from !== undefined && ( + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + )} +
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx new file mode 100644 index 000000000..44f8c8a44 --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { + AdvancedSection, + Field, + KeyInput, +} from "@/components/models/shared-form" + +interface SlackFormProps { + config: Record + onChange: (key: string, value: any) => void + isEdit: boolean +} + +export function SlackForm({ config, onChange, isEdit }: SlackFormProps) { + const { t } = useTranslation() + + return ( +
+ + onChange("_bot_token", v)} + placeholder={ + isEdit && config.bot_token + ? t("channels.field.secretPlaceholderSet") + : "xoxb-xxxx" + } + /> + + + + onChange("_app_token", v)} + placeholder={ + isEdit && config.app_token + ? t("channels.field.secretPlaceholderSet") + : "xapp-xxxx" + } + /> + + + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx new file mode 100644 index 000000000..0c2c0f8bd --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { + AdvancedSection, + Field, + KeyInput, +} from "@/components/models/shared-form" + +interface TelegramFormProps { + config: Record + onChange: (key: string, value: any) => void + isEdit: boolean +} + +export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) { + const { t } = useTranslation() + + return ( +
+ + onChange("_token", v)} + placeholder={ + isEdit && config.token + ? t("channels.field.secretPlaceholderSet") + : t("channels.field.tokenPlaceholder") + } + /> + + + + + onChange("base_url", e.target.value)} + placeholder="https://api.telegram.org" + /> + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ ) +} diff --git a/web/frontend/src/components/channels/channels-page.tsx b/web/frontend/src/components/channels/channels-page.tsx new file mode 100644 index 000000000..225711375 --- /dev/null +++ b/web/frontend/src/components/channels/channels-page.tsx @@ -0,0 +1,137 @@ +import { IconSearch } from "@tabler/icons-react" +import { useCallback, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ChannelInfo } from "@/api/channels" +import { getChannels, toggleChannel } from "@/api/channels" +import { ChannelCard } from "@/components/channels/channel-card" +import { EditChannelSheet } from "@/components/channels/edit-channel-sheet" +import { PageHeader } from "@/components/page-header" +import { Input } from "@/components/ui/input" + +export function ChannelsPage() { + const { t } = useTranslation() + const [channels, setChannels] = useState([]) + const [loading, setLoading] = useState(true) + const [fetchError, setFetchError] = useState("") + const [editingChannel, setEditingChannel] = useState(null) + const [togglingChannel, setTogglingChannel] = useState(null) + const [search, setSearch] = useState("") + + const fetchChannels = useCallback(async () => { + try { + const data = await getChannels() + // Sort: enabled first, then configured, then alphabetical + const sorted = [...data.channels].sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1 + if (a.configured !== b.configured) return a.configured ? -1 : 1 + return a.display_name.localeCompare(b.display_name) + }) + setChannels(sorted) + setFetchError("") + } catch (e) { + setFetchError( + e instanceof Error ? e.message : t("channels.loadError"), + ) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { + fetchChannels() + }, [fetchChannels]) + + const handleToggle = async (name: string, enabled: boolean) => { + setTogglingChannel(name) + try { + await toggleChannel(name, enabled) + await fetchChannels() + } catch { + // Refresh to show actual state + await fetchChannels() + } finally { + setTogglingChannel(null) + } + } + + const filtered = search + ? channels.filter( + (ch) => + ch.display_name.toLowerCase().includes(search.toLowerCase()) || + ch.name.toLowerCase().includes(search.toLowerCase()), + ) + : channels + + const enabledCount = channels.filter((ch) => ch.enabled).length + + return ( +
+ 0 ? ( + + {enabledCount} {t("channels.header.enabled")} + + ) : undefined + } + /> + +
+ {loading ? ( +
+
+
+ ) : fetchError ? ( +
+

{fetchError}

+
+ ) : ( +
+

+ {t("channels.description")} +

+ + {channels.length > 6 && ( +
+ + setSearch(e.target.value)} + placeholder={t("channels.search")} + className="pl-9" + /> +
+ )} + +
+ {filtered.map((channel) => ( + + ))} +
+ + {filtered.length === 0 && search && ( +

+ {t("channels.noResults")} +

+ )} +
+ )} +
+ + setEditingChannel(null)} + onSaved={fetchChannels} + /> +
+ ) +} diff --git a/web/frontend/src/components/channels/edit-channel-sheet.tsx b/web/frontend/src/components/channels/edit-channel-sheet.tsx new file mode 100644 index 000000000..ae079b174 --- /dev/null +++ b/web/frontend/src/components/channels/edit-channel-sheet.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ChannelInfo } from "@/api/channels" +import { updateChannel } from "@/api/channels" +import { DiscordForm } from "@/components/channels/channel-forms/discord-form" +import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" +import { GenericForm } from "@/components/channels/channel-forms/generic-form" +import { SlackForm } from "@/components/channels/channel-forms/slack-form" +import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +interface EditChannelSheetProps { + channel: ChannelInfo | null + open: boolean + onClose: () => void + onSaved: () => void +} + +// Map of secret config keys to their edit-buffer keys. +// When editing, we use _token etc. to avoid overwriting with masked values. +const SECRET_FIELD_MAP: Record = { + token: "_token", + app_secret: "_app_secret", + client_secret: "_client_secret", + corp_secret: "_corp_secret", + channel_secret: "_channel_secret", + channel_access_token: "_channel_access_token", + access_token: "_access_token", + bot_token: "_bot_token", + app_token: "_app_token", + encoding_aes_key: "_encoding_aes_key", + encrypt_key: "_encrypt_key", + verification_token: "_verification_token", +} + +function buildEditConfig(config: Record): Record { + const edit: Record = { ...config } + // Initialize edit buffer keys for secrets as empty (user fills new values) + for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { + if (secretKey in config) { + edit[SECRET_FIELD_MAP[secretKey]] = "" + } + } + return edit +} + +function buildSavePayload( + channel: ChannelInfo, + editConfig: Record, +): Record { + const payload: Record = { enabled: channel.enabled } + + for (const [key, value] of Object.entries(editConfig)) { + // Skip the edit-buffer underscore keys — we use them to populate real keys + if (key.startsWith("_")) continue + // For secret fields, use the edit buffer value (empty means preserve existing) + if (key in SECRET_FIELD_MAP) { + const editKey = SECRET_FIELD_MAP[key] + payload[key] = editConfig[editKey] ?? "" + } else { + payload[key] = value + } + } + + return payload +} + +export function EditChannelSheet({ + channel, + open, + onClose, + onSaved, +}: EditChannelSheetProps) { + const { t } = useTranslation() + const [editConfig, setEditConfig] = useState>({}) + const [saving, setSaving] = useState(false) + const [serverError, setServerError] = useState("") + + useEffect(() => { + if (channel) { + setEditConfig(buildEditConfig(channel.config)) + setServerError("") + } + }, [channel]) + + const handleChange = useCallback((key: string, value: any) => { + setEditConfig((prev) => ({ ...prev, [key]: value })) + }, []) + + const handleSave = async () => { + if (!channel) return + setSaving(true) + setServerError("") + try { + await updateChannel(channel.name, buildSavePayload(channel, editConfig)) + onSaved() + onClose() + } catch (e) { + setServerError( + e instanceof Error ? e.message : t("channels.edit.saveError"), + ) + } finally { + setSaving(false) + } + } + + const renderForm = () => { + if (!channel) return null + const isEdit = channel.configured + + switch (channel.name) { + case "telegram": + return ( + + ) + case "discord": + return ( + + ) + case "slack": + return ( + + ) + case "feishu": + return ( + + ) + default: + return ( + + ) + } + } + + return ( + !v && onClose()}> + + + + {t("channels.edit.title", { + name: channel?.display_name ?? "", + })} + + + {t("channels.edit.description")} + + + +
{renderForm()}
+ + {serverError && ( +

{serverError}

+ )} + + + + + +
+
+ ) +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 73ed46685..475a2c096 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -5,6 +5,7 @@ "models": "Models", "credentials": "Credentials", "services": "Services", + "channels": "Channels", "config": "Config", "logs": "Logs" }, @@ -215,6 +216,47 @@ "saveError": "Failed to save" } }, + "channels": { + "description": "Configure messaging channels to connect your AI agent to chat platforms.", + "loadError": "Failed to load channels", + "search": "Search channels...", + "noResults": "No channels match your search.", + "header": { + "enabled": "enabled" + }, + "status": { + "configured": "Configured", + "unconfigured": "Not configured" + }, + "action": { + "configure": "Configure" + }, + "field": { + "token": "Bot Token", + "tokenPlaceholder": "Enter bot token", + "botToken": "Bot Token", + "appToken": "App Token", + "appId": "App ID", + "appSecret": "App Secret", + "verificationToken": "Verification Token", + "encryptKey": "Encrypt Key", + "baseUrl": "API Base URL", + "proxy": "HTTP Proxy", + "proxyHint": "Optional. e.g. http://127.0.0.1:7890", + "allowFrom": "Allow From", + "allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.", + "allowFromPlaceholder": "e.g. 123456, 789012", + "secretPlaceholder": "Enter secret", + "secretPlaceholderSet": "Leave blank to keep existing", + "secretHintSet": "A value is already set. Leave blank to keep it unchanged." + }, + "edit": { + "title": "Configure {{name}}", + "description": "Set up credentials and options for this channel.", + "saveError": "Failed to save channel configuration", + "saving": "Saving..." + } + }, "pages": { "providers": { "description": "Manage AI model providers and configurations." diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 0fe345a0b..f2d0384e1 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -5,6 +5,7 @@ "models": "模型", "credentials": "凭据", "services": "服务", + "channels": "频道", "config": "配置", "logs": "日志" }, @@ -215,6 +216,47 @@ "saveError": "保存失败" } }, + "channels": { + "description": "配置消息频道,将 AI 助手连接到各聊天平台。", + "loadError": "加载频道列表失败", + "search": "搜索频道...", + "noResults": "没有匹配的频道。", + "header": { + "enabled": "已启用" + }, + "status": { + "configured": "已配置", + "unconfigured": "未配置" + }, + "action": { + "configure": "配置" + }, + "field": { + "token": "Bot Token", + "tokenPlaceholder": "输入 Bot Token", + "botToken": "Bot Token", + "appToken": "App Token", + "appId": "App ID", + "appSecret": "App Secret", + "verificationToken": "Verification Token", + "encryptKey": "Encrypt Key", + "baseUrl": "API Base URL", + "proxy": "HTTP 代理", + "proxyHint": "可选。例如 http://127.0.0.1:7890", + "allowFrom": "允许来源", + "allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。", + "allowFromPlaceholder": "例如 123456, 789012", + "secretPlaceholder": "输入密钥", + "secretPlaceholderSet": "留空保持原有值不变", + "secretHintSet": "已设置密钥,留空表示不修改。" + }, + "edit": { + "title": "配置 {{name}}", + "description": "设置此频道的凭据和选项。", + "saveError": "保存频道配置失败", + "saving": "保存中..." + } + }, "pages": { "providers": { "description": "管理各个 AI 模型服务商的接入配置。" diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index ec661f1ee..298d71b24 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as ModelsRouteImport } from './routes/models' import { Route as LogsRouteImport } from './routes/logs' import { Route as CredentialsRouteImport } from './routes/credentials' import { Route as ConfigRouteImport } from './routes/config' +import { Route as ChannelsRouteImport } from './routes/channels' import { Route as IndexRouteImport } from './routes/index' const ProvidersRoute = ProvidersRouteImport.update({ @@ -41,6 +42,11 @@ const ConfigRoute = ConfigRouteImport.update({ path: '/config', getParentRoute: () => rootRouteImport, } as any) +const ChannelsRoute = ChannelsRouteImport.update({ + id: '/channels', + path: '/channels', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -49,6 +55,7 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/channels': typeof ChannelsRoute '/config': typeof ConfigRoute '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute @@ -57,6 +64,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/channels': typeof ChannelsRoute '/config': typeof ConfigRoute '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute @@ -66,6 +74,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/channels': typeof ChannelsRoute '/config': typeof ConfigRoute '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute @@ -76,16 +85,25 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/channels' | '/config' | '/credentials' | '/logs' | '/models' | '/providers' fileRoutesByTo: FileRoutesByTo - to: '/' | '/config' | '/credentials' | '/logs' | '/models' | '/providers' + to: + | '/' + | '/channels' + | '/config' + | '/credentials' + | '/logs' + | '/models' + | '/providers' id: | '__root__' | '/' + | '/channels' | '/config' | '/credentials' | '/logs' @@ -95,6 +113,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ChannelsRoute: typeof ChannelsRoute ConfigRoute: typeof ConfigRoute CredentialsRoute: typeof CredentialsRoute LogsRoute: typeof LogsRoute @@ -139,6 +158,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConfigRouteImport parentRoute: typeof rootRouteImport } + '/channels': { + id: '/channels' + path: '/channels' + fullPath: '/channels' + preLoaderRoute: typeof ChannelsRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -151,6 +177,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ChannelsRoute: ChannelsRoute, ConfigRoute: ConfigRoute, CredentialsRoute: CredentialsRoute, LogsRoute: LogsRoute, diff --git a/web/frontend/src/routes/channels.tsx b/web/frontend/src/routes/channels.tsx new file mode 100644 index 000000000..762ae646e --- /dev/null +++ b/web/frontend/src/routes/channels.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { ChannelsPage } from "@/components/channels/channels-page" + +export const Route = createFileRoute("/channels")({ + component: ChannelsPage, +})