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:
|
build-launcher:
|
||||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@if [ ! -d web/backend/dist ]; then \
|
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||||
echo "Building frontend..."; \
|
echo "Building frontend..."; \
|
||||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||||
fi
|
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
|
// Model list management
|
||||||
h.registerModelRoutes(mux)
|
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,
|
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"
|
||||||
|
|
@ -46,6 +47,7 @@ const navGroups = [
|
||||||
label: "navigation.services",
|
label: "navigation.services",
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
items: [
|
items: [
|
||||||
|
{ title: "navigation.channels", url: "/channels", icon: IconPlug },
|
||||||
{ title: "navigation.config", url: "/config", icon: IconSettings },
|
{ title: "navigation.config", url: "/config", icon: IconSettings },
|
||||||
{ title: "navigation.logs", url: "/logs", icon: IconListDetails },
|
{ 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",
|
"models": "Models",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
|
"channels": "Channels",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
"logs": "Logs"
|
"logs": "Logs"
|
||||||
},
|
},
|
||||||
|
|
@ -215,6 +216,47 @@
|
||||||
"saveError": "Failed to save"
|
"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": {
|
"pages": {
|
||||||
"providers": {
|
"providers": {
|
||||||
"description": "Manage AI model providers and configurations."
|
"description": "Manage AI model providers and configurations."
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"credentials": "凭据",
|
"credentials": "凭据",
|
||||||
"services": "服务",
|
"services": "服务",
|
||||||
|
"channels": "频道",
|
||||||
"config": "配置",
|
"config": "配置",
|
||||||
"logs": "日志"
|
"logs": "日志"
|
||||||
},
|
},
|
||||||
|
|
@ -215,6 +216,47 @@
|
||||||
"saveError": "保存失败"
|
"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": {
|
"pages": {
|
||||||
"providers": {
|
"providers": {
|
||||||
"description": "管理各个 AI 模型服务商的接入配置。"
|
"description": "管理各个 AI 模型服务商的接入配置。"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ 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 IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
const ProvidersRoute = ProvidersRouteImport.update({
|
const ProvidersRoute = ProvidersRouteImport.update({
|
||||||
|
|
@ -41,6 +42,11 @@ const ConfigRoute = ConfigRouteImport.update({
|
||||||
path: '/config',
|
path: '/config',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ChannelsRoute = ChannelsRouteImport.update({
|
||||||
|
id: '/channels',
|
||||||
|
path: '/channels',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
@ -49,6 +55,7 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/channels': typeof ChannelsRoute
|
||||||
'/config': typeof ConfigRoute
|
'/config': typeof ConfigRoute
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
|
|
@ -57,6 +64,7 @@ export interface FileRoutesByFullPath {
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/channels': typeof ChannelsRoute
|
||||||
'/config': typeof ConfigRoute
|
'/config': typeof ConfigRoute
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
|
|
@ -66,6 +74,7 @@ export interface FileRoutesByTo {
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/channels': typeof ChannelsRoute
|
||||||
'/config': typeof ConfigRoute
|
'/config': typeof ConfigRoute
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
|
|
@ -76,16 +85,25 @@ export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/channels'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/providers'
|
| '/providers'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/config' | '/credentials' | '/logs' | '/models' | '/providers'
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/channels'
|
||||||
|
| '/config'
|
||||||
|
| '/credentials'
|
||||||
|
| '/logs'
|
||||||
|
| '/models'
|
||||||
|
| '/providers'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/channels'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
|
|
@ -95,6 +113,7 @@ export interface FileRouteTypes {
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
ChannelsRoute: typeof ChannelsRoute
|
||||||
ConfigRoute: typeof ConfigRoute
|
ConfigRoute: typeof ConfigRoute
|
||||||
CredentialsRoute: typeof CredentialsRoute
|
CredentialsRoute: typeof CredentialsRoute
|
||||||
LogsRoute: typeof LogsRoute
|
LogsRoute: typeof LogsRoute
|
||||||
|
|
@ -139,6 +158,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ConfigRouteImport
|
preLoaderRoute: typeof ConfigRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/channels': {
|
||||||
|
id: '/channels'
|
||||||
|
path: '/channels'
|
||||||
|
fullPath: '/channels'
|
||||||
|
preLoaderRoute: typeof ChannelsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
|
|
@ -151,6 +177,7 @@ declare module '@tanstack/react-router' {
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
ChannelsRoute: ChannelsRoute,
|
||||||
ConfigRoute: ConfigRoute,
|
ConfigRoute: ConfigRoute,
|
||||||
CredentialsRoute: CredentialsRoute,
|
CredentialsRoute: CredentialsRoute,
|
||||||
LogsRoute: LogsRoute,
|
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