feat(web): revamp channel config UX with catalog-based routing

- replace legacy channel management endpoints with a backend channel catalog API
- switch frontend channel updates to PATCH /api/config and per-channel config pages
- add dynamic channel items in the sidebar with support for expand/collapse
- migrate /channels to nested routes (/channels/$name) and remove old card/sheet flow
- improve channel forms with clearer hints, required/error states, and reusable switch cards
- fix Discord mention-only toggle to read/write group_trigger.mention_only
This commit is contained in:
wenjie 2026-03-09 15:18:53 +08:00
parent efed6784af
commit d7b9534dbc
25 changed files with 1880 additions and 1355 deletions

View file

@ -2,667 +2,46 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "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) { func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/channels", h.handleListChannels) mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
mux.HandleFunc("PUT /api/channels/{name}", h.handleUpdateChannel)
mux.HandleFunc("PATCH /api/channels/{name}/toggle", h.handleToggleChannel)
} }
// channelMeta holds static metadata for each supported channel. // handleListChannelCatalog returns the channels supported by backend.
type channelMeta struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
}
var channelRegistry = []channelMeta{
{Name: "telegram", DisplayName: "Telegram"},
{Name: "discord", DisplayName: "Discord"},
{Name: "slack", DisplayName: "Slack"},
{Name: "feishu", DisplayName: "Feishu"},
{Name: "dingtalk", DisplayName: "DingTalk"},
{Name: "line", DisplayName: "LINE"},
{Name: "qq", DisplayName: "QQ"},
{Name: "onebot", DisplayName: "OneBot"},
{Name: "wecom", DisplayName: "WeCom"},
{Name: "wecom_app", DisplayName: "WeCom App"},
{Name: "wecom_aibot", DisplayName: "WeCom AI Bot"},
{Name: "whatsapp", DisplayName: "WhatsApp"},
{Name: "pico", DisplayName: "Pico (Web)"},
{Name: "maixcam", DisplayName: "MaixCAM"},
}
// channelResponse is the JSON structure returned for each channel in the list.
type channelResponse struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
Config map[string]any `json:"config"`
}
// handleListChannels returns all channels with their enabled status and masked secrets.
// //
// GET /api/channels // GET /api/channels/catalog
func (h *Handler) handleListChannels(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
channels := make([]channelResponse, 0, len(channelRegistry))
for _, meta := range channelRegistry {
cr := channelResponse{
Name: meta.Name,
DisplayName: meta.DisplayName,
}
cr.Enabled, cr.Configured, cr.Config = extractChannelInfo(meta.Name, &cfg.Channels)
channels = append(channels, cr)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ 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")
}
}

View file

@ -84,8 +84,8 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
return false, "no default model configured", nil return false, "no default model configured", nil
} }
modelCfg, err := cfg.GetModelConfig(modelName) modelCfg := lookupModelConfig(cfg, modelName)
if err != nil { if modelCfg == nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), 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 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 { func isGatewayProcessAliveLocked() bool {
return gateway.cmd != nil && return gateway.cmd != nil &&
gateway.cmd.Process != nil && gateway.cmd.Process != nil &&

View file

@ -42,6 +42,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Model list management // Model list management
h.registerModelRoutes(mux) h.registerModelRoutes(mux)
// Channel management // Channel catalog (for frontend navigation/config pages)
h.registerChannelRoutes(mux) h.registerChannelRoutes(mux)
} }

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17 29C21 29 25 26.9339 28 23.4065C36 14 41.4242 16.8166 44 17.9998C38.5 20.9998 40.5 29.6233 33 35.9998C28.382 39.9259 23.4945 41.014 19 41C12.5231 40.9799 6.86226 37.7637 4 35.4063V16.9998" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.64808 15.8669C5.02231 14.9567 3.77715 14.7261 2.86694 15.3519C1.95673 15.9777 1.72615 17.2228 2.35192 18.1331L5.64808 15.8669ZM36.0021 35.7309C36.958 35.1774 37.2843 33.9539 36.7309 32.9979C36.1774 32.042 34.9539 31.7157 33.9979 32.2691L36.0021 35.7309ZM2.35192 18.1331C5.2435 22.339 10.7992 28.144 16.8865 32.2239C19.9345 34.2667 23.217 35.946 26.449 36.7324C29.6946 37.522 33.0451 37.4428 36.0021 35.7309L33.9979 32.2691C32.2049 33.3072 29.9929 33.478 27.3947 32.8458C24.783 32.2103 21.9405 30.7958 19.1135 28.9011C13.4508 25.106 8.2565 19.661 5.64808 15.8669L2.35192 18.1331Z" fill="#000"/><path d="M33.5947 17C32.84 14.7027 30.8551 9.94054 27.5947 7H11.5947C15.2174 10.6757 23.0002 16 27.0002 24" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,21 +1,22 @@
// API client for channel management. // API client for channels navigation and channel-specific config flows.
export type ChannelConfig = Record<string, unknown> export type ChannelConfig = Record<string, unknown>
export type AppConfig = Record<string, unknown>
export interface ChannelInfo { export interface SupportedChannel {
name: string name: string
display_name: string display_name?: string
enabled: boolean config_key: string
configured: boolean variant?: string
config: ChannelConfig
} }
interface ChannelsListResponse { interface ChannelsCatalogResponse {
channels: ChannelInfo[] channels: SupportedChannel[]
} }
interface ChannelActionResponse { interface ConfigActionResponse {
status: string status: string
errors?: string[]
} }
const BASE_URL = "" const BASE_URL = ""
@ -23,35 +24,42 @@ const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> { async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options) const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) { 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<T> return res.json() as Promise<T>
} }
export async function getChannels(): Promise<ChannelsListResponse> { export async function getChannelsCatalog(): Promise<ChannelsCatalogResponse> {
return request<ChannelsListResponse>("/api/channels") return request<ChannelsCatalogResponse>("/api/channels/catalog")
} }
export async function updateChannel( export async function getAppConfig(): Promise<AppConfig> {
name: string, return request<AppConfig>("/api/config")
config: ChannelConfig,
): Promise<ChannelActionResponse> {
return request<ChannelActionResponse>(`/api/channels/${name}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
})
} }
export async function toggleChannel( export async function patchAppConfig(
name: string, patch: Record<string, unknown>,
enabled: boolean, ): Promise<ConfigActionResponse> {
): Promise<ChannelActionResponse> { return request<ConfigActionResponse>("/api/config", {
return request<ChannelActionResponse>(`/api/channels/${name}/toggle`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }), body: JSON.stringify(patch),
}) })
} }
export type { ChannelsListResponse, ChannelActionResponse } export type { ChannelsCatalogResponse, ConfigActionResponse }

View file

@ -1,10 +1,11 @@
import { IconChevronRight } from "@tabler/icons-react" import { IconChevronRight } from "@tabler/icons-react"
import { import {
IconAtom, IconAtom,
IconChevronsDown,
IconChevronsUp,
IconKey, IconKey,
IconListDetails, IconListDetails,
IconMessageCircle, IconMessageCircle,
IconPlug,
IconSettings, IconSettings,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { Link, useRouterState } from "@tanstack/react-router" import { Link, useRouterState } from "@tanstack/react-router"
@ -27,34 +28,34 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
// Navigation data with real routes interface NavItem {
const navGroups = [ title: string
url: string
icon: React.ComponentType<{ className?: string }>
translateTitle?: boolean
}
interface NavGroup {
label: string
defaultOpen: boolean
items: NavItem[]
isChannelsGroup?: boolean
}
const baseNavGroups: Omit<NavGroup, "items">[] = [
{ {
label: "navigation.chat", label: "navigation.chat",
defaultOpen: true, defaultOpen: true,
items: [{ title: "navigation.chat", url: "/", icon: IconMessageCircle }],
}, },
{ {
label: "navigation.model_group", label: "navigation.model_group",
defaultOpen: true, 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", label: "navigation.services",
defaultOpen: true, 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<typeof Sidebar>) {
const routerState = useRouterState() const routerState = useRouterState()
const { t } = useTranslation() const { t } = useTranslation()
const currentPath = routerState.location.pathname 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 ( return (
<Sidebar <Sidebar
@ -103,13 +171,34 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive ? "opacity-100" : "opacity-80" isActive ? "opacity-100" : "opacity-80"
} }
> >
{t(item.title)} {item.translateTitle === false
? item.title
: t(item.title)}
</span> </span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
) )
})} })}
{group.isChannelsGroup && hasMoreChannels && (
<SidebarMenuItem key="channels-more-toggle">
<SidebarMenuButton
onClick={toggleShowAllChannels}
className="text-muted-foreground hover:bg-muted/60 h-9 px-3"
>
{showAllChannels ? (
<IconChevronsUp className="size-4 opacity-60" />
) : (
<IconChevronsDown className="size-4 opacity-60" />
)}
<span className="opacity-80">
{showAllChannels
? t("navigation.show_less_channels")
: t("navigation.show_more_channels")}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</CollapsibleContent> </CollapsibleContent>

View file

@ -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 (
<div className="border-border/60 bg-card flex items-center justify-between rounded-xl border p-4 transition-colors">
<div className="flex items-center gap-3">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">
{channel.display_name}
</span>
{channel.configured ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
{t("channels.status.configured")}
</span>
) : (
<span className="text-muted-foreground bg-muted rounded-full px-2 py-0.5 text-[10px] font-medium">
{t("channels.status.unconfigured")}
</span>
)}
</div>
<span className="text-muted-foreground text-xs">{channel.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={channel.enabled}
onCheckedChange={(checked) => onToggle(channel.name, checked)}
disabled={toggling}
/>
<Button
size="sm"
variant="ghost"
className="size-8 p-0"
onClick={() => onEdit(channel)}
>
<IconSettings className="size-4" />
<span className="sr-only">{t("channels.action.configure")}</span>
</Button>
</div>
</div>
)
}

View file

@ -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<string, string> = {
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<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
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<Record<string, string>>({})
const [channel, setChannel] = useState<SupportedChannel | null>(null)
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
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<string, string> = {}
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 (
<TelegramForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
case "discord":
return (
<DiscordForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
case "slack":
return (
<SlackForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
case "feishu":
return (
<FeishuForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
default:
return (
<GenericForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
hiddenKeys={hiddenKeys}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
/>
)
}
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={channelDisplayName}
titleExtra={
channel ? (
<div className="flex items-center gap-1.5">
{enabled ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
{t("channels.page.enabled")}
</span>
) : configured ? (
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
{t("channels.status.configured")}
</span>
) : null}
</div>
) : undefined
}
/>
<div className="flex min-h-0 flex-1 justify-center overflow-y-auto px-4 pb-8 sm:px-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<IconLoader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : fetchError ? (
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm">
{fetchError}
</div>
) : (
<div className="w-full max-w-250 space-y-5 pt-2">
<div className="flex items-center gap-2 text-sm">
<p className="font-medium">
{t("channels.edit.title", {
name: channelDisplayName,
})}
</p>
{channel && docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
>
{t("channels.page.docLink")}
</a>
)}
</div>
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
{renderForm()}
{serverError && (
<p className="text-destructive text-sm">{serverError}</p>
)}
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
<Button variant="outline" onClick={handleReset} disabled={saving}>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,23 @@
import type { TFunction } from "i18next"
import type { SupportedChannel } from "@/api/channels"
export function getChannelDisplayName(
channel: Pick<SupportedChannel, "name" | "display_name">,
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(" ")
}

View file

@ -1,10 +1,11 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
import { import {
AdvancedSection,
Field, Field,
KeyInput, KeyInput,
SwitchCardField,
} from "@/components/models/shared-form" } from "@/components/models/shared-form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -12,6 +13,7 @@ interface DiscordFormProps {
config: ChannelConfig config: ChannelConfig
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
fieldErrors?: Record<string, string>
} }
function asString(value: unknown): string { function asString(value: unknown): string {
@ -23,60 +25,89 @@ function asStringArray(value: unknown): string[] {
return value.filter((item): item is string => typeof item === "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<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
export function DiscordForm({
config,
onChange,
isEdit,
fieldErrors = {},
}: DiscordFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const groupTriggerConfig = asRecord(config.group_trigger)
const tokenExtraHint =
isEdit && asString(config.token)
? ` ${t("channels.field.secretHintSet")}`
: ""
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<Field <Field
label={t("channels.field.token")} label={t("channels.field.token")}
hint={ required
isEdit && asString(config.token) hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
? t("channels.field.secretHintSet") error={fieldErrors.token}
: undefined
}
> >
<KeyInput <KeyInput
value={asString(config._token)} value={asString(config._token)}
onChange={(v) => onChange("_token", v)} onChange={(v) => onChange("_token", v)}
placeholder={ placeholder={maskedSecretPlaceholder(
isEdit && asString(config.token) config.token,
? t("channels.field.secretPlaceholderSet") t("channels.field.tokenPlaceholder"),
: t("channels.field.tokenPlaceholder") )}
}
/> />
</Field> </Field>
<AdvancedSection> <Field
<Field label={t("channels.field.proxy")}
label={t("channels.field.proxy")} hint={t("channels.form.desc.proxy")}
hint={t("channels.field.proxyHint")} >
> <Input
<Input value={asString(config.proxy)}
value={asString(config.proxy)} onChange={(e) => onChange("proxy", e.target.value)}
onChange={(e) => onChange("proxy", e.target.value)} placeholder="http://127.0.0.1:7890"
placeholder="http://127.0.0.1:7890" />
/> </Field>
</Field> <Field
<Field label={t("channels.field.allowFrom")}
label={t("channels.field.allowFrom")} hint={t("channels.form.desc.allowFrom")}
hint={t("channels.field.allowFromHint")} >
> <Input
<Input value={asStringArray(config.allow_from).join(", ")}
value={asStringArray(config.allow_from).join(", ")} onChange={(e) =>
onChange={(e) => onChange(
onChange( "allow_from",
"allow_from", e.target.value
e.target.value .split(",")
.split(",") .map((s: string) => s.trim())
.map((s: string) => s.trim()) .filter(Boolean),
.filter(Boolean), )
) }
} placeholder={t("channels.field.allowFromPlaceholder")}
placeholder={t("channels.field.allowFromPlaceholder")} />
/> </Field>
</Field>
</AdvancedSection> <SwitchCardField
label={t("channels.field.mentionOnly")}
hint={t("channels.form.desc.mentionOnly")}
checked={asBool(groupTriggerConfig.mention_only)}
onCheckedChange={(checked) => {
onChange("group_trigger", {
...groupTriggerConfig,
mention_only: checked,
})
}}
ariaLabel={t("channels.field.mentionOnly")}
/>
</div> </div>
) )
} }

View file

@ -1,17 +1,15 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" import type { ChannelConfig } from "@/api/channels"
import { import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
AdvancedSection, import { Field, KeyInput } from "@/components/models/shared-form"
Field,
KeyInput,
} from "@/components/models/shared-form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
interface FeishuFormProps { interface FeishuFormProps {
config: ChannelConfig config: ChannelConfig
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
fieldErrors?: Record<string, string>
} }
function asString(value: unknown): string { function asString(value: unknown): string {
@ -23,12 +21,34 @@ function asStringArray(value: unknown): string[] {
return value.filter((item): item is string => typeof item === "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 { 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 ( return (
<div className="space-y-5"> <div className="space-y-5">
<Field label={t("channels.field.appId")}> <Field
label={t("channels.field.appId")}
required
hint={t("channels.form.desc.appId")}
error={fieldErrors.app_id}
>
<Input <Input
value={asString(config.app_id)} value={asString(config.app_id)}
onChange={(e) => onChange("app_id", e.target.value)} onChange={(e) => onChange("app_id", e.target.value)}
@ -38,65 +58,64 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
<Field <Field
label={t("channels.field.appSecret")} label={t("channels.field.appSecret")}
hint={ required
isEdit && asString(config.app_secret) hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
? t("channels.field.secretHintSet") error={fieldErrors.app_secret}
: undefined
}
> >
<KeyInput <KeyInput
value={asString(config._app_secret)} value={asString(config._app_secret)}
onChange={(v) => onChange("_app_secret", v)} onChange={(v) => onChange("_app_secret", v)}
placeholder={ placeholder={maskedSecretPlaceholder(
isEdit && asString(config.app_secret) config.app_secret,
? t("channels.field.secretPlaceholderSet") t("channels.field.secretPlaceholder"),
: t("channels.field.secretPlaceholder") )}
}
/> />
</Field> </Field>
<AdvancedSection> <Field
<Field label={t("channels.field.verificationToken")}> label={t("channels.field.verificationToken")}
<KeyInput hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
value={asString(config._verification_token)} >
onChange={(v) => onChange("_verification_token", v)} <KeyInput
placeholder={ value={asString(config._verification_token)}
isEdit && asString(config.verification_token) onChange={(v) => onChange("_verification_token", v)}
? t("channels.field.secretPlaceholderSet") placeholder={maskedSecretPlaceholder(
: t("channels.field.secretPlaceholder") config.verification_token,
} t("channels.field.secretPlaceholder"),
/> )}
</Field> />
<Field label={t("channels.field.encryptKey")}> </Field>
<KeyInput <Field
value={asString(config._encrypt_key)} label={t("channels.field.encryptKey")}
onChange={(v) => onChange("_encrypt_key", v)} hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
placeholder={ >
isEdit && asString(config.encrypt_key) <KeyInput
? t("channels.field.secretPlaceholderSet") value={asString(config._encrypt_key)}
: t("channels.field.secretPlaceholder") onChange={(v) => onChange("_encrypt_key", v)}
} placeholder={maskedSecretPlaceholder(
/> config.encrypt_key,
</Field> t("channels.field.secretPlaceholder"),
<Field )}
label={t("channels.field.allowFrom")} />
hint={t("channels.field.allowFromHint")} </Field>
> <Field
<Input label={t("channels.field.allowFrom")}
value={asStringArray(config.allow_from).join(", ")} hint={t("channels.form.desc.allowFrom")}
onChange={(e) => >
onChange( <Input
"allow_from", value={asStringArray(config.allow_from).join(", ")}
e.target.value onChange={(e) =>
.split(",") onChange(
.map((s: string) => s.trim()) "allow_from",
.filter(Boolean), e.target.value
) .split(",")
} .map((s: string) => s.trim())
placeholder={t("channels.field.allowFromPlaceholder")} .filter(Boolean),
/> )
</Field> }
</AdvancedSection> placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</div> </div>
) )
} }

View file

@ -1,15 +1,21 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" 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 { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
interface GenericFormProps { interface GenericFormProps {
channelName: string
config: ChannelConfig config: ChannelConfig
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
hiddenKeys?: string[]
requiredKeys?: string[]
fieldErrors?: Record<string, string>
} }
// Secret field names that should use masked input. // Secret field names that should use masked input.
@ -26,6 +32,9 @@ const SECRET_FIELDS = new Set([
"encoding_aes_key", "encoding_aes_key",
"encrypt_key", "encrypt_key",
"verification_token", "verification_token",
"password",
"nickserv_password",
"sasl_password",
]) ])
// Fields to skip in the generic form (handled by enabled toggle or internal). // Fields to skip in the generic form (handled by enabled toggle or internal).
@ -36,6 +45,7 @@ const OBJECT_FIELDS = new Set([
"group_trigger", "group_trigger",
"typing", "typing",
"placeholder", "placeholder",
"allow_token_query",
"allow_from", "allow_from",
"allow_origins", "allow_origins",
]) ])
@ -47,6 +57,11 @@ function formatLabel(key: string): string {
.join(" ") .join(" ")
} }
function formatSentenceFieldName(key: string): string {
const label = formatLabel(key)
return label.charAt(0).toLowerCase() + label.slice(1)
}
function asString(value: unknown): string { function asString(value: unknown): string {
return typeof value === "string" ? value : "" return typeof value === "string" ? value : ""
} }
@ -56,36 +71,106 @@ function asStringArray(value: unknown): string[] {
return value.filter((item): item is string => typeof item === "string") return value.filter((item): item is string => typeof item === "string")
} }
export function GenericForm({ config, onChange, isEdit }: GenericFormProps) { function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function asBool(value: unknown): boolean {
return value === true
}
export function GenericForm({
config,
onChange,
isEdit,
hiddenKeys = [],
requiredKeys = [],
fieldErrors = {},
}: GenericFormProps) {
const { t } = useTranslation() 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( 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<string, string> = {
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 ( return (
<div className="space-y-5"> <div className="space-y-5">
{fields.map((key) => { {fields.map((key) => {
const isRequired = requiredFieldSet.has(key)
if (SECRET_FIELDS.has(key)) { if (SECRET_FIELDS.has(key)) {
const editKey = `_${key}` const editKey = `_${key}`
const extraHint =
isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : ""
return ( return (
<Field <Field
key={key} key={key}
label={formatLabel(key)} label={formatLabel(key)}
hint={ required={isRequired}
isEdit && config[key] hint={`${buildHint(key)}${extraHint}`}
? t("channels.field.secretHintSet") error={fieldErrors[key]}
: undefined
}
> >
<KeyInput <KeyInput
value={asString(config[editKey])} value={asString(config[editKey])}
onChange={(v) => onChange(editKey, v)} onChange={(v) => onChange(editKey, v)}
placeholder={ placeholder={maskedSecretPlaceholder(config[key])}
isEdit && Boolean(config[key])
? t("channels.field.secretPlaceholderSet")
: ""
}
/> />
</Field> </Field>
) )
@ -94,20 +179,51 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
const value = config[key] const value = config[key]
if (typeof value === "boolean") { if (typeof value === "boolean") {
return ( return (
<Field key={key} label={formatLabel(key)}> <SwitchCardField
<div className="border-input flex h-9 items-center justify-end rounded-md border px-2.5"> key={key}
<Switch label={formatLabel(key)}
checked={value} hint={buildHint(key)}
onCheckedChange={(checked) => onChange(key, checked)} error={fieldErrors[key]}
aria-label={formatLabel(key)} checked={value}
/> onCheckedChange={(checked) => onChange(key, checked)}
</div> ariaLabel={formatLabel(key)}
/>
)
}
if (Array.isArray(value)) {
return (
<Field
key={key}
label={formatLabel(key)}
required={isRequired}
hint={buildHint(key)}
error={fieldErrors[key]}
>
<Input
value={asStringArray(value).join(", ")}
onChange={(e) =>
onChange(
key,
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
/>
</Field> </Field>
) )
} }
return ( return (
<Field key={key} label={formatLabel(key)}> <Field
key={key}
label={formatLabel(key)}
required={isRequired}
hint={buildHint(key)}
error={fieldErrors[key]}
>
<Input <Input
value={String(value ?? "")} value={String(value ?? "")}
onChange={(e) => { onChange={(e) => {
@ -125,10 +241,10 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
})} })}
{/* Allow From field */} {/* Allow From field */}
{config.allow_from !== undefined && ( {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && (
<Field <Field
label={t("channels.field.allowFrom")} label={t("channels.field.allowFrom")}
hint={t("channels.field.allowFromHint")} hint={t("channels.form.desc.allowFrom")}
> >
<Input <Input
value={asStringArray(config.allow_from).join(", ")} value={asStringArray(config.allow_from).join(", ")}
@ -146,32 +262,120 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
</Field> </Field>
)} )}
{config.allow_origins !== undefined && ( {config.allow_origins !== undefined &&
<Field !hiddenFieldSet.has("allow_origins") && (
label={t("channels.field.allowOrigins", "Allow Origins")} <Field
hint={t( label={t("channels.field.allowOrigins")}
"channels.field.allowOriginsHint", hint={t("channels.form.desc.allowOrigins")}
"Comma-separated list of allowed origins. Leave empty to allow all.", >
)} <Input
> value={asStringArray(config.allow_origins).join(", ")}
<Input onChange={(e) =>
value={asStringArray(config.allow_origins).join(", ")} onChange(
onChange={(e) => "allow_origins",
onChange( e.target.value
"allow_origins", .split(",")
e.target.value .map((s: string) => s.trim())
.split(",") .filter(Boolean),
.map((s: string) => s.trim()) )
.filter(Boolean), }
) placeholder={t("channels.field.allowOriginsPlaceholder")}
/>
</Field>
)}
{config.allow_token_query !== undefined &&
!hiddenFieldSet.has("allow_token_query") && (
<SwitchCardField
label={formatLabel("allow_token_query")}
hint={buildHint("allow_token_query")}
checked={asBool(config.allow_token_query)}
onCheckedChange={(checked) =>
onChange("allow_token_query", checked)
} }
placeholder={t( ariaLabel={formatLabel("allow_token_query")}
"channels.field.allowOriginsPlaceholder",
"e.g. https://example.com, http://localhost:5173",
)}
/> />
</Field> )}
{config.group_trigger !== undefined &&
!hiddenFieldSet.has("group_trigger") && (
<>
<SwitchCardField
label={t("channels.field.groupTriggerMentionOnly")}
hint={t("channels.form.desc.groupTriggerMentionOnly")}
checked={asBool(groupTriggerConfig.mention_only)}
onCheckedChange={(checked) =>
onChange("group_trigger", {
...groupTriggerConfig,
mention_only: checked,
})
}
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
/>
<Field
label={t("channels.field.groupTriggerPrefixes")}
hint={t("channels.form.desc.groupTriggerPrefixes")}
>
<Input
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
onChange={(e) =>
onChange("group_trigger", {
...groupTriggerConfig,
prefixes: e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
})
}
placeholder={t("channels.field.groupTriggerPrefixes")}
/>
</Field>
</>
)}
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
<SwitchCardField
label={t("channels.field.typingEnabled")}
hint={t("channels.form.desc.typingEnabled")}
checked={asBool(typingConfig.enabled)}
onCheckedChange={(checked) =>
onChange("typing", { ...typingConfig, enabled: checked })
}
ariaLabel={t("channels.field.typingEnabled")}
/>
)} )}
{config.placeholder !== undefined &&
!hiddenFieldSet.has("placeholder") && (
<SwitchCardField
label={t("channels.field.placeholderEnabled")}
hint={t("channels.form.desc.placeholderEnabled")}
checked={placeholderEnabled}
onCheckedChange={(checked) =>
onChange("placeholder", {
...placeholderConfig,
enabled: checked,
})
}
ariaLabel={t("channels.field.placeholderEnabled")}
>
{placeholderEnabled && (
<div className="space-y-1">
<Input
value={asString(placeholderConfig.text)}
onChange={(e) =>
onChange("placeholder", {
...placeholderConfig,
text: e.target.value,
})
}
placeholder={t("channels.field.placeholderText")}
aria-label={t("channels.field.placeholderText")}
/>
</div>
)}
</SwitchCardField>
)}
</div> </div>
) )
} }

View file

@ -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}`
}

View file

@ -1,17 +1,15 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" import type { ChannelConfig } from "@/api/channels"
import { import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
AdvancedSection, import { Field, KeyInput } from "@/components/models/shared-form"
Field,
KeyInput,
} from "@/components/models/shared-form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
interface SlackFormProps { interface SlackFormProps {
config: ChannelConfig config: ChannelConfig
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
fieldErrors?: Record<string, string>
} }
function asString(value: unknown): string { function asString(value: unknown): string {
@ -23,69 +21,66 @@ function asStringArray(value: unknown): string[] {
return value.filter((item): item is string => typeof item === "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 { 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 ( return (
<div className="space-y-5"> <div className="space-y-5">
<Field <Field
label={t("channels.field.botToken")} label={t("channels.field.botToken")}
hint={ required
isEdit && asString(config.bot_token) hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
? t("channels.field.secretHintSet") error={fieldErrors.bot_token}
: undefined
}
> >
<KeyInput <KeyInput
value={asString(config._bot_token)} value={asString(config._bot_token)}
onChange={(v) => onChange("_bot_token", v)} onChange={(v) => onChange("_bot_token", v)}
placeholder={ placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
isEdit && asString(config.bot_token)
? t("channels.field.secretPlaceholderSet")
: "xoxb-xxxx"
}
/> />
</Field> </Field>
<Field <Field
label={t("channels.field.appToken")} label={t("channels.field.appToken")}
hint={ hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
isEdit && asString(config.app_token)
? t("channels.field.secretHintSet")
: undefined
}
> >
<KeyInput <KeyInput
value={asString(config._app_token)} value={asString(config._app_token)}
onChange={(v) => onChange("_app_token", v)} onChange={(v) => onChange("_app_token", v)}
placeholder={ placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
isEdit && asString(config.app_token)
? t("channels.field.secretPlaceholderSet")
: "xapp-xxxx"
}
/> />
</Field> </Field>
<AdvancedSection> <Field
<Field label={t("channels.field.allowFrom")}
label={t("channels.field.allowFrom")} hint={t("channels.form.desc.allowFrom")}
hint={t("channels.field.allowFromHint")} >
> <Input
<Input value={asStringArray(config.allow_from).join(", ")}
value={asStringArray(config.allow_from).join(", ")} onChange={(e) =>
onChange={(e) => onChange(
onChange( "allow_from",
"allow_from", e.target.value
e.target.value .split(",")
.split(",") .map((s: string) => s.trim())
.map((s: string) => s.trim()) .filter(Boolean),
.filter(Boolean), )
) }
} placeholder={t("channels.field.allowFromPlaceholder")}
placeholder={t("channels.field.allowFromPlaceholder")} />
/> </Field>
</Field>
</AdvancedSection>
</div> </div>
) )
} }

View file

@ -1,10 +1,11 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels" import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
import { import {
AdvancedSection,
Field, Field,
KeyInput, KeyInput,
SwitchCardField,
} from "@/components/models/shared-form" } from "@/components/models/shared-form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -12,6 +13,7 @@ interface TelegramFormProps {
config: ChannelConfig config: ChannelConfig
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
fieldErrors?: Record<string, string>
} }
function asString(value: unknown): string { function asString(value: unknown): string {
@ -23,67 +25,127 @@ function asStringArray(value: unknown): string[] {
return value.filter((item): item is string => typeof item === "string") return value.filter((item): item is string => typeof item === "string")
} }
export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) { function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function asBool(value: unknown): boolean {
return value === true
}
export function TelegramForm({
config,
onChange,
isEdit,
fieldErrors = {},
}: TelegramFormProps) {
const { t } = useTranslation() 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 ( return (
<div className="space-y-5"> <div className="space-y-5">
<Field <Field
label={t("channels.field.token")} label={t("channels.field.token")}
hint={ required
isEdit && asString(config.token) hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
? t("channels.field.secretHintSet") error={fieldErrors.token}
: undefined
}
> >
<KeyInput <KeyInput
value={asString(config._token)} value={asString(config._token)}
onChange={(v) => onChange("_token", v)} onChange={(v) => onChange("_token", v)}
placeholder={ placeholder={maskedSecretPlaceholder(
isEdit && asString(config.token) config.token,
? t("channels.field.secretPlaceholderSet") t("channels.field.tokenPlaceholder"),
: t("channels.field.tokenPlaceholder") )}
}
/> />
</Field> </Field>
<AdvancedSection> <Field
<Field label={t("channels.field.baseUrl")}> label={t("channels.field.baseUrl")}
<Input hint={t("channels.form.desc.baseUrl")}
value={asString(config.base_url)} >
onChange={(e) => onChange("base_url", e.target.value)} <Input
placeholder="https://api.telegram.org" value={asString(config.base_url)}
/> onChange={(e) => onChange("base_url", e.target.value)}
</Field> placeholder="https://api.telegram.org"
<Field />
label={t("channels.field.proxy")} </Field>
hint={t("channels.field.proxyHint")} <Field
> label={t("channels.field.proxy")}
<Input hint={t("channels.form.desc.proxy")}
value={asString(config.proxy)} >
onChange={(e) => onChange("proxy", e.target.value)} <Input
placeholder="http://127.0.0.1:7890" value={asString(config.proxy)}
/> onChange={(e) => onChange("proxy", e.target.value)}
</Field> placeholder="http://127.0.0.1:7890"
<Field />
label={t("channels.field.allowFrom")} </Field>
hint={t("channels.field.allowFromHint")} <Field
> label={t("channels.field.allowFrom")}
<Input hint={t("channels.form.desc.allowFrom")}
value={asStringArray(config.allow_from).join(", ")} >
onChange={(e) => <Input
onChange( value={asStringArray(config.allow_from).join(", ")}
"allow_from", onChange={(e) =>
e.target.value onChange(
.split(",") "allow_from",
.map((s: string) => s.trim()) e.target.value
.filter(Boolean), .split(",")
) .map((s: string) => s.trim())
} .filter(Boolean),
placeholder={t("channels.field.allowFromPlaceholder")} )
/> }
</Field> placeholder={t("channels.field.allowFromPlaceholder")}
</AdvancedSection> />
</Field>
<SwitchCardField
label={t("channels.field.typingEnabled")}
hint={t("channels.form.desc.typingEnabled")}
checked={asBool(typingConfig.enabled)}
onCheckedChange={(checked) =>
onChange("typing", { ...typingConfig, enabled: checked })
}
ariaLabel={t("channels.field.typingEnabled")}
/>
<SwitchCardField
label={t("channels.field.placeholderEnabled")}
hint={t("channels.form.desc.placeholderEnabled")}
checked={placeholderEnabled}
onCheckedChange={(checked) =>
onChange("placeholder", {
...placeholderConfig,
enabled: checked,
})
}
ariaLabel={t("channels.field.placeholderEnabled")}
>
{placeholderEnabled && (
<div className="space-y-1">
<Input
value={asString(placeholderConfig.text)}
onChange={(e) =>
onChange("placeholder", {
...placeholderConfig,
text: e.target.value,
})
}
placeholder={t("channels.field.placeholderText")}
aria-label={t("channels.field.placeholderText")}
/>
</div>
)}
</SwitchCardField>
</div> </div>
) )
} }

View file

@ -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<ChannelInfo[]>([])
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState("")
const [editingChannel, setEditingChannel] = useState<ChannelInfo | null>(null)
const [togglingChannel, setTogglingChannel] = useState<string | null>(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 (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.channels", "Channels")}
titleExtra={
!loading && channels.length > 0 ? (
<span className="text-muted-foreground text-sm font-normal">
{enabledCount} {t("channels.header.enabled")}
</span>
) : undefined
}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : fetchError ? (
<div className="flex flex-col items-center justify-center gap-2 py-20">
<p className="text-destructive text-sm">{fetchError}</p>
</div>
) : (
<div className="pb-8">
<p className="text-muted-foreground mb-4 text-sm">
{t("channels.description")}
</p>
{channels.length > 6 && (
<div className="relative mb-4">
<IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("channels.search")}
className="pl-9"
/>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((channel) => (
<ChannelCard
key={channel.name}
channel={channel}
onToggle={handleToggle}
onEdit={setEditingChannel}
toggling={togglingChannel === channel.name}
/>
))}
</div>
{filtered.length === 0 && search && (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("channels.noResults")}
</p>
)}
</div>
)}
</div>
<EditChannelSheet
channel={editingChannel}
open={editingChannel !== null}
onClose={() => setEditingChannel(null)}
onSaved={fetchChannels}
/>
</div>
)
}

View file

@ -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<string, string> = {
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<ChannelConfig>({})
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 (
<TelegramForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
case "discord":
return (
<DiscordForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
case "slack":
return (
<SlackForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
case "feishu":
return (
<FeishuForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
default:
return (
<GenericForm
channelName={channel.name}
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
}
}
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="right" className="flex flex-col sm:max-w-md">
<SheetHeader>
<SheetTitle>
{t("channels.edit.title", {
name: channel?.display_name ?? "",
})}
</SheetTitle>
<SheetDescription>{t("channels.edit.description")}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">{renderForm()}</div>
{serverError && (
<p className="px-1 text-sm text-red-500">{serverError}</p>
)}
<SheetFooter className="gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("channels.edit.saving") : t("common.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View file

@ -8,18 +8,24 @@ import {
Field as UiField, Field as UiField,
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
interface FieldProps { interface FieldProps {
label: string label: string
hint?: string hint?: string
error?: string
required?: boolean
children: ReactNode children: ReactNode
} }
export function Field({ label, hint, children }: FieldProps) { export function Field({ label, hint, error, required, children }: FieldProps) {
return ( return (
<UiField className="gap-2.5"> <UiField className="gap-2.5">
<div className="space-y-1"> <div className="space-y-1">
<FieldLabel>{label}</FieldLabel> <FieldLabel>
{label}
{required && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
{hint && ( {hint && (
<FieldDescription className="text-xs leading-normal"> <FieldDescription className="text-xs leading-normal">
{hint} {hint}
@ -27,6 +33,11 @@ export function Field({ label, hint, children }: FieldProps) {
)} )}
</div> </div>
{children} {children}
{error && (
<FieldDescription className="text-destructive text-xs leading-normal">
{error}
</FieldDescription>
)}
</UiField> </UiField>
) )
} }
@ -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 (
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{label}</p>
{hint && (
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
{hint}
</p>
)}
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={ariaLabel ?? label}
/>
</div>
{children && <div className="mt-3">{children}</div>}
{error && (
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
)}
</div>
)
}
interface AdvancedSectionProps { interface AdvancedSectionProps {
children: ReactNode children: ReactNode
} }

View file

@ -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<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function isChannelEnabled(
channel: SupportedChannel,
channelsConfig: Record<string, unknown>,
): 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<string, boolean> {
const channelsConfig = asRecord(asRecord(appConfig).channels)
const result: Record<string, boolean> = {}
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<SupportedChannel[]>([])
const [enabledMap, setEnabledMap] = React.useState<Record<string, boolean>>(
{},
)
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<SidebarChannelNavItem[]>(
() =>
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,
}
}

View file

@ -7,6 +7,8 @@
"services": "Services", "services": "Services",
"channels_group": "Channels", "channels_group": "Channels",
"channels": "Channels", "channels": "Channels",
"show_more_channels": "More",
"show_less_channels": "Less",
"config": "Config", "config": "Config",
"logs": "Logs" "logs": "Logs"
}, },
@ -232,6 +234,25 @@
"action": { "action": {
"configure": "Configure" "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": { "field": {
"token": "Bot Token", "token": "Bot Token",
"tokenPlaceholder": "Enter bot token", "tokenPlaceholder": "Enter bot token",
@ -244,6 +265,12 @@
"baseUrl": "API Base URL", "baseUrl": "API Base URL",
"proxy": "HTTP Proxy", "proxy": "HTTP Proxy",
"proxyHint": "Optional. e.g. http://127.0.0.1:7890", "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", "allowFrom": "Allow From",
"allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.", "allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.",
"allowFromPlaceholder": "e.g. 123456, 789012", "allowFromPlaceholder": "e.g. 123456, 789012",
@ -259,6 +286,78 @@
"description": "Set up credentials and options for this channel.", "description": "Set up credentials and options for this channel.",
"saveError": "Failed to save channel configuration", "saveError": "Failed to save channel configuration",
"saving": "Saving..." "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": { "pages": {

View file

@ -7,6 +7,8 @@
"services": "服务", "services": "服务",
"channels_group": "频道", "channels_group": "频道",
"channels": "频道", "channels": "频道",
"show_more_channels": "更多",
"show_less_channels": "收起",
"config": "配置", "config": "配置",
"logs": "日志" "logs": "日志"
}, },
@ -232,6 +234,25 @@
"action": { "action": {
"configure": "配置" "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": { "field": {
"token": "Bot Token", "token": "Bot Token",
"tokenPlaceholder": "输入 Bot Token", "tokenPlaceholder": "输入 Bot Token",
@ -244,6 +265,12 @@
"baseUrl": "API Base URL", "baseUrl": "API Base URL",
"proxy": "HTTP 代理", "proxy": "HTTP 代理",
"proxyHint": "可选。例如 http://127.0.0.1:7890", "proxyHint": "可选。例如 http://127.0.0.1:7890",
"mentionOnly": "仅提及时响应",
"typingEnabled": "输入中提示",
"placeholderEnabled": "占位消息",
"placeholderText": "占位文案",
"groupTriggerMentionOnly": "群聊仅提及时响应",
"groupTriggerPrefixes": "群聊触发前缀",
"allowFrom": "允许来源", "allowFrom": "允许来源",
"allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。", "allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。",
"allowFromPlaceholder": "例如 123456, 789012", "allowFromPlaceholder": "例如 123456, 789012",
@ -259,6 +286,78 @@
"description": "设置此频道的凭据和选项。", "description": "设置此频道的凭据和选项。",
"saveError": "保存频道配置失败", "saveError": "保存频道配置失败",
"saving": "保存中..." "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": { "pages": {

View file

@ -14,8 +14,9 @@ import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs' import { Route as LogsRouteImport } from './routes/logs'
import { Route as CredentialsRouteImport } from './routes/credentials' import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config' 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 IndexRouteImport } from './routes/index'
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
const ProvidersRoute = ProvidersRouteImport.update({ const ProvidersRoute = ProvidersRouteImport.update({
id: '/providers', id: '/providers',
@ -42,7 +43,7 @@ const ConfigRoute = ConfigRouteImport.update({
path: '/config', path: '/config',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ChannelsRoute = ChannelsRouteImport.update({ const ChannelsRouteRoute = ChannelsRouteRouteImport.update({
id: '/channels', id: '/channels',
path: '/channels', path: '/channels',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
@ -52,34 +53,42 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ChannelsNameRoute = ChannelsNameRouteImport.update({
id: '/$name',
path: '/$name',
getParentRoute: () => ChannelsRouteRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/channels': typeof ChannelsRoute '/channels': typeof ChannelsRouteRouteWithChildren
'/config': typeof ConfigRoute '/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute '/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute '/providers': typeof ProvidersRoute
'/channels/$name': typeof ChannelsNameRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/channels': typeof ChannelsRoute '/channels': typeof ChannelsRouteRouteWithChildren
'/config': typeof ConfigRoute '/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute '/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute '/providers': typeof ProvidersRoute
'/channels/$name': typeof ChannelsNameRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/channels': typeof ChannelsRoute '/channels': typeof ChannelsRouteRouteWithChildren
'/config': typeof ConfigRoute '/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute '/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute '/providers': typeof ProvidersRoute
'/channels/$name': typeof ChannelsNameRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@ -91,6 +100,7 @@ export interface FileRouteTypes {
| '/logs' | '/logs'
| '/models' | '/models'
| '/providers' | '/providers'
| '/channels/$name'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@ -100,6 +110,7 @@ export interface FileRouteTypes {
| '/logs' | '/logs'
| '/models' | '/models'
| '/providers' | '/providers'
| '/channels/$name'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@ -109,11 +120,12 @@ export interface FileRouteTypes {
| '/logs' | '/logs'
| '/models' | '/models'
| '/providers' | '/providers'
| '/channels/$name'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
ChannelsRoute: typeof ChannelsRoute ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
ConfigRoute: typeof ConfigRoute ConfigRoute: typeof ConfigRoute
CredentialsRoute: typeof CredentialsRoute CredentialsRoute: typeof CredentialsRoute
LogsRoute: typeof LogsRoute LogsRoute: typeof LogsRoute
@ -162,7 +174,7 @@ declare module '@tanstack/react-router' {
id: '/channels' id: '/channels'
path: '/channels' path: '/channels'
fullPath: '/channels' fullPath: '/channels'
preLoaderRoute: typeof ChannelsRouteImport preLoaderRoute: typeof ChannelsRouteRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/': { '/': {
@ -172,12 +184,31 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport 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 = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
ChannelsRoute: ChannelsRoute, ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
ConfigRoute: ConfigRoute, ConfigRoute: ConfigRoute,
CredentialsRoute: CredentialsRoute, CredentialsRoute: CredentialsRoute,
LogsRoute: LogsRoute, LogsRoute: LogsRoute,

View file

@ -1,7 +0,0 @@
import { createFileRoute } from "@tanstack/react-router"
import { ChannelsPage } from "@/components/channels/channels-page"
export const Route = createFileRoute("/channels")({
component: ChannelsPage,
})

View file

@ -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 <ChannelConfigPage channelName={name} />
}

View file

@ -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 <Navigate to="/channels/$name" params={{ name: "pico" }} />
}
return <Outlet />
}