diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index 93e59b3ff..507882823 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -2,667 +2,46 @@ package api import ( "encoding/json" - "fmt" - "io" "net/http" - "strings" - - "github.com/sipeed/picoclaw/pkg/config" ) -// registerChannelRoutes binds channel management endpoints to the ServeMux. +type channelCatalogItem struct { + Name string `json:"name"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant,omitempty"` +} + +var channelCatalog = []channelCatalogItem{ + {Name: "telegram", ConfigKey: "telegram"}, + {Name: "discord", ConfigKey: "discord"}, + {Name: "slack", ConfigKey: "slack"}, + {Name: "feishu", ConfigKey: "feishu"}, + {Name: "dingtalk", ConfigKey: "dingtalk"}, + {Name: "line", ConfigKey: "line"}, + {Name: "qq", ConfigKey: "qq"}, + {Name: "onebot", ConfigKey: "onebot"}, + {Name: "wecom", ConfigKey: "wecom"}, + {Name: "wecom_app", ConfigKey: "wecom_app"}, + {Name: "wecom_aibot", ConfigKey: "wecom_aibot"}, + {Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, + {Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, + {Name: "pico", ConfigKey: "pico"}, + {Name: "maixcam", ConfigKey: "maixcam"}, + {Name: "matrix", ConfigKey: "matrix"}, + {Name: "irc", ConfigKey: "irc"}, +} + +// registerChannelRoutes binds read-only channel catalog 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) + mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog) } -// 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. +// handleListChannelCatalog returns the channels supported by backend. // -// 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) - } - +// GET /api/channels/catalog +func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "channels": channels, + "channels": channelCatalog, }) } - -// 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 errs := validateConfig(cfg); len(errs) > 0 { - writeValidationErrors(w, 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"}) -} - -// 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 - } - if req.Enabled == nil { - http.Error(w, "Missing required field: enabled", 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 errs := validateConfig(cfg); len(errs) > 0 { - writeValidationErrors(w, 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"}) -} - -func isValidChannel(name string) bool { - for _, m := range channelRegistry { - if m.Name == name { - return true - } - } - return false -} - -func writeValidationErrors(w http.ResponseWriter, errs []string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]any{ - "status": "validation_error", - "errors": errs, - }) -} - -// extractChannelInfo returns enabled, configured status and masked config for a channel. -func extractChannelInfo(name string, ch *config.ChannelsConfig) (bool, bool, map[string]any) { - var enabled, configured bool - 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 enabled, configured, cfg -} - -// 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 - } - getStringArray := func(key string) ([]string, bool) { - v, ok := incoming[key] - if !ok { - return nil, false - } - - switch arr := v.(type) { - case []any: - result := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - s = strings.TrimSpace(s) - if s != "" { - result = append(result, s) - } - } - } - return result, true - case []string: - result := make([]string, 0, len(arr)) - for _, s := range arr { - s = strings.TrimSpace(s) - if s != "" { - result = append(result, s) - } - } - return result, true - case string: - if strings.TrimSpace(arr) == "" { - return []string{}, true - } - parts := strings.Split(arr, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - return result, true - default: - return nil, false - } - } - 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 origins, ok := getStringArray("allow_origins"); ok { - 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/gateway.go b/web/backend/api/gateway.go index f4b953929..86f580269 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -84,8 +84,8 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { return false, "no default model configured", nil } - modelCfg, err := cfg.GetModelConfig(modelName) - if err != nil { + modelCfg := lookupModelConfig(cfg, modelName) + if modelCfg == nil { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } @@ -98,6 +98,14 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { return true, "", nil } +func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig { + modelCfg, err := cfg.GetModelConfig(modelName) + if err != nil { + return nil + } + return modelCfg +} + func isGatewayProcessAliveLocked() bool { return gateway.cmd != nil && gateway.cmd.Process != nil && diff --git a/web/backend/api/router.go b/web/backend/api/router.go index a13d71a3a..8b8cc4748 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -42,6 +42,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Model list management h.registerModelRoutes(mux) - // Channel management + // Channel catalog (for frontend navigation/config pages) h.registerChannelRoutes(mux) } diff --git a/web/frontend/public/lark.svg b/web/frontend/public/lark.svg new file mode 100644 index 000000000..0761f278f --- /dev/null +++ b/web/frontend/public/lark.svg @@ -0,0 +1 @@ + diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index f4b7d343e..ecd77632c 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -1,21 +1,22 @@ -// API client for channel management. +// API client for channels navigation and channel-specific config flows. export type ChannelConfig = Record +export type AppConfig = Record -export interface ChannelInfo { +export interface SupportedChannel { name: string - display_name: string - enabled: boolean - configured: boolean - config: ChannelConfig + display_name?: string + config_key: string + variant?: string } -interface ChannelsListResponse { - channels: ChannelInfo[] +interface ChannelsCatalogResponse { + channels: SupportedChannel[] } -interface ChannelActionResponse { +interface ConfigActionResponse { status: string + errors?: string[] } const BASE_URL = "" @@ -23,35 +24,42 @@ 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}`) + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + status?: string + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // Keep default fallback message if response body is not JSON. + } + throw new Error(message) } return res.json() as Promise } -export async function getChannels(): Promise { - return request("/api/channels") +export async function getChannelsCatalog(): Promise { + return request("/api/channels/catalog") } -export async function updateChannel( - name: string, - config: ChannelConfig, -): Promise { - return request(`/api/channels/${name}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), - }) +export async function getAppConfig(): Promise { + return request("/api/config") } -export async function toggleChannel( - name: string, - enabled: boolean, -): Promise { - return request(`/api/channels/${name}/toggle`, { +export async function patchAppConfig( + patch: Record, +): Promise { + return request("/api/config", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), + body: JSON.stringify(patch), }) } -export type { ChannelsListResponse, ChannelActionResponse } +export type { ChannelsCatalogResponse, ConfigActionResponse } diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index b541755f8..8c4d4c0a5 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -1,10 +1,11 @@ import { IconChevronRight } from "@tabler/icons-react" import { IconAtom, + IconChevronsDown, + IconChevronsUp, IconKey, IconListDetails, IconMessageCircle, - IconPlug, IconSettings, } from "@tabler/icons-react" import { Link, useRouterState } from "@tanstack/react-router" @@ -27,34 +28,34 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar" +import { useSidebarChannels } from "@/hooks/use-sidebar-channels" -// Navigation data with real routes -const navGroups = [ +interface NavItem { + title: string + url: string + icon: React.ComponentType<{ className?: string }> + translateTitle?: boolean +} + +interface NavGroup { + label: string + defaultOpen: boolean + items: NavItem[] + isChannelsGroup?: boolean +} + +const baseNavGroups: Omit[] = [ { label: "navigation.chat", defaultOpen: true, - items: [{ title: "navigation.chat", url: "/", icon: IconMessageCircle }], }, { label: "navigation.model_group", defaultOpen: true, - items: [ - { title: "navigation.models", url: "/models", icon: IconAtom }, - { title: "navigation.credentials", url: "/credentials", icon: IconKey }, - ], - }, - { - label: "navigation.channels_group", - defaultOpen: true, - items: [{ title: "navigation.channels", url: "/channels", icon: IconPlug }], }, { label: "navigation.services", defaultOpen: true, - items: [ - { title: "navigation.config", url: "/config", icon: IconSettings }, - { title: "navigation.logs", url: "/logs", icon: IconListDetails }, - ], }, ] @@ -62,6 +63,73 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const routerState = useRouterState() const { t } = useTranslation() const currentPath = routerState.location.pathname + const { + channelItems, + hasMoreChannels, + showAllChannels, + toggleShowAllChannels, + } = useSidebarChannels({ t }) + + const navGroups: NavGroup[] = React.useMemo(() => { + return [ + { + ...baseNavGroups[0], + items: [ + { + title: "navigation.chat", + url: "/", + icon: IconMessageCircle, + translateTitle: true, + }, + ], + }, + { + ...baseNavGroups[1], + items: [ + { + title: "navigation.models", + url: "/models", + icon: IconAtom, + translateTitle: true, + }, + { + title: "navigation.credentials", + url: "/credentials", + icon: IconKey, + translateTitle: true, + }, + ], + }, + { + label: "navigation.channels_group", + defaultOpen: true, + items: channelItems.map((item) => ({ + title: item.title, + url: item.url, + icon: item.icon, + translateTitle: false, + })), + isChannelsGroup: true, + }, + { + ...baseNavGroups[2], + items: [ + { + title: "navigation.config", + url: "/config", + icon: IconSettings, + translateTitle: true, + }, + { + title: "navigation.logs", + url: "/logs", + icon: IconListDetails, + translateTitle: true, + }, + ], + }, + ] + }, [channelItems]) return ( ) { isActive ? "opacity-100" : "opacity-80" } > - {t(item.title)} + {item.translateTitle === false + ? item.title + : t(item.title)} ) })} + {group.isChannelsGroup && hasMoreChannels && ( + + + {showAllChannels ? ( + + ) : ( + + )} + + {showAllChannels + ? t("navigation.show_less_channels") + : t("navigation.show_more_channels")} + + + + )} diff --git a/web/frontend/src/components/channels/channel-card.tsx b/web/frontend/src/components/channels/channel-card.tsx deleted file mode 100644 index 5cece1a33..000000000 --- a/web/frontend/src/components/channels/channel-card.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx new file mode 100644 index 000000000..faa0f4043 --- /dev/null +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -0,0 +1,539 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useAtomValue } from "jotai" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type ChannelConfig, + type SupportedChannel, + getAppConfig, + getChannelsCatalog, + patchAppConfig, +} from "@/api/channels" +import { getChannelDisplayName } from "@/components/channels/channel-display-name" +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 { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { gatewayAtom } from "@/store/gateway" + +interface ChannelConfigPageProps { + channelName: string +} + +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", + password: "_password", + nickserv_password: "_nickserv_password", + sasl_password: "_sasl_password", +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asBool(value: unknown): boolean { + return value === true +} + +function buildEditConfig(config: ChannelConfig): ChannelConfig { + const edit: ChannelConfig = { ...config } + for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { + if (secretKey in config) { + edit[SECRET_FIELD_MAP[secretKey]] = "" + } + } + return edit +} + +function normalizeConfig( + channel: SupportedChannel, + rawConfig: ChannelConfig, +): ChannelConfig { + const config = { ...rawConfig } + if (channel.name === "whatsapp_native") { + config.use_native = true + } + if (channel.name === "whatsapp") { + config.use_native = false + } + return config +} + +function buildSavePayload( + channel: SupportedChannel, + editConfig: ChannelConfig, + enabled: boolean, +): ChannelConfig { + const payload: ChannelConfig = { enabled } + + for (const [key, value] of Object.entries(editConfig)) { + if (key.startsWith("_")) continue + if (key === "enabled") continue + + if (key in SECRET_FIELD_MAP) { + const editKey = SECRET_FIELD_MAP[key] + const incoming = asString(editConfig[editKey]) + payload[key] = incoming !== "" ? incoming : value + continue + } + + payload[key] = value + } + + if (channel.name === "whatsapp_native") { + payload.use_native = true + } + if (channel.name === "whatsapp") { + payload.use_native = false + } + + return payload +} + +function isConfigured( + channel: SupportedChannel, + config: ChannelConfig, +): boolean { + switch (channel.name) { + case "telegram": + return asString(config.token) !== "" + case "discord": + return asString(config.token) !== "" + case "slack": + return asString(config.bot_token) !== "" + case "feishu": + return ( + asString(config.app_id) !== "" && asString(config.app_secret) !== "" + ) + case "dingtalk": + return ( + asString(config.client_id) !== "" && + asString(config.client_secret) !== "" + ) + case "line": + return asString(config.channel_access_token) !== "" + case "qq": + return ( + asString(config.app_id) !== "" && asString(config.app_secret) !== "" + ) + case "onebot": + return asString(config.ws_url) !== "" + case "wecom": + return asString(config.token) !== "" + case "wecom_app": + return ( + asString(config.corp_id) !== "" && asString(config.corp_secret) !== "" + ) + case "wecom_aibot": + return asString(config.token) !== "" + case "whatsapp": + return asString(config.bridge_url) !== "" + case "whatsapp_native": + return asBool(config.use_native) + case "pico": + return asString(config.token) !== "" + case "maixcam": + return asString(config.host) !== "" + case "matrix": + return ( + asString(config.homeserver) !== "" && + asString(config.user_id) !== "" && + asString(config.access_token) !== "" + ) + case "irc": + return asString(config.server) !== "" + default: + return false + } +} + +function getRequiredFieldKeys(channelName: string): string[] { + switch (channelName) { + case "telegram": + return ["token"] + case "discord": + return ["token"] + case "slack": + return ["bot_token"] + case "feishu": + return ["app_id", "app_secret"] + case "dingtalk": + return ["client_id", "client_secret"] + case "line": + return ["channel_secret", "channel_access_token"] + case "qq": + return ["app_id", "app_secret"] + case "onebot": + return ["ws_url"] + case "wecom": + return ["token"] + case "wecom_app": + return ["corp_id", "corp_secret"] + case "wecom_aibot": + return ["token"] + case "whatsapp": + return ["bridge_url"] + case "pico": + return ["token"] + case "maixcam": + return ["host"] + case "matrix": + return ["homeserver", "user_id", "access_token"] + case "irc": + return ["server"] + default: + return [] + } +} + +function isMissingRequiredValue(value: unknown): boolean { + if (value === null || value === undefined) { + return true + } + if (typeof value === "string") { + return value.trim() === "" + } + if (Array.isArray(value)) { + return value.length === 0 + } + return false +} + +function getChannelDocSlug(channelName: string): string { + return channelName.replaceAll("_", "-") +} + +const CHANNELS_WITHOUT_DOCS = new Set([ + "pico", + "wecom", + "matrix", + "irc", + "whatsapp", + "whatsapp_native", +]) + +export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { + const { t, i18n } = useTranslation() + const gateway = useAtomValue(gatewayAtom) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [fetchError, setFetchError] = useState("") + const [serverError, setServerError] = useState("") + const [fieldErrors, setFieldErrors] = useState>({}) + + const [channel, setChannel] = useState(null) + const [baseConfig, setBaseConfig] = useState({}) + const [editConfig, setEditConfig] = useState({}) + const [enabled, setEnabled] = useState(false) + + const loadData = useCallback(async () => { + setLoading(true) + try { + const [catalog, appConfig] = await Promise.all([ + getChannelsCatalog(), + getAppConfig(), + ]) + const matched = + catalog.channels.find((item) => item.name === channelName) ?? null + + if (!matched) { + setChannel(null) + setFetchError( + t("channels.page.notFound", { + name: channelName, + }), + ) + return + } + + const channelsConfig = asRecord(asRecord(appConfig).channels) + const raw = asRecord(channelsConfig[matched.config_key]) + const normalized = normalizeConfig(matched, raw) + + setChannel(matched) + setBaseConfig(normalized) + setEditConfig(buildEditConfig(normalized)) + setEnabled(asBool(normalized.enabled)) + setFetchError("") + setServerError("") + setFieldErrors({}) + } catch (e) { + setFetchError(e instanceof Error ? e.message : t("channels.loadError")) + } finally { + setLoading(false) + } + }, [channelName, t]) + + useEffect(() => { + loadData() + }, [loadData]) + + const previousGatewayStatusRef = useRef(gateway.status) + useEffect(() => { + const previousStatus = previousGatewayStatusRef.current + if (previousStatus !== "running" && gateway.status === "running") { + void loadData() + } + previousGatewayStatusRef.current = gateway.status + }, [gateway.status, loadData]) + + const savePayload = useMemo(() => { + if (!channel) return null + return buildSavePayload(channel, editConfig, enabled) + }, [channel, editConfig, enabled]) + + const configured = useMemo(() => { + if (!channel || !savePayload) return false + return isConfigured(channel, savePayload) + }, [channel, savePayload]) + + const docsUrl = useMemo(() => { + if (!channel) return "" + if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" + const language = ( + i18n.resolvedLanguage ?? + i18n.language ?? + "" + ).toLowerCase() + const base = language.startsWith("zh") + ? "https://docs.picoclaw.io/zh-Hans/docs/channels" + : "https://docs.picoclaw.io/docs/channels" + return `${base}/${getChannelDocSlug(channel.name)}` + }, [channel, i18n.language, i18n.resolvedLanguage]) + + const channelDisplayName = useMemo(() => { + if (!channel) return channelName + return getChannelDisplayName(channel, t) + }, [channel, channelName, t]) + + const hiddenKeys = useMemo(() => { + if (!channel) return [] + if (channel.name === "whatsapp") { + return ["use_native"] + } + if (channel.name === "whatsapp_native") { + return ["use_native", "bridge_url"] + } + return [] + }, [channel]) + const requiredKeys = useMemo( + () => getRequiredFieldKeys(channelName), + [channelName], + ) + + const handleChange = useCallback((key: string, value: unknown) => { + const normalizedKey = key.startsWith("_") ? key.slice(1) : key + setEditConfig((prev) => ({ ...prev, [key]: value })) + setFieldErrors((prev) => { + if (!(key in prev) && !(normalizedKey in prev)) { + return prev + } + const next = { ...prev } + delete next[key] + delete next[normalizedKey] + return next + }) + }, []) + + const handleReset = () => { + setEditConfig(buildEditConfig(baseConfig)) + setEnabled(asBool(baseConfig.enabled)) + setServerError("") + setFieldErrors({}) + } + + const handleSave = async () => { + if (!channel || !savePayload) return + + const missingRequiredFields = requiredKeys.filter((key) => + isMissingRequiredValue(savePayload[key]), + ) + if (missingRequiredFields.length > 0) { + const requiredFieldError = t("channels.validation.requiredField") + const nextFieldErrors: Record = {} + for (const key of missingRequiredFields) { + nextFieldErrors[key] = requiredFieldError + } + setFieldErrors(nextFieldErrors) + setServerError("") + return + } + + setSaving(true) + setServerError("") + setFieldErrors({}) + try { + await patchAppConfig({ + channels: { + [channel.config_key]: savePayload, + }, + }) + toast.success(t("channels.page.saveSuccess")) + await loadData() + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + toast.error(message) + } finally { + setSaving(false) + } + } + + const renderForm = () => { + if (!channel) return null + const isEdit = configured + + switch (channel.name) { + case "telegram": + return ( + + ) + case "discord": + return ( + + ) + case "slack": + return ( + + ) + case "feishu": + return ( + + ) + default: + return ( + + ) + } + } + + return ( +
+ + {enabled ? ( + + {t("channels.page.enabled")} + + ) : configured ? ( + + {t("channels.status.configured")} + + ) : null} +
+ ) : undefined + } + /> + +
+ {loading ? ( +
+ +
+ ) : fetchError ? ( +
+ {fetchError} +
+ ) : ( +
+
+

+ {t("channels.edit.title", { + name: channelDisplayName, + })} +

+ {channel && docsUrl && ( + + {t("channels.page.docLink")} + + )} +
+ +
+

+ {t("channels.page.enableLabel")} +

+ +
+ + {renderForm()} + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+ )} +
+ + ) +} diff --git a/web/frontend/src/components/channels/channel-display-name.ts b/web/frontend/src/components/channels/channel-display-name.ts new file mode 100644 index 000000000..fe70f5f5e --- /dev/null +++ b/web/frontend/src/components/channels/channel-display-name.ts @@ -0,0 +1,23 @@ +import type { TFunction } from "i18next" + +import type { SupportedChannel } from "@/api/channels" + +export function getChannelDisplayName( + channel: Pick, + t: TFunction, +): string { + const key = `channels.name.${channel.name}` + const translated = t(key) + if (translated !== key) { + return translated + } + + if (channel.display_name && channel.display_name.trim() !== "") { + return channel.display_name + } + + return channel.name + .split("_") + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" ") +} diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index f602f2a82..e59d9483a 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,10 +1,11 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder" import { - AdvancedSection, Field, KeyInput, + SwitchCardField, } from "@/components/models/shared-form" import { Input } from "@/components/ui/input" @@ -12,6 +13,7 @@ interface DiscordFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean + fieldErrors?: Record } function asString(value: unknown): string { @@ -23,60 +25,89 @@ function asStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string") } -export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) { +function asBool(value: unknown): boolean { + return value === true +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +export function DiscordForm({ + config, + onChange, + isEdit, + fieldErrors = {}, +}: DiscordFormProps) { const { t } = useTranslation() + const groupTriggerConfig = asRecord(config.group_trigger) + const tokenExtraHint = + isEdit && asString(config.token) + ? ` ${t("channels.field.secretHintSet")}` + : "" return (
onChange("_token", v)} - placeholder={ - isEdit && asString(config.token) - ? t("channels.field.secretPlaceholderSet") - : t("channels.field.tokenPlaceholder") - } + placeholder={maskedSecretPlaceholder( + config.token, + 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")} - /> - - + + 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")} + /> + + + { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.mentionOnly")} + />
) } diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx index f6cd1ebe2..2fb4a0153 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -1,17 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { - AdvancedSection, - Field, - KeyInput, -} from "@/components/models/shared-form" +import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder" +import { Field, KeyInput } from "@/components/models/shared-form" import { Input } from "@/components/ui/input" interface FeishuFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean + fieldErrors?: Record } function asString(value: unknown): string { @@ -23,12 +21,34 @@ function asStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string") } -export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) { +export function FeishuForm({ + config, + onChange, + isEdit, + fieldErrors = {}, +}: FeishuFormProps) { const { t } = useTranslation() + const appSecretExtraHint = + isEdit && asString(config.app_secret) + ? ` ${t("channels.field.secretHintSet")}` + : "" + const verificationExtraHint = + isEdit && asString(config.verification_token) + ? ` ${t("channels.field.secretHintSet")}` + : "" + const encryptExtraHint = + isEdit && asString(config.encrypt_key) + ? ` ${t("channels.field.secretHintSet")}` + : "" return (
- + onChange("app_id", e.target.value)} @@ -38,65 +58,64 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) { onChange("_app_secret", v)} - placeholder={ - isEdit && asString(config.app_secret) - ? t("channels.field.secretPlaceholderSet") - : t("channels.field.secretPlaceholder") - } + placeholder={maskedSecretPlaceholder( + config.app_secret, + t("channels.field.secretPlaceholder"), + )} /> - - - onChange("_verification_token", v)} - placeholder={ - isEdit && asString(config.verification_token) - ? t("channels.field.secretPlaceholderSet") - : t("channels.field.secretPlaceholder") - } - /> - - - onChange("_encrypt_key", v)} - placeholder={ - isEdit && asString(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")} - /> - - + + onChange("_verification_token", v)} + placeholder={maskedSecretPlaceholder( + config.verification_token, + t("channels.field.secretPlaceholder"), + )} + /> + + + onChange("_encrypt_key", v)} + placeholder={maskedSecretPlaceholder( + config.encrypt_key, + 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 index 5ee753f4b..57cd1bd06 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -1,15 +1,21 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { Field, KeyInput } from "@/components/models/shared-form" +import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder" +import { + Field, + KeyInput, + SwitchCardField, +} from "@/components/models/shared-form" import { Input } from "@/components/ui/input" -import { Switch } from "@/components/ui/switch" interface GenericFormProps { - channelName: string config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean + hiddenKeys?: string[] + requiredKeys?: string[] + fieldErrors?: Record } // Secret field names that should use masked input. @@ -26,6 +32,9 @@ const SECRET_FIELDS = new Set([ "encoding_aes_key", "encrypt_key", "verification_token", + "password", + "nickserv_password", + "sasl_password", ]) // Fields to skip in the generic form (handled by enabled toggle or internal). @@ -36,6 +45,7 @@ const OBJECT_FIELDS = new Set([ "group_trigger", "typing", "placeholder", + "allow_token_query", "allow_from", "allow_origins", ]) @@ -47,6 +57,11 @@ function formatLabel(key: string): string { .join(" ") } +function formatSentenceFieldName(key: string): string { + const label = formatLabel(key) + return label.charAt(0).toLowerCase() + label.slice(1) +} + function asString(value: unknown): string { return typeof value === "string" ? value : "" } @@ -56,36 +71,106 @@ function asStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string") } -export function GenericForm({ config, onChange, isEdit }: GenericFormProps) { +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function asBool(value: unknown): boolean { + return value === true +} + +export function GenericForm({ + config, + onChange, + isEdit, + hiddenKeys = [], + requiredKeys = [], + fieldErrors = {}, +}: GenericFormProps) { const { t } = useTranslation() + const hiddenFieldSet = new Set(hiddenKeys) + const requiredFieldSet = new Set(requiredKeys) + const groupTriggerConfig = asRecord(config.group_trigger) + const typingConfig = asRecord(config.typing) + const placeholderConfig = asRecord(config.placeholder) + const placeholderEnabled = asBool(placeholderConfig.enabled) const fields = Object.keys(config).filter( - (k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && !OBJECT_FIELDS.has(k), + (k) => + !k.startsWith("_") && + !SKIP_FIELDS.has(k) && + !OBJECT_FIELDS.has(k) && + !hiddenFieldSet.has(k), ) + const buildHint = (key: string): string => { + const descriptions: Record = { + ws_url: t("channels.form.desc.wsUrl"), + reconnect_interval: t("channels.form.desc.reconnectInterval"), + bridge_url: t("channels.form.desc.bridgeUrl"), + session_store_path: t("channels.form.desc.sessionStorePath"), + use_native: t("channels.form.desc.useNative"), + host: t("channels.form.desc.host"), + port: t("channels.form.desc.port"), + homeserver: t("channels.form.desc.homeserver"), + user_id: t("channels.form.desc.userId"), + device_id: t("channels.form.desc.deviceId"), + join_on_invite: t("channels.form.desc.joinOnInvite"), + app_id: t("channels.form.desc.appId"), + client_id: t("channels.form.desc.clientId"), + corp_id: t("channels.form.desc.corpId"), + agent_id: t("channels.form.desc.agentId"), + webhook_url: t("channels.form.desc.webhookUrl"), + webhook_host: t("channels.form.desc.webhookHost"), + webhook_port: t("channels.form.desc.webhookPort"), + webhook_path: t("channels.form.desc.webhookPath"), + reply_timeout: t("channels.form.desc.replyTimeout"), + max_steps: t("channels.form.desc.maxSteps"), + welcome_message: t("channels.form.desc.welcomeMessage"), + allow_token_query: t("channels.form.desc.allowTokenQuery"), + ping_interval: t("channels.form.desc.pingInterval"), + read_timeout: t("channels.form.desc.readTimeout"), + write_timeout: t("channels.form.desc.writeTimeout"), + max_connections: t("channels.form.desc.maxConnections"), + server: t("channels.form.desc.server"), + tls: t("channels.form.desc.tls"), + nick: t("channels.form.desc.nick"), + user: t("channels.form.desc.user"), + real_name: t("channels.form.desc.realName"), + channels: t("channels.form.desc.channels"), + request_caps: t("channels.form.desc.requestCaps"), + } + return ( + descriptions[key] ?? + t("channels.form.desc.genericField", { + field: formatSentenceFieldName(key), + }) + ) + } + return (
{fields.map((key) => { + const isRequired = requiredFieldSet.has(key) if (SECRET_FIELDS.has(key)) { const editKey = `_${key}` + const extraHint = + isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : "" return ( onChange(editKey, v)} - placeholder={ - isEdit && Boolean(config[key]) - ? t("channels.field.secretPlaceholderSet") - : "" - } + placeholder={maskedSecretPlaceholder(config[key])} /> ) @@ -94,20 +179,51 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) { const value = config[key] if (typeof value === "boolean") { return ( - -
- onChange(key, checked)} - aria-label={formatLabel(key)} - /> -
+ onChange(key, checked)} + ariaLabel={formatLabel(key)} + /> + ) + } + + if (Array.isArray(value)) { + return ( + + + onChange( + key, + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + /> ) } return ( - + { @@ -125,10 +241,10 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) { })} {/* Allow From field */} - {config.allow_from !== undefined && ( + {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( )} - {config.allow_origins !== undefined && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) + {config.allow_origins !== undefined && + !hiddenFieldSet.has("allow_origins") && ( + + + onChange( + "allow_origins", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowOriginsPlaceholder")} + /> + + )} + + {config.allow_token_query !== undefined && + !hiddenFieldSet.has("allow_token_query") && ( + + onChange("allow_token_query", checked) } - placeholder={t( - "channels.field.allowOriginsPlaceholder", - "e.g. https://example.com, http://localhost:5173", - )} + ariaLabel={formatLabel("allow_token_query")} /> - + )} + + {config.group_trigger !== undefined && + !hiddenFieldSet.has("group_trigger") && ( + <> + + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + } + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> + + + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + /> + + + )} + + {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( + + onChange("typing", { ...typingConfig, enabled: checked }) + } + ariaLabel={t("channels.field.typingEnabled")} + /> )} + + {config.placeholder !== undefined && + !hiddenFieldSet.has("placeholder") && ( + + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
+ )}
) } diff --git a/web/frontend/src/components/channels/channel-forms/secret-placeholder.ts b/web/frontend/src/components/channels/channel-forms/secret-placeholder.ts new file mode 100644 index 000000000..a2d716b3c --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/secret-placeholder.ts @@ -0,0 +1,10 @@ +export function maskedSecretPlaceholder(value: unknown, fallback = ""): string { + const secret = typeof value === "string" ? value.trim() : "" + if (!secret) { + return fallback + } + + const prefix = secret.slice(0, Math.min(4, secret.length)) + const suffix = secret.slice(-Math.min(3, secret.length)) + return `${prefix}***${suffix}` +} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 2114c4d4a..811ee5128 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,17 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { - AdvancedSection, - Field, - KeyInput, -} from "@/components/models/shared-form" +import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder" +import { Field, KeyInput } from "@/components/models/shared-form" import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean + fieldErrors?: Record } function asString(value: unknown): string { @@ -23,69 +21,66 @@ function asStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string") } -export function SlackForm({ config, onChange, isEdit }: SlackFormProps) { +export function SlackForm({ + config, + onChange, + isEdit, + fieldErrors = {}, +}: SlackFormProps) { const { t } = useTranslation() + const botTokenExtraHint = + isEdit && asString(config.bot_token) + ? ` ${t("channels.field.secretHintSet")}` + : "" + const appTokenExtraHint = + isEdit && asString(config.app_token) + ? ` ${t("channels.field.secretHintSet")}` + : "" return (
onChange("_bot_token", v)} - placeholder={ - isEdit && asString(config.bot_token) - ? t("channels.field.secretPlaceholderSet") - : "xoxb-xxxx" - } + placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")} /> onChange("_app_token", v)} - placeholder={ - isEdit && asString(config.app_token) - ? t("channels.field.secretPlaceholderSet") - : "xapp-xxxx" - } + placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")} /> - - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - - + + + 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 index d73b84a89..3b475bcff 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,10 +1,11 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder" import { - AdvancedSection, Field, KeyInput, + SwitchCardField, } from "@/components/models/shared-form" import { Input } from "@/components/ui/input" @@ -12,6 +13,7 @@ interface TelegramFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean + fieldErrors?: Record } function asString(value: unknown): string { @@ -23,67 +25,127 @@ function asStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string") } -export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) { +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function asBool(value: unknown): boolean { + return value === true +} + +export function TelegramForm({ + config, + onChange, + isEdit, + fieldErrors = {}, +}: TelegramFormProps) { const { t } = useTranslation() + const typingConfig = asRecord(config.typing) + const placeholderConfig = asRecord(config.placeholder) + const placeholderEnabled = asBool(placeholderConfig.enabled) + const tokenExtraHint = + isEdit && asString(config.token) + ? ` ${t("channels.field.secretHintSet")}` + : "" return (
onChange("_token", v)} - placeholder={ - isEdit && asString(config.token) - ? t("channels.field.secretPlaceholderSet") - : t("channels.field.tokenPlaceholder") - } + placeholder={maskedSecretPlaceholder( + config.token, + 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")} - /> - - + + 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")} + /> + + + + onChange("typing", { ...typingConfig, enabled: checked }) + } + ariaLabel={t("channels.field.typingEnabled")} + /> + + + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
) } diff --git a/web/frontend/src/components/channels/channels-page.tsx b/web/frontend/src/components/channels/channels-page.tsx deleted file mode 100644 index 28dc6e944..000000000 --- a/web/frontend/src/components/channels/channels-page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 7a6ea222c..000000000 --- a/web/frontend/src/components/channels/edit-channel-sheet.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { useCallback, useEffect, useState } from "react" -import { useTranslation } from "react-i18next" - -import type { ChannelConfig, 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: ChannelConfig): ChannelConfig { - const edit: ChannelConfig = { ...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: ChannelConfig, -): ChannelConfig { - const payload: ChannelConfig = { 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: unknown) => { - 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/components/models/shared-form.tsx b/web/frontend/src/components/models/shared-form.tsx index 9c51c0c66..e62c5ecb5 100644 --- a/web/frontend/src/components/models/shared-form.tsx +++ b/web/frontend/src/components/models/shared-form.tsx @@ -8,18 +8,24 @@ import { Field as UiField, } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" interface FieldProps { label: string hint?: string + error?: string + required?: boolean children: ReactNode } -export function Field({ label, hint, children }: FieldProps) { +export function Field({ label, hint, error, required, children }: FieldProps) { return (
- {label} + + {label} + {required && *} + {hint && ( {hint} @@ -27,6 +33,11 @@ export function Field({ label, hint, children }: FieldProps) { )}
{children} + {error && ( + + {error} + + )}
) } @@ -65,6 +76,50 @@ export function KeyInput({ value, onChange, placeholder }: KeyInputProps) { ) } +interface SwitchCardFieldProps { + label: string + hint?: string + error?: string + checked: boolean + onCheckedChange: (checked: boolean) => void + ariaLabel?: string + children?: ReactNode +} + +export function SwitchCardField({ + label, + hint, + error, + checked, + onCheckedChange, + ariaLabel, + children, +}: SwitchCardFieldProps) { + return ( +
+
+
+

{label}

+ {hint && ( +

+ {hint} +

+ )} +
+ +
+ {children &&
{children}
} + {error && ( +

{error}

+ )} +
+ ) +} + interface AdvancedSectionProps { children: ReactNode } diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts new file mode 100644 index 000000000..0848af468 --- /dev/null +++ b/web/frontend/src/hooks/use-sidebar-channels.ts @@ -0,0 +1,236 @@ +import { + IconBrandChrome, + IconBrandDingtalk, + IconBrandDiscord, + IconBrandLine, + IconBrandMatrix, + IconBrandQq, + IconBrandSlack, + IconBrandTelegram, + IconBrandWechat, + IconBrandWhatsapp, + IconCamera, + IconMessages, + IconPlug, + IconRobot, +} from "@tabler/icons-react" +import type { TFunction } from "i18next" +import { useAtomValue } from "jotai" +import * as React from "react" + +import { + type AppConfig, + type SupportedChannel, + getAppConfig, + getChannelsCatalog, +} from "@/api/channels" +import { getChannelDisplayName } from "@/components/channels/channel-display-name" +import { gatewayAtom } from "@/store/gateway" + +const DEFAULT_VISIBLE_CHANNELS = 5 +const CHANNEL_IMPORTANCE_ORDER = [ + "discord", + "feishu", + "telegram", + "slack", + "line", + "wecom", + "wecom_app", + "wecom_aibot", + "dingtalk", + "qq", + "onebot", + "matrix", + "pico", + "maixcam", + "irc", + "whatsapp", + "whatsapp_native", +] +const CHANNEL_IMPORTANCE_INDEX = new Map( + CHANNEL_IMPORTANCE_ORDER.map((name, index) => [name, index]), +) + +function IconLark({ className }: { className?: string }) { + return React.createElement("span", { + className, + "aria-hidden": "true", + style: { + display: "inline-block", + backgroundColor: "currentColor", + mask: "url(/lark.svg) center / contain no-repeat", + WebkitMask: "url(/lark.svg) center / contain no-repeat", + } as React.CSSProperties, + }) +} + +const CHANNEL_ICON_MAP: Record< + string, + React.ComponentType<{ className?: string }> +> = { + telegram: IconBrandTelegram, + discord: IconBrandDiscord, + slack: IconBrandSlack, + feishu: IconLark, + dingtalk: IconBrandDingtalk, + line: IconBrandLine, + qq: IconBrandQq, + wecom: IconBrandWechat, + wecom_app: IconBrandWechat, + wecom_aibot: IconBrandWechat, + whatsapp: IconBrandWhatsapp, + whatsapp_native: IconBrandWhatsapp, + matrix: IconBrandMatrix, + maixcam: IconCamera, + onebot: IconRobot, + pico: IconBrandChrome, + irc: IconMessages, +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function isChannelEnabled( + channel: SupportedChannel, + channelsConfig: Record, +): boolean { + const channelConfig = asRecord(channelsConfig[channel.config_key]) + if (channelConfig.enabled !== true) { + return false + } + + // whatsapp / whatsapp_native share one config block and are split by use_native. + if (channel.name === "whatsapp_native") { + return channelConfig.use_native === true + } + if (channel.name === "whatsapp") { + return channelConfig.use_native !== true + } + + return true +} + +function buildChannelEnabledMap( + channels: SupportedChannel[], + appConfig: AppConfig, +): Record { + const channelsConfig = asRecord(asRecord(appConfig).channels) + const result: Record = {} + for (const channel of channels) { + result[channel.name] = isChannelEnabled(channel, channelsConfig) + } + return result +} + +export interface SidebarChannelNavItem { + key: string + title: string + url: string + icon: React.ComponentType<{ className?: string }> +} + +interface UseSidebarChannelsOptions { + t: TFunction +} + +export function useSidebarChannels({ t }: UseSidebarChannelsOptions) { + const gateway = useAtomValue(gatewayAtom) + const [channels, setChannels] = React.useState([]) + const [enabledMap, setEnabledMap] = React.useState>( + {}, + ) + const [showAllChannels, setShowAllChannels] = React.useState(false) + + const reloadChannels = React.useCallback((shouldApply?: () => boolean) => { + Promise.all([ + getChannelsCatalog(), + getAppConfig().catch(() => ({}) as AppConfig), + ]) + .then(([catalog, appConfig]) => { + if (shouldApply && !shouldApply()) { + return + } + setChannels(catalog.channels) + setEnabledMap(buildChannelEnabledMap(catalog.channels, appConfig)) + }) + .catch(() => { + if (shouldApply && !shouldApply()) { + return + } + setChannels([]) + setEnabledMap({}) + }) + }, []) + + React.useEffect(() => { + let active = true + reloadChannels(() => active) + return () => { + active = false + } + }, [reloadChannels]) + + const previousGatewayStatusRef = React.useRef(gateway.status) + React.useEffect(() => { + const previousStatus = previousGatewayStatusRef.current + if (previousStatus !== "running" && gateway.status === "running") { + reloadChannels() + } + previousGatewayStatusRef.current = gateway.status + }, [gateway.status, reloadChannels]) + + const sortedChannels = React.useMemo(() => { + const list = [...channels] + list.sort((a, b) => { + const aEnabled = enabledMap[a.name] === true + const bEnabled = enabledMap[b.name] === true + if (aEnabled !== bEnabled) { + return aEnabled ? -1 : 1 + } + + const aImportance = + CHANNEL_IMPORTANCE_INDEX.get(a.name) ?? Number.MAX_SAFE_INTEGER + const bImportance = + CHANNEL_IMPORTANCE_INDEX.get(b.name) ?? Number.MAX_SAFE_INTEGER + if (aImportance !== bImportance) { + return aImportance - bImportance + } + + return getChannelDisplayName(a, t).localeCompare( + getChannelDisplayName(b, t), + ) + }) + return list + }, [channels, enabledMap, t]) + + const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS + const visibleChannels = showAllChannels + ? sortedChannels + : sortedChannels.slice(0, DEFAULT_VISIBLE_CHANNELS) + + const channelItems = React.useMemo( + () => + visibleChannels.map((channel) => ({ + key: channel.name, + title: getChannelDisplayName(channel, t), + url: `/channels/${channel.name}`, + icon: CHANNEL_ICON_MAP[channel.name] ?? IconPlug, + })), + [t, visibleChannels], + ) + + const toggleShowAllChannels = React.useCallback(() => { + setShowAllChannels((prev) => !prev) + }, []) + + return { + channelItems, + hasMoreChannels, + showAllChannels, + toggleShowAllChannels, + } +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 65684d072..402a4ca85 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -7,6 +7,8 @@ "services": "Services", "channels_group": "Channels", "channels": "Channels", + "show_more_channels": "More", + "show_less_channels": "Less", "config": "Config", "logs": "Logs" }, @@ -232,6 +234,25 @@ "action": { "configure": "Configure" }, + "name": { + "telegram": "Telegram", + "discord": "Discord", + "slack": "Slack", + "feishu": "Feishu", + "dingtalk": "DingTalk", + "line": "LINE", + "qq": "QQ", + "onebot": "OneBot", + "wecom": "WeCom", + "wecom_app": "WeCom App", + "wecom_aibot": "WeCom AI Bot", + "whatsapp": "WhatsApp", + "whatsapp_native": "WhatsApp Native", + "pico": "Web", + "maixcam": "MaixCam", + "matrix": "Matrix", + "irc": "IRC" + }, "field": { "token": "Bot Token", "tokenPlaceholder": "Enter bot token", @@ -244,6 +265,12 @@ "baseUrl": "API Base URL", "proxy": "HTTP Proxy", "proxyHint": "Optional. e.g. http://127.0.0.1:7890", + "mentionOnly": "Mention Only", + "typingEnabled": "Typing Indicator", + "placeholderEnabled": "Placeholder Message", + "placeholderText": "Placeholder Text", + "groupTriggerMentionOnly": "Group Mention Only", + "groupTriggerPrefixes": "Group Trigger Prefixes", "allowFrom": "Allow From", "allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.", "allowFromPlaceholder": "e.g. 123456, 789012", @@ -259,6 +286,78 @@ "description": "Set up credentials and options for this channel.", "saveError": "Failed to save channel configuration", "saving": "Saving..." + }, + "page": { + "notFound": "Channel \"{{name}}\" is not supported.", + "saveSuccess": "Channel configuration saved.", + "saveError": "Failed to save channel configuration", + "flowTitle": "Setup Flow", + "flowDescription": "Complete the following steps to put this channel into service.", + "step1": "Set credentials", + "step2": "Enable channel", + "step3": "Save and restart gateway", + "enabled": "enabled", + "disabled": "disabled", + "docLink": "Documentation", + "enableLabel": "Enable channel" + }, + "form": { + "desc": { + "token": "Bot access token used to connect to the platform API.", + "botToken": "Bot token used to send and receive messages.", + "appToken": "App token used for Socket Mode connections.", + "appId": "Unique application ID used for authentication.", + "appSecret": "Application secret used for signing and authentication.", + "verificationToken": "Verification token for event callbacks.", + "encryptKey": "Encryption key used to decrypt callback payloads.", + "baseUrl": "Platform API base URL. Official endpoint is used by default.", + "proxy": "HTTP proxy address for outbound network access.", + "mentionOnly": "Only respond when the bot is explicitly mentioned in group chats.", + "typingEnabled": "Display typing status while the assistant is generating a response.", + "placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.", + "placeholderText": "Placeholder text shown while waiting for the final response.", + "groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.", + "groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.", + "allowFrom": "Allowed user or group IDs, separated by commas.", + "allowOrigins": "Allowed origin domains, separated by commas.", + "wsUrl": "WebSocket service URL.", + "reconnectInterval": "Reconnect interval after disconnection (seconds).", + "bridgeUrl": "Bridge service URL.", + "sessionStorePath": "Local path for session storage.", + "useNative": "Whether to use native client mode.", + "host": "Service host address.", + "port": "Service port.", + "homeserver": "Matrix homeserver URL.", + "userId": "Account user ID.", + "deviceId": "Device ID.", + "joinOnInvite": "Automatically join rooms when invited.", + "clientId": "Client ID used for platform authentication.", + "corpId": "Enterprise Corp ID.", + "agentId": "Enterprise application Agent ID.", + "webhookUrl": "Full webhook URL.", + "webhookHost": "Webhook listening host.", + "webhookPort": "Webhook listening port.", + "webhookPath": "Webhook route path.", + "replyTimeout": "Reply timeout in seconds.", + "maxSteps": "Maximum number of processing steps.", + "welcomeMessage": "Welcome message content for new sessions.", + "allowTokenQuery": "Allow token in URL query parameters.", + "pingInterval": "Connection heartbeat interval in seconds.", + "readTimeout": "Read timeout in seconds.", + "writeTimeout": "Write timeout in seconds.", + "maxConnections": "Maximum number of concurrent connections.", + "server": "IRC server address.", + "tls": "Whether to enable TLS.", + "nick": "Bot nickname.", + "user": "IRC username.", + "realName": "Displayed real name.", + "channels": "IRC channels to join.", + "requestCaps": "IRC capability list requested on connect.", + "genericField": "Used to configure {{field}}." + } + }, + "validation": { + "requiredField": "This field is required." } }, "pages": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index c4f9e6315..6d6851321 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -7,6 +7,8 @@ "services": "服务", "channels_group": "频道", "channels": "频道", + "show_more_channels": "更多", + "show_less_channels": "收起", "config": "配置", "logs": "日志" }, @@ -232,6 +234,25 @@ "action": { "configure": "配置" }, + "name": { + "telegram": "Telegram", + "discord": "Discord", + "slack": "Slack", + "feishu": "飞书", + "dingtalk": "钉钉", + "line": "LINE", + "qq": "QQ", + "onebot": "OneBot", + "wecom": "企业微信", + "wecom_app": "企业微信应用", + "wecom_aibot": "企业微信 AI 机器人", + "whatsapp": "WhatsApp", + "whatsapp_native": "WhatsApp Native", + "pico": "Web", + "maixcam": "MaixCam", + "matrix": "Matrix", + "irc": "IRC" + }, "field": { "token": "Bot Token", "tokenPlaceholder": "输入 Bot Token", @@ -244,6 +265,12 @@ "baseUrl": "API Base URL", "proxy": "HTTP 代理", "proxyHint": "可选。例如 http://127.0.0.1:7890", + "mentionOnly": "仅提及时响应", + "typingEnabled": "输入中提示", + "placeholderEnabled": "占位消息", + "placeholderText": "占位文案", + "groupTriggerMentionOnly": "群聊仅提及时响应", + "groupTriggerPrefixes": "群聊触发前缀", "allowFrom": "允许来源", "allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。", "allowFromPlaceholder": "例如 123456, 789012", @@ -259,6 +286,78 @@ "description": "设置此频道的凭据和选项。", "saveError": "保存频道配置失败", "saving": "保存中..." + }, + "page": { + "notFound": "不支持频道“{{name}}”。", + "saveSuccess": "频道配置已保存。", + "saveError": "保存频道配置失败", + "flowTitle": "配置流程", + "flowDescription": "按以下步骤完成频道接入并投入使用。", + "step1": "填写凭据", + "step2": "启用频道", + "step3": "保存并重启网关", + "enabled": "已启用", + "disabled": "未启用", + "docLink": "配置文档", + "enableLabel": "启用频道" + }, + "form": { + "desc": { + "token": "机器人访问令牌,用于连接平台 API。", + "botToken": "Bot Token,用于发送与接收消息。", + "appToken": "App Token,用于 Socket 模式连接。", + "appId": "应用唯一标识,用于平台鉴权。", + "appSecret": "应用密钥,用于请求签名和鉴权。", + "verificationToken": "事件回调验证令牌。", + "encryptKey": "消息加密密钥,用于解密回调内容。", + "baseUrl": "平台 API 地址,默认使用官方地址。", + "proxy": "HTTP 代理地址,用于网络访问。", + "mentionOnly": "在群聊中仅当明确提及时才响应。", + "typingEnabled": "在生成回复时显示“正在输入”状态。", + "placeholderEnabled": "在最终回复发送前,先发送临时占位消息。", + "placeholderText": "等待最终回复期间显示的占位文案。", + "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应。", + "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔。", + "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔。", + "allowOrigins": "允许访问的来源域名,多个值用逗号分隔。", + "wsUrl": "WebSocket 服务地址。", + "reconnectInterval": "断线后的重连间隔(秒)。", + "bridgeUrl": "桥接服务地址。", + "sessionStorePath": "本地会话存储目录路径。", + "useNative": "是否使用原生客户端模式连接。", + "host": "服务监听主机地址。", + "port": "服务监听端口。", + "homeserver": "Matrix homeserver 地址。", + "userId": "账号 ID。", + "deviceId": "设备 ID。", + "joinOnInvite": "收到邀请时是否自动加入房间。", + "clientId": "应用客户端 ID,用于平台鉴权。", + "corpId": "企业 ID。", + "agentId": "企业应用 Agent ID。", + "webhookUrl": "Webhook 完整地址。", + "webhookHost": "Webhook 监听主机。", + "webhookPort": "Webhook 监听端口。", + "webhookPath": "Webhook 路径。", + "replyTimeout": "回复超时时间(秒)。", + "maxSteps": "最大步骤数。", + "welcomeMessage": "新会话欢迎语内容。", + "allowTokenQuery": "是否允许 URL Query 方式传递 Token。", + "pingInterval": "连接心跳间隔(秒)。", + "readTimeout": "读取超时时间(秒)。", + "writeTimeout": "写入超时时间(秒)。", + "maxConnections": "最大并发连接数。", + "server": "IRC 服务器地址。", + "tls": "是否启用 TLS 连接。", + "nick": "机器人昵称。", + "user": "IRC 用户名。", + "realName": "显示名称。", + "channels": "要加入的 IRC 频道列表。", + "requestCaps": "连接时请求的 IRC 扩展能力列表。", + "genericField": "用于配置{{field}}。" + } + }, + "validation": { + "requiredField": "请填写该字段" } }, "pages": { diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index 298d71b24..f6145ac67 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -14,8 +14,9 @@ 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 ChannelsRouteRouteImport } from './routes/channels/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as ChannelsNameRouteImport } from './routes/channels/$name' const ProvidersRoute = ProvidersRouteImport.update({ id: '/providers', @@ -42,7 +43,7 @@ const ConfigRoute = ConfigRouteImport.update({ path: '/config', getParentRoute: () => rootRouteImport, } as any) -const ChannelsRoute = ChannelsRouteImport.update({ +const ChannelsRouteRoute = ChannelsRouteRouteImport.update({ id: '/channels', path: '/channels', getParentRoute: () => rootRouteImport, @@ -52,34 +53,42 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ChannelsNameRoute = ChannelsNameRouteImport.update({ + id: '/$name', + path: '/$name', + getParentRoute: () => ChannelsRouteRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/channels': typeof ChannelsRoute + '/channels': typeof ChannelsRouteRouteWithChildren '/config': typeof ConfigRoute '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/providers': typeof ProvidersRoute + '/channels/$name': typeof ChannelsNameRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/channels': typeof ChannelsRoute + '/channels': typeof ChannelsRouteRouteWithChildren '/config': typeof ConfigRoute '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/providers': typeof ProvidersRoute + '/channels/$name': typeof ChannelsNameRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/channels': typeof ChannelsRoute + '/channels': typeof ChannelsRouteRouteWithChildren '/config': typeof ConfigRoute '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/providers': typeof ProvidersRoute + '/channels/$name': typeof ChannelsNameRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -91,6 +100,7 @@ export interface FileRouteTypes { | '/logs' | '/models' | '/providers' + | '/channels/$name' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -100,6 +110,7 @@ export interface FileRouteTypes { | '/logs' | '/models' | '/providers' + | '/channels/$name' id: | '__root__' | '/' @@ -109,11 +120,12 @@ export interface FileRouteTypes { | '/logs' | '/models' | '/providers' + | '/channels/$name' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - ChannelsRoute: typeof ChannelsRoute + ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren ConfigRoute: typeof ConfigRoute CredentialsRoute: typeof CredentialsRoute LogsRoute: typeof LogsRoute @@ -162,7 +174,7 @@ declare module '@tanstack/react-router' { id: '/channels' path: '/channels' fullPath: '/channels' - preLoaderRoute: typeof ChannelsRouteImport + preLoaderRoute: typeof ChannelsRouteRouteImport parentRoute: typeof rootRouteImport } '/': { @@ -172,12 +184,31 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/channels/$name': { + id: '/channels/$name' + path: '/$name' + fullPath: '/channels/$name' + preLoaderRoute: typeof ChannelsNameRouteImport + parentRoute: typeof ChannelsRouteRoute + } } } +interface ChannelsRouteRouteChildren { + ChannelsNameRoute: typeof ChannelsNameRoute +} + +const ChannelsRouteRouteChildren: ChannelsRouteRouteChildren = { + ChannelsNameRoute: ChannelsNameRoute, +} + +const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren( + ChannelsRouteRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - ChannelsRoute: ChannelsRoute, + ChannelsRouteRoute: ChannelsRouteRouteWithChildren, ConfigRoute: ConfigRoute, CredentialsRoute: CredentialsRoute, LogsRoute: LogsRoute, diff --git a/web/frontend/src/routes/channels.tsx b/web/frontend/src/routes/channels.tsx deleted file mode 100644 index 762ae646e..000000000 --- a/web/frontend/src/routes/channels.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router" - -import { ChannelsPage } from "@/components/channels/channels-page" - -export const Route = createFileRoute("/channels")({ - component: ChannelsPage, -}) diff --git a/web/frontend/src/routes/channels/$name.tsx b/web/frontend/src/routes/channels/$name.tsx new file mode 100644 index 000000000..e2e4941c1 --- /dev/null +++ b/web/frontend/src/routes/channels/$name.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { ChannelConfigPage } from "@/components/channels/channel-config-page" + +export const Route = createFileRoute("/channels/$name")({ + component: ChannelsByNameRoute, +}) + +function ChannelsByNameRoute() { + const { name } = Route.useParams() + + return +} diff --git a/web/frontend/src/routes/channels/route.tsx b/web/frontend/src/routes/channels/route.tsx new file mode 100644 index 000000000..044ff20c1 --- /dev/null +++ b/web/frontend/src/routes/channels/route.tsx @@ -0,0 +1,22 @@ +import { + Navigate, + Outlet, + createFileRoute, + useRouterState, +} from "@tanstack/react-router" + +export const Route = createFileRoute("/channels")({ + component: ChannelsLayout, +}) + +function ChannelsLayout() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + + if (pathname === "/channels") { + return + } + + return +}