feat(web): add channel management page for web console
Add a complete channel management UI that allows users to configure messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly from the web console instead of manually editing config.json. Backend: GET/PUT/PATCH API endpoints for listing, updating, and toggling channels with secret field masking. Frontend: Channel cards grid with enable/disable toggles, per-channel configuration sheets with dedicated forms for major platforms and a generic fallback for others. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f245fb06ba
commit
5ae8044c72
17 changed files with 1642 additions and 2 deletions
2
Makefile
2
Makefile
|
|
@ -91,7 +91,7 @@ build: generate
|
|||
build-launcher:
|
||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@if [ ! -d web/backend/dist ]; then \
|
||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||
echo "Building frontend..."; \
|
||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||
fi
|
||||
|
|
|
|||
609
web/backend/api/channels.go
Normal file
609
web/backend/api/channels.go
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// registerChannelRoutes binds channel management endpoints to the ServeMux.
|
||||
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/channels", h.handleListChannels)
|
||||
mux.HandleFunc("PUT /api/channels/{name}", h.handleUpdateChannel)
|
||||
mux.HandleFunc("PATCH /api/channels/{name}/toggle", h.handleToggleChannel)
|
||||
}
|
||||
|
||||
// channelMeta holds static metadata for each supported channel.
|
||||
type channelMeta struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
var channelRegistry = []channelMeta{
|
||||
{Name: "telegram", DisplayName: "Telegram"},
|
||||
{Name: "discord", DisplayName: "Discord"},
|
||||
{Name: "slack", DisplayName: "Slack"},
|
||||
{Name: "feishu", DisplayName: "Feishu"},
|
||||
{Name: "dingtalk", DisplayName: "DingTalk"},
|
||||
{Name: "line", DisplayName: "LINE"},
|
||||
{Name: "qq", DisplayName: "QQ"},
|
||||
{Name: "onebot", DisplayName: "OneBot"},
|
||||
{Name: "wecom", DisplayName: "WeCom"},
|
||||
{Name: "wecom_app", DisplayName: "WeCom App"},
|
||||
{Name: "wecom_aibot", DisplayName: "WeCom AI Bot"},
|
||||
{Name: "whatsapp", DisplayName: "WhatsApp"},
|
||||
{Name: "pico", DisplayName: "Pico (Web)"},
|
||||
{Name: "maixcam", DisplayName: "MaixCAM"},
|
||||
}
|
||||
|
||||
// channelResponse is the JSON structure returned for each channel in the list.
|
||||
type channelResponse struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
// handleListChannels returns all channels with their enabled status and masked secrets.
|
||||
//
|
||||
// GET /api/channels
|
||||
func (h *Handler) handleListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := h.loadFilteredConfig()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
channels := make([]channelResponse, 0, len(channelRegistry))
|
||||
for _, meta := range channelRegistry {
|
||||
cr := channelResponse{
|
||||
Name: meta.Name,
|
||||
DisplayName: meta.DisplayName,
|
||||
}
|
||||
cr.Enabled, cr.Configured, cr.Config = extractChannelInfo(meta.Name, &cfg.Channels)
|
||||
channels = append(channels, cr)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"channels": channels,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateChannel replaces a channel's configuration.
|
||||
// Secret fields sent as empty strings are preserved from the existing config.
|
||||
//
|
||||
// PUT /api/channels/{name}
|
||||
func (h *Handler) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if !isValidChannel(name) {
|
||||
http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var incoming map[string]any
|
||||
if err = json.Unmarshal(body, &incoming); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
applyChannelUpdate(name, &cfg.Channels, incoming)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleToggleChannel enables or disables a channel.
|
||||
//
|
||||
// PATCH /api/channels/{name}/toggle
|
||||
func (h *Handler) handleToggleChannel(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if !isValidChannel(name) {
|
||||
http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
setChannelEnabled(name, &cfg.Channels, req.Enabled)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func isValidChannel(name string) bool {
|
||||
for _, m := range channelRegistry {
|
||||
if m.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractChannelInfo returns enabled, configured status and masked config for a channel.
|
||||
func extractChannelInfo(name string, ch *config.ChannelsConfig) (enabled, configured bool, cfg map[string]any) {
|
||||
cfg = make(map[string]any)
|
||||
switch name {
|
||||
case "telegram":
|
||||
c := ch.Telegram
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["base_url"] = c.BaseURL
|
||||
cfg["proxy"] = c.Proxy
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "discord":
|
||||
c := ch.Discord
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["proxy"] = c.Proxy
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "slack":
|
||||
c := ch.Slack
|
||||
enabled = c.Enabled
|
||||
configured = c.BotToken != ""
|
||||
cfg["bot_token"] = maskAPIKey(c.BotToken)
|
||||
cfg["app_token"] = maskAPIKey(c.AppToken)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "feishu":
|
||||
c := ch.Feishu
|
||||
enabled = c.Enabled
|
||||
configured = c.AppID != "" && c.AppSecret != ""
|
||||
cfg["app_id"] = c.AppID
|
||||
cfg["app_secret"] = maskAPIKey(c.AppSecret)
|
||||
cfg["encrypt_key"] = maskAPIKey(c.EncryptKey)
|
||||
cfg["verification_token"] = maskAPIKey(c.VerificationToken)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "dingtalk":
|
||||
c := ch.DingTalk
|
||||
enabled = c.Enabled
|
||||
configured = c.ClientID != "" && c.ClientSecret != ""
|
||||
cfg["client_id"] = c.ClientID
|
||||
cfg["client_secret"] = maskAPIKey(c.ClientSecret)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "line":
|
||||
c := ch.LINE
|
||||
enabled = c.Enabled
|
||||
configured = c.ChannelSecret != "" && c.ChannelAccessToken != ""
|
||||
cfg["channel_secret"] = maskAPIKey(c.ChannelSecret)
|
||||
cfg["channel_access_token"] = maskAPIKey(c.ChannelAccessToken)
|
||||
cfg["webhook_host"] = c.WebhookHost
|
||||
cfg["webhook_port"] = c.WebhookPort
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "qq":
|
||||
c := ch.QQ
|
||||
enabled = c.Enabled
|
||||
configured = c.AppID != "" && c.AppSecret != ""
|
||||
cfg["app_id"] = c.AppID
|
||||
cfg["app_secret"] = maskAPIKey(c.AppSecret)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "onebot":
|
||||
c := ch.OneBot
|
||||
enabled = c.Enabled
|
||||
configured = c.WSUrl != ""
|
||||
cfg["ws_url"] = c.WSUrl
|
||||
cfg["access_token"] = maskAPIKey(c.AccessToken)
|
||||
cfg["reconnect_interval"] = c.ReconnectInterval
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "wecom":
|
||||
c := ch.WeCom
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
|
||||
cfg["webhook_url"] = c.WebhookURL
|
||||
cfg["webhook_host"] = c.WebhookHost
|
||||
cfg["webhook_port"] = c.WebhookPort
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["reply_timeout"] = c.ReplyTimeout
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "wecom_app":
|
||||
c := ch.WeComApp
|
||||
enabled = c.Enabled
|
||||
configured = c.CorpID != "" && c.CorpSecret != ""
|
||||
cfg["corp_id"] = c.CorpID
|
||||
cfg["corp_secret"] = maskAPIKey(c.CorpSecret)
|
||||
cfg["agent_id"] = c.AgentID
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
|
||||
cfg["webhook_host"] = c.WebhookHost
|
||||
cfg["webhook_port"] = c.WebhookPort
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["reply_timeout"] = c.ReplyTimeout
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "wecom_aibot":
|
||||
c := ch.WeComAIBot
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["reply_timeout"] = c.ReplyTimeout
|
||||
cfg["max_steps"] = c.MaxSteps
|
||||
cfg["welcome_message"] = c.WelcomeMessage
|
||||
case "whatsapp":
|
||||
c := ch.WhatsApp
|
||||
enabled = c.Enabled
|
||||
configured = c.BridgeURL != "" || c.UseNative
|
||||
cfg["bridge_url"] = c.BridgeURL
|
||||
cfg["use_native"] = c.UseNative
|
||||
cfg["session_store_path"] = c.SessionStorePath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
case "pico":
|
||||
c := ch.Pico
|
||||
enabled = c.Enabled
|
||||
configured = true // Always considered configured (built-in WebSocket channel)
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["allow_token_query"] = c.AllowTokenQuery
|
||||
cfg["allow_origins"] = c.AllowOrigins
|
||||
cfg["ping_interval"] = c.PingInterval
|
||||
cfg["read_timeout"] = c.ReadTimeout
|
||||
cfg["write_timeout"] = c.WriteTimeout
|
||||
cfg["max_connections"] = c.MaxConnections
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "maixcam":
|
||||
c := ch.MaixCam
|
||||
enabled = c.Enabled
|
||||
configured = c.Host != ""
|
||||
cfg["host"] = c.Host
|
||||
cfg["port"] = c.Port
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// setChannelEnabled sets the enabled flag for a channel.
|
||||
func setChannelEnabled(name string, ch *config.ChannelsConfig, enabled bool) {
|
||||
switch name {
|
||||
case "telegram":
|
||||
ch.Telegram.Enabled = enabled
|
||||
case "discord":
|
||||
ch.Discord.Enabled = enabled
|
||||
case "slack":
|
||||
ch.Slack.Enabled = enabled
|
||||
case "feishu":
|
||||
ch.Feishu.Enabled = enabled
|
||||
case "dingtalk":
|
||||
ch.DingTalk.Enabled = enabled
|
||||
case "line":
|
||||
ch.LINE.Enabled = enabled
|
||||
case "qq":
|
||||
ch.QQ.Enabled = enabled
|
||||
case "onebot":
|
||||
ch.OneBot.Enabled = enabled
|
||||
case "wecom":
|
||||
ch.WeCom.Enabled = enabled
|
||||
case "wecom_app":
|
||||
ch.WeComApp.Enabled = enabled
|
||||
case "wecom_aibot":
|
||||
ch.WeComAIBot.Enabled = enabled
|
||||
case "whatsapp":
|
||||
ch.WhatsApp.Enabled = enabled
|
||||
case "pico":
|
||||
ch.Pico.Enabled = enabled
|
||||
case "maixcam":
|
||||
ch.MaixCam.Enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// applyChannelUpdate applies incoming config fields to the corresponding channel.
|
||||
// Empty secret fields are preserved from the existing config.
|
||||
func applyChannelUpdate(name string, ch *config.ChannelsConfig, incoming map[string]any) {
|
||||
getString := func(key string) string {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
getBool := func(key string) bool {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
getInt := func(key string) int {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
getInt64 := func(key string) int64 {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int64(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
getStringSlice := func(key string) config.FlexibleStringSlice {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if arr, ok := v.([]any); ok {
|
||||
result := make(config.FlexibleStringSlice, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
getGroupTrigger := func() config.GroupTriggerConfig {
|
||||
if v, ok := incoming["group_trigger"]; ok {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
gt := config.GroupTriggerConfig{}
|
||||
if b, ok := m["mention_only"].(bool); ok {
|
||||
gt.MentionOnly = b
|
||||
}
|
||||
if arr, ok := m["prefixes"].([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
gt.Prefixes = append(gt.Prefixes, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return gt
|
||||
}
|
||||
}
|
||||
return config.GroupTriggerConfig{}
|
||||
}
|
||||
getTyping := func() config.TypingConfig {
|
||||
if v, ok := incoming["typing"]; ok {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
if b, ok := m["enabled"].(bool); ok {
|
||||
return config.TypingConfig{Enabled: b}
|
||||
}
|
||||
}
|
||||
}
|
||||
return config.TypingConfig{}
|
||||
}
|
||||
getPlaceholder := func() config.PlaceholderConfig {
|
||||
if v, ok := incoming["placeholder"]; ok {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
pc := config.PlaceholderConfig{}
|
||||
if b, ok := m["enabled"].(bool); ok {
|
||||
pc.Enabled = b
|
||||
}
|
||||
if s, ok := m["text"].(string); ok {
|
||||
pc.Text = s
|
||||
}
|
||||
return pc
|
||||
}
|
||||
}
|
||||
return config.PlaceholderConfig{}
|
||||
}
|
||||
|
||||
// preserveSecret returns the incoming value if non-empty, or keeps existing.
|
||||
preserveSecret := func(incoming, existing string) string {
|
||||
if incoming == "" {
|
||||
return existing
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "telegram":
|
||||
c := &ch.Telegram
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.BaseURL = getString("base_url")
|
||||
c.Proxy = getString("proxy")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "discord":
|
||||
c := &ch.Discord
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.Proxy = getString("proxy")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "slack":
|
||||
c := &ch.Slack
|
||||
c.Enabled = getBool("enabled")
|
||||
c.BotToken = preserveSecret(getString("bot_token"), c.BotToken)
|
||||
c.AppToken = preserveSecret(getString("app_token"), c.AppToken)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "feishu":
|
||||
c := &ch.Feishu
|
||||
c.Enabled = getBool("enabled")
|
||||
c.AppID = getString("app_id")
|
||||
c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret)
|
||||
c.EncryptKey = preserveSecret(getString("encrypt_key"), c.EncryptKey)
|
||||
c.VerificationToken = preserveSecret(getString("verification_token"), c.VerificationToken)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "dingtalk":
|
||||
c := &ch.DingTalk
|
||||
c.Enabled = getBool("enabled")
|
||||
c.ClientID = getString("client_id")
|
||||
c.ClientSecret = preserveSecret(getString("client_secret"), c.ClientSecret)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "line":
|
||||
c := &ch.LINE
|
||||
c.Enabled = getBool("enabled")
|
||||
c.ChannelSecret = preserveSecret(getString("channel_secret"), c.ChannelSecret)
|
||||
c.ChannelAccessToken = preserveSecret(getString("channel_access_token"), c.ChannelAccessToken)
|
||||
c.WebhookHost = getString("webhook_host")
|
||||
c.WebhookPort = getInt("webhook_port")
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "qq":
|
||||
c := &ch.QQ
|
||||
c.Enabled = getBool("enabled")
|
||||
c.AppID = getString("app_id")
|
||||
c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "onebot":
|
||||
c := &ch.OneBot
|
||||
c.Enabled = getBool("enabled")
|
||||
c.WSUrl = getString("ws_url")
|
||||
c.AccessToken = preserveSecret(getString("access_token"), c.AccessToken)
|
||||
c.ReconnectInterval = getInt("reconnect_interval")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "wecom":
|
||||
c := &ch.WeCom
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
|
||||
c.WebhookURL = getString("webhook_url")
|
||||
c.WebhookHost = getString("webhook_host")
|
||||
c.WebhookPort = getInt("webhook_port")
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.ReplyTimeout = getInt("reply_timeout")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "wecom_app":
|
||||
c := &ch.WeComApp
|
||||
c.Enabled = getBool("enabled")
|
||||
c.CorpID = getString("corp_id")
|
||||
c.CorpSecret = preserveSecret(getString("corp_secret"), c.CorpSecret)
|
||||
c.AgentID = getInt64("agent_id")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
|
||||
c.WebhookHost = getString("webhook_host")
|
||||
c.WebhookPort = getInt("webhook_port")
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.ReplyTimeout = getInt("reply_timeout")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "wecom_aibot":
|
||||
c := &ch.WeComAIBot
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.ReplyTimeout = getInt("reply_timeout")
|
||||
c.MaxSteps = getInt("max_steps")
|
||||
c.WelcomeMessage = getString("welcome_message")
|
||||
case "whatsapp":
|
||||
c := &ch.WhatsApp
|
||||
c.Enabled = getBool("enabled")
|
||||
c.BridgeURL = getString("bridge_url")
|
||||
c.UseNative = getBool("use_native")
|
||||
c.SessionStorePath = getString("session_store_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
case "pico":
|
||||
c := &ch.Pico
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.AllowTokenQuery = getBool("allow_token_query")
|
||||
if v, ok := incoming["allow_origins"]; ok {
|
||||
if arr, ok := v.([]any); ok {
|
||||
origins := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
origins = append(origins, s)
|
||||
}
|
||||
}
|
||||
c.AllowOrigins = origins
|
||||
}
|
||||
}
|
||||
c.PingInterval = getInt("ping_interval")
|
||||
c.ReadTimeout = getInt("read_timeout")
|
||||
c.WriteTimeout = getInt("write_timeout")
|
||||
c.MaxConnections = getInt("max_connections")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "maixcam":
|
||||
c := &ch.MaixCam
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Host = getString("host")
|
||||
c.Port = getInt("port")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
}
|
||||
}
|
||||
|
|
@ -41,4 +41,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
|
||||
// Model list management
|
||||
h.registerModelRoutes(mux)
|
||||
|
||||
// Channel management
|
||||
h.registerChannelRoutes(mux)
|
||||
}
|
||||
|
|
|
|||
55
web/frontend/src/api/channels.ts
Normal file
55
web/frontend/src/api/channels.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// API client for channel management.
|
||||
|
||||
export interface ChannelInfo {
|
||||
name: string
|
||||
display_name: string
|
||||
enabled: boolean
|
||||
configured: boolean
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
interface ChannelsListResponse {
|
||||
channels: ChannelInfo[]
|
||||
}
|
||||
|
||||
interface ChannelActionResponse {
|
||||
status: string
|
||||
}
|
||||
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getChannels(): Promise<ChannelsListResponse> {
|
||||
return request<ChannelsListResponse>("/api/channels")
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
name: string,
|
||||
config: Record<string, any>,
|
||||
): Promise<ChannelActionResponse> {
|
||||
return request<ChannelActionResponse>(`/api/channels/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
export async function toggleChannel(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
): Promise<ChannelActionResponse> {
|
||||
return request<ChannelActionResponse>(`/api/channels/${name}/toggle`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
}
|
||||
|
||||
export type { ChannelsListResponse, ChannelActionResponse }
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IconKey,
|
||||
IconListDetails,
|
||||
IconMessageCircle,
|
||||
IconPlug,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
|
|
@ -46,6 +47,7 @@ const navGroups = [
|
|||
label: "navigation.services",
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ title: "navigation.channels", url: "/channels", icon: IconPlug },
|
||||
{ title: "navigation.config", url: "/config", icon: IconSettings },
|
||||
{ title: "navigation.logs", url: "/logs", icon: IconListDetails },
|
||||
],
|
||||
|
|
|
|||
64
web/frontend/src/components/channels/channel-card.tsx
Normal file
64
web/frontend/src/components/channels/channel-card.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { IconSettings } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelInfo } from "@/api/channels"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface ChannelCardProps {
|
||||
channel: ChannelInfo
|
||||
onToggle: (name: string, enabled: boolean) => void
|
||||
onEdit: (channel: ChannelInfo) => void
|
||||
toggling: boolean
|
||||
}
|
||||
|
||||
export function ChannelCard({
|
||||
channel,
|
||||
onToggle,
|
||||
onEdit,
|
||||
toggling,
|
||||
}: ChannelCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<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="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full px-2 py-0.5 text-[10px] font-medium">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
|
||||
interface DiscordFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
isEdit: boolean
|
||||
}
|
||||
|
||||
export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
hint={
|
||||
isEdit && config.token
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._token ?? ""}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.token
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.tokenPlaceholder")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={config.proxy ?? ""}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
|
||||
interface FeishuFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
isEdit: boolean
|
||||
}
|
||||
|
||||
export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field label={t("channels.field.appId")}>
|
||||
<Input
|
||||
value={config.app_id ?? ""}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
placeholder="cli_xxxx"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
hint={
|
||||
isEdit && config.app_secret
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._app_secret ?? ""}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={
|
||||
isEdit && config.app_secret
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field label={t("channels.field.verificationToken")}>
|
||||
<KeyInput
|
||||
value={config._verification_token ?? ""}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.verification_token
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("channels.field.encryptKey")}>
|
||||
<KeyInput
|
||||
value={config._encrypt_key ?? ""}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={
|
||||
isEdit && config.encrypt_key
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, KeyInput } from "@/components/models/shared-form"
|
||||
|
||||
interface GenericFormProps {
|
||||
channelName: string
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
isEdit: boolean
|
||||
}
|
||||
|
||||
// Secret field names that should use masked input.
|
||||
const SECRET_FIELDS = new Set([
|
||||
"token",
|
||||
"app_secret",
|
||||
"client_secret",
|
||||
"corp_secret",
|
||||
"channel_secret",
|
||||
"channel_access_token",
|
||||
"access_token",
|
||||
"bot_token",
|
||||
"app_token",
|
||||
"encoding_aes_key",
|
||||
"encrypt_key",
|
||||
"verification_token",
|
||||
])
|
||||
|
||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
|
||||
|
||||
// Fields that are objects/nested — show as JSON or skip.
|
||||
const OBJECT_FIELDS = new Set([
|
||||
"group_trigger",
|
||||
"typing",
|
||||
"placeholder",
|
||||
"allow_from",
|
||||
])
|
||||
|
||||
function formatLabel(key: string): string {
|
||||
return key
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export function GenericForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
}: GenericFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const fields = Object.keys(config).filter(
|
||||
(k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && !OBJECT_FIELDS.has(k),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fields.map((key) => {
|
||||
if (SECRET_FIELDS.has(key)) {
|
||||
const editKey = `_${key}`
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
hint={
|
||||
isEdit && config[key]
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config[editKey] ?? ""}
|
||||
onChange={(v) => onChange(editKey, v)}
|
||||
placeholder={
|
||||
isEdit && config[key]
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const value = config[key]
|
||||
if (typeof value === "boolean") {
|
||||
return null // Booleans are less common in generic; skip for now
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={key} label={formatLabel(key)}>
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
// Attempt to preserve number types
|
||||
const v = e.target.value
|
||||
if (typeof config[key] === "number") {
|
||||
onChange(key, v === "" ? 0 : Number(v))
|
||||
} else {
|
||||
onChange(key, v)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Allow From field */}
|
||||
{config.allow_from !== undefined && (
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
|
||||
interface SlackFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
isEdit: boolean
|
||||
}
|
||||
|
||||
export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.botToken")}
|
||||
hint={
|
||||
isEdit && config.bot_token
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._bot_token ?? ""}
|
||||
onChange={(v) => onChange("_bot_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.bot_token
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: "xoxb-xxxx"
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appToken")}
|
||||
hint={
|
||||
isEdit && config.app_token
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._app_token ?? ""}
|
||||
onChange={(v) => onChange("_app_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.app_token
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: "xapp-xxxx"
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
|
||||
interface TelegramFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
isEdit: boolean
|
||||
}
|
||||
|
||||
export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
hint={
|
||||
isEdit && config.token
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._token ?? ""}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.token
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.tokenPlaceholder")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field label={t("channels.field.baseUrl")}>
|
||||
<Input
|
||||
value={config.base_url ?? ""}
|
||||
onChange={(e) => onChange("base_url", e.target.value)}
|
||||
placeholder="https://api.telegram.org"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={config.proxy ?? ""}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
web/frontend/src/components/channels/channels-page.tsx
Normal file
137
web/frontend/src/components/channels/channels-page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { IconSearch } from "@tabler/icons-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelInfo } from "@/api/channels"
|
||||
import { getChannels, toggleChannel } from "@/api/channels"
|
||||
import { ChannelCard } from "@/components/channels/channel-card"
|
||||
import { EditChannelSheet } from "@/components/channels/edit-channel-sheet"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export function ChannelsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [channels, setChannels] = useState<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>
|
||||
)
|
||||
}
|
||||
196
web/frontend/src/components/channels/edit-channel-sheet.tsx
Normal file
196
web/frontend/src/components/channels/edit-channel-sheet.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelInfo } from "@/api/channels"
|
||||
import { updateChannel } from "@/api/channels"
|
||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
interface EditChannelSheetProps {
|
||||
channel: ChannelInfo | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
// Map of secret config keys to their edit-buffer keys.
|
||||
// When editing, we use _token etc. to avoid overwriting with masked values.
|
||||
const SECRET_FIELD_MAP: Record<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: Record<string, any>): Record<string, any> {
|
||||
const edit: Record<string, any> = { ...config }
|
||||
// Initialize edit buffer keys for secrets as empty (user fills new values)
|
||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
||||
if (secretKey in config) {
|
||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
||||
}
|
||||
}
|
||||
return edit
|
||||
}
|
||||
|
||||
function buildSavePayload(
|
||||
channel: ChannelInfo,
|
||||
editConfig: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const payload: Record<string, any> = { 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<Record<string, any>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [serverError, setServerError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
setEditConfig(buildEditConfig(channel.config))
|
||||
setServerError("")
|
||||
}
|
||||
}, [channel])
|
||||
|
||||
const handleChange = useCallback((key: string, value: any) => {
|
||||
setEditConfig((prev) => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!channel) return
|
||||
setSaving(true)
|
||||
setServerError("")
|
||||
try {
|
||||
await updateChannel(channel.name, buildSavePayload(channel, editConfig))
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setServerError(
|
||||
e instanceof Error ? e.message : t("channels.edit.saveError"),
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderForm = () => {
|
||||
if (!channel) return null
|
||||
const isEdit = channel.configured
|
||||
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return (
|
||||
<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-1 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"models": "Models",
|
||||
"credentials": "Credentials",
|
||||
"services": "Services",
|
||||
"channels": "Channels",
|
||||
"config": "Config",
|
||||
"logs": "Logs"
|
||||
},
|
||||
|
|
@ -215,6 +216,47 @@
|
|||
"saveError": "Failed to save"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"description": "Configure messaging channels to connect your AI agent to chat platforms.",
|
||||
"loadError": "Failed to load channels",
|
||||
"search": "Search channels...",
|
||||
"noResults": "No channels match your search.",
|
||||
"header": {
|
||||
"enabled": "enabled"
|
||||
},
|
||||
"status": {
|
||||
"configured": "Configured",
|
||||
"unconfigured": "Not configured"
|
||||
},
|
||||
"action": {
|
||||
"configure": "Configure"
|
||||
},
|
||||
"field": {
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "Enter bot token",
|
||||
"botToken": "Bot Token",
|
||||
"appToken": "App Token",
|
||||
"appId": "App ID",
|
||||
"appSecret": "App Secret",
|
||||
"verificationToken": "Verification Token",
|
||||
"encryptKey": "Encrypt Key",
|
||||
"baseUrl": "API Base URL",
|
||||
"proxy": "HTTP Proxy",
|
||||
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
||||
"allowFrom": "Allow From",
|
||||
"allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.",
|
||||
"allowFromPlaceholder": "e.g. 123456, 789012",
|
||||
"secretPlaceholder": "Enter secret",
|
||||
"secretPlaceholderSet": "Leave blank to keep existing",
|
||||
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Configure {{name}}",
|
||||
"description": "Set up credentials and options for this channel.",
|
||||
"saveError": "Failed to save channel configuration",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"providers": {
|
||||
"description": "Manage AI model providers and configurations."
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"models": "模型",
|
||||
"credentials": "凭据",
|
||||
"services": "服务",
|
||||
"channels": "频道",
|
||||
"config": "配置",
|
||||
"logs": "日志"
|
||||
},
|
||||
|
|
@ -215,6 +216,47 @@
|
|||
"saveError": "保存失败"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"description": "配置消息频道,将 AI 助手连接到各聊天平台。",
|
||||
"loadError": "加载频道列表失败",
|
||||
"search": "搜索频道...",
|
||||
"noResults": "没有匹配的频道。",
|
||||
"header": {
|
||||
"enabled": "已启用"
|
||||
},
|
||||
"status": {
|
||||
"configured": "已配置",
|
||||
"unconfigured": "未配置"
|
||||
},
|
||||
"action": {
|
||||
"configure": "配置"
|
||||
},
|
||||
"field": {
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "输入 Bot Token",
|
||||
"botToken": "Bot Token",
|
||||
"appToken": "App Token",
|
||||
"appId": "App ID",
|
||||
"appSecret": "App Secret",
|
||||
"verificationToken": "Verification Token",
|
||||
"encryptKey": "Encrypt Key",
|
||||
"baseUrl": "API Base URL",
|
||||
"proxy": "HTTP 代理",
|
||||
"proxyHint": "可选。例如 http://127.0.0.1:7890",
|
||||
"allowFrom": "允许来源",
|
||||
"allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。",
|
||||
"allowFromPlaceholder": "例如 123456, 789012",
|
||||
"secretPlaceholder": "输入密钥",
|
||||
"secretPlaceholderSet": "留空保持原有值不变",
|
||||
"secretHintSet": "已设置密钥,留空表示不修改。"
|
||||
},
|
||||
"edit": {
|
||||
"title": "配置 {{name}}",
|
||||
"description": "设置此频道的凭据和选项。",
|
||||
"saveError": "保存频道配置失败",
|
||||
"saving": "保存中..."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"providers": {
|
||||
"description": "管理各个 AI 模型服务商的接入配置。"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Route as ModelsRouteImport } from './routes/models'
|
|||
import { Route as LogsRouteImport } from './routes/logs'
|
||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||
import { Route as ConfigRouteImport } from './routes/config'
|
||||
import { Route as ChannelsRouteImport } from './routes/channels'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const ProvidersRoute = ProvidersRouteImport.update({
|
||||
|
|
@ -41,6 +42,11 @@ const ConfigRoute = ConfigRouteImport.update({
|
|||
path: '/config',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ChannelsRoute = ChannelsRouteImport.update({
|
||||
id: '/channels',
|
||||
path: '/channels',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
|
|
@ -49,6 +55,7 @@ const IndexRoute = IndexRouteImport.update({
|
|||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/config': typeof ConfigRoute
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
|
|
@ -57,6 +64,7 @@ export interface FileRoutesByFullPath {
|
|||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/config': typeof ConfigRoute
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
|
|
@ -66,6 +74,7 @@ export interface FileRoutesByTo {
|
|||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/config': typeof ConfigRoute
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
|
|
@ -76,16 +85,25 @@ export interface FileRouteTypes {
|
|||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/providers'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/config' | '/credentials' | '/logs' | '/models' | '/providers'
|
||||
to:
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/providers'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/logs'
|
||||
|
|
@ -95,6 +113,7 @@ export interface FileRouteTypes {
|
|||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ChannelsRoute: typeof ChannelsRoute
|
||||
ConfigRoute: typeof ConfigRoute
|
||||
CredentialsRoute: typeof CredentialsRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
|
|
@ -139,6 +158,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof ConfigRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/channels': {
|
||||
id: '/channels'
|
||||
path: '/channels'
|
||||
fullPath: '/channels'
|
||||
preLoaderRoute: typeof ChannelsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
|
|
@ -151,6 +177,7 @@ declare module '@tanstack/react-router' {
|
|||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ChannelsRoute: ChannelsRoute,
|
||||
ConfigRoute: ConfigRoute,
|
||||
CredentialsRoute: CredentialsRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
|
|
|
|||
7
web/frontend/src/routes/channels.tsx
Normal file
7
web/frontend/src/routes/channels.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { ChannelsPage } from "@/components/channels/channels-page"
|
||||
|
||||
export const Route = createFileRoute("/channels")({
|
||||
component: ChannelsPage,
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue