feat(web): revamp channel config UX with catalog-based routing
- replace legacy channel management endpoints with a backend channel catalog API - switch frontend channel updates to PATCH /api/config and per-channel config pages - add dynamic channel items in the sidebar with support for expand/collapse - migrate /channels to nested routes (/channels/$name) and remove old card/sheet flow - improve channel forms with clearer hints, required/error states, and reusable switch cards - fix Discord mention-only toggle to read/write group_trigger.mention_only
This commit is contained in:
parent
efed6784af
commit
d7b9534dbc
25 changed files with 1880 additions and 1355 deletions
|
|
@ -2,667 +2,46 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// registerChannelRoutes binds channel management endpoints to the ServeMux.
|
||||
type channelCatalogItem struct {
|
||||
Name string `json:"name"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
}
|
||||
|
||||
var channelCatalog = []channelCatalogItem{
|
||||
{Name: "telegram", ConfigKey: "telegram"},
|
||||
{Name: "discord", ConfigKey: "discord"},
|
||||
{Name: "slack", ConfigKey: "slack"},
|
||||
{Name: "feishu", ConfigKey: "feishu"},
|
||||
{Name: "dingtalk", ConfigKey: "dingtalk"},
|
||||
{Name: "line", ConfigKey: "line"},
|
||||
{Name: "qq", ConfigKey: "qq"},
|
||||
{Name: "onebot", ConfigKey: "onebot"},
|
||||
{Name: "wecom", ConfigKey: "wecom"},
|
||||
{Name: "wecom_app", ConfigKey: "wecom_app"},
|
||||
{Name: "wecom_aibot", ConfigKey: "wecom_aibot"},
|
||||
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
|
||||
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
|
||||
{Name: "pico", ConfigKey: "pico"},
|
||||
{Name: "maixcam", ConfigKey: "maixcam"},
|
||||
{Name: "matrix", ConfigKey: "matrix"},
|
||||
{Name: "irc", ConfigKey: "irc"},
|
||||
}
|
||||
|
||||
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
||||
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/channels", h.handleListChannels)
|
||||
mux.HandleFunc("PUT /api/channels/{name}", h.handleUpdateChannel)
|
||||
mux.HandleFunc("PATCH /api/channels/{name}/toggle", h.handleToggleChannel)
|
||||
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
||||
}
|
||||
|
||||
// channelMeta holds static metadata for each supported channel.
|
||||
type channelMeta struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
var channelRegistry = []channelMeta{
|
||||
{Name: "telegram", DisplayName: "Telegram"},
|
||||
{Name: "discord", DisplayName: "Discord"},
|
||||
{Name: "slack", DisplayName: "Slack"},
|
||||
{Name: "feishu", DisplayName: "Feishu"},
|
||||
{Name: "dingtalk", DisplayName: "DingTalk"},
|
||||
{Name: "line", DisplayName: "LINE"},
|
||||
{Name: "qq", DisplayName: "QQ"},
|
||||
{Name: "onebot", DisplayName: "OneBot"},
|
||||
{Name: "wecom", DisplayName: "WeCom"},
|
||||
{Name: "wecom_app", DisplayName: "WeCom App"},
|
||||
{Name: "wecom_aibot", DisplayName: "WeCom AI Bot"},
|
||||
{Name: "whatsapp", DisplayName: "WhatsApp"},
|
||||
{Name: "pico", DisplayName: "Pico (Web)"},
|
||||
{Name: "maixcam", DisplayName: "MaixCAM"},
|
||||
}
|
||||
|
||||
// channelResponse is the JSON structure returned for each channel in the list.
|
||||
type channelResponse struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
// handleListChannels returns all channels with their enabled status and masked secrets.
|
||||
// handleListChannelCatalog returns the channels supported by backend.
|
||||
//
|
||||
// GET /api/channels
|
||||
func (h *Handler) handleListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := h.loadFilteredConfig()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
channels := make([]channelResponse, 0, len(channelRegistry))
|
||||
for _, meta := range channelRegistry {
|
||||
cr := channelResponse{
|
||||
Name: meta.Name,
|
||||
DisplayName: meta.DisplayName,
|
||||
}
|
||||
cr.Enabled, cr.Configured, cr.Config = extractChannelInfo(meta.Name, &cfg.Channels)
|
||||
channels = append(channels, cr)
|
||||
}
|
||||
|
||||
// GET /api/channels/catalog
|
||||
func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"channels": channels,
|
||||
"channels": channelCatalog,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateChannel replaces a channel's configuration.
|
||||
// Secret fields sent as empty strings are preserved from the existing config.
|
||||
//
|
||||
// PUT /api/channels/{name}
|
||||
func (h *Handler) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if !isValidChannel(name) {
|
||||
http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var incoming map[string]any
|
||||
if err = json.Unmarshal(body, &incoming); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
applyChannelUpdate(name, &cfg.Channels, incoming)
|
||||
if errs := validateConfig(cfg); len(errs) > 0 {
|
||||
writeValidationErrors(w, errs)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleToggleChannel enables or disables a channel.
|
||||
//
|
||||
// PATCH /api/channels/{name}/toggle
|
||||
func (h *Handler) handleToggleChannel(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if !isValidChannel(name) {
|
||||
http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Enabled == nil {
|
||||
http.Error(w, "Missing required field: enabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
setChannelEnabled(name, &cfg.Channels, *req.Enabled)
|
||||
if errs := validateConfig(cfg); len(errs) > 0 {
|
||||
writeValidationErrors(w, errs)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func isValidChannel(name string) bool {
|
||||
for _, m := range channelRegistry {
|
||||
if m.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeValidationErrors(w http.ResponseWriter, errs []string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "validation_error",
|
||||
"errors": errs,
|
||||
})
|
||||
}
|
||||
|
||||
// extractChannelInfo returns enabled, configured status and masked config for a channel.
|
||||
func extractChannelInfo(name string, ch *config.ChannelsConfig) (bool, bool, map[string]any) {
|
||||
var enabled, configured bool
|
||||
cfg := make(map[string]any)
|
||||
switch name {
|
||||
case "telegram":
|
||||
c := ch.Telegram
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["base_url"] = c.BaseURL
|
||||
cfg["proxy"] = c.Proxy
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "discord":
|
||||
c := ch.Discord
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["proxy"] = c.Proxy
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "slack":
|
||||
c := ch.Slack
|
||||
enabled = c.Enabled
|
||||
configured = c.BotToken != ""
|
||||
cfg["bot_token"] = maskAPIKey(c.BotToken)
|
||||
cfg["app_token"] = maskAPIKey(c.AppToken)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "feishu":
|
||||
c := ch.Feishu
|
||||
enabled = c.Enabled
|
||||
configured = c.AppID != "" && c.AppSecret != ""
|
||||
cfg["app_id"] = c.AppID
|
||||
cfg["app_secret"] = maskAPIKey(c.AppSecret)
|
||||
cfg["encrypt_key"] = maskAPIKey(c.EncryptKey)
|
||||
cfg["verification_token"] = maskAPIKey(c.VerificationToken)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "dingtalk":
|
||||
c := ch.DingTalk
|
||||
enabled = c.Enabled
|
||||
configured = c.ClientID != "" && c.ClientSecret != ""
|
||||
cfg["client_id"] = c.ClientID
|
||||
cfg["client_secret"] = maskAPIKey(c.ClientSecret)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "line":
|
||||
c := ch.LINE
|
||||
enabled = c.Enabled
|
||||
configured = c.ChannelSecret != "" && c.ChannelAccessToken != ""
|
||||
cfg["channel_secret"] = maskAPIKey(c.ChannelSecret)
|
||||
cfg["channel_access_token"] = maskAPIKey(c.ChannelAccessToken)
|
||||
cfg["webhook_host"] = c.WebhookHost
|
||||
cfg["webhook_port"] = c.WebhookPort
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "qq":
|
||||
c := ch.QQ
|
||||
enabled = c.Enabled
|
||||
configured = c.AppID != "" && c.AppSecret != ""
|
||||
cfg["app_id"] = c.AppID
|
||||
cfg["app_secret"] = maskAPIKey(c.AppSecret)
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "onebot":
|
||||
c := ch.OneBot
|
||||
enabled = c.Enabled
|
||||
configured = c.WSUrl != ""
|
||||
cfg["ws_url"] = c.WSUrl
|
||||
cfg["access_token"] = maskAPIKey(c.AccessToken)
|
||||
cfg["reconnect_interval"] = c.ReconnectInterval
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
cfg["typing"] = c.Typing
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "wecom":
|
||||
c := ch.WeCom
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
|
||||
cfg["webhook_url"] = c.WebhookURL
|
||||
cfg["webhook_host"] = c.WebhookHost
|
||||
cfg["webhook_port"] = c.WebhookPort
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["reply_timeout"] = c.ReplyTimeout
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "wecom_app":
|
||||
c := ch.WeComApp
|
||||
enabled = c.Enabled
|
||||
configured = c.CorpID != "" && c.CorpSecret != ""
|
||||
cfg["corp_id"] = c.CorpID
|
||||
cfg["corp_secret"] = maskAPIKey(c.CorpSecret)
|
||||
cfg["agent_id"] = c.AgentID
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
|
||||
cfg["webhook_host"] = c.WebhookHost
|
||||
cfg["webhook_port"] = c.WebhookPort
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["reply_timeout"] = c.ReplyTimeout
|
||||
cfg["group_trigger"] = c.GroupTrigger
|
||||
case "wecom_aibot":
|
||||
c := ch.WeComAIBot
|
||||
enabled = c.Enabled
|
||||
configured = c.Token != ""
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
|
||||
cfg["webhook_path"] = c.WebhookPath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["reply_timeout"] = c.ReplyTimeout
|
||||
cfg["max_steps"] = c.MaxSteps
|
||||
cfg["welcome_message"] = c.WelcomeMessage
|
||||
case "whatsapp":
|
||||
c := ch.WhatsApp
|
||||
enabled = c.Enabled
|
||||
configured = c.BridgeURL != "" || c.UseNative
|
||||
cfg["bridge_url"] = c.BridgeURL
|
||||
cfg["use_native"] = c.UseNative
|
||||
cfg["session_store_path"] = c.SessionStorePath
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
case "pico":
|
||||
c := ch.Pico
|
||||
enabled = c.Enabled
|
||||
configured = true // Always considered configured (built-in WebSocket channel)
|
||||
cfg["token"] = maskAPIKey(c.Token)
|
||||
cfg["allow_token_query"] = c.AllowTokenQuery
|
||||
cfg["allow_origins"] = c.AllowOrigins
|
||||
cfg["ping_interval"] = c.PingInterval
|
||||
cfg["read_timeout"] = c.ReadTimeout
|
||||
cfg["write_timeout"] = c.WriteTimeout
|
||||
cfg["max_connections"] = c.MaxConnections
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
cfg["placeholder"] = c.Placeholder
|
||||
case "maixcam":
|
||||
c := ch.MaixCam
|
||||
enabled = c.Enabled
|
||||
configured = c.Host != ""
|
||||
cfg["host"] = c.Host
|
||||
cfg["port"] = c.Port
|
||||
cfg["allow_from"] = []string(c.AllowFrom)
|
||||
}
|
||||
return enabled, configured, cfg
|
||||
}
|
||||
|
||||
// setChannelEnabled sets the enabled flag for a channel.
|
||||
func setChannelEnabled(name string, ch *config.ChannelsConfig, enabled bool) {
|
||||
switch name {
|
||||
case "telegram":
|
||||
ch.Telegram.Enabled = enabled
|
||||
case "discord":
|
||||
ch.Discord.Enabled = enabled
|
||||
case "slack":
|
||||
ch.Slack.Enabled = enabled
|
||||
case "feishu":
|
||||
ch.Feishu.Enabled = enabled
|
||||
case "dingtalk":
|
||||
ch.DingTalk.Enabled = enabled
|
||||
case "line":
|
||||
ch.LINE.Enabled = enabled
|
||||
case "qq":
|
||||
ch.QQ.Enabled = enabled
|
||||
case "onebot":
|
||||
ch.OneBot.Enabled = enabled
|
||||
case "wecom":
|
||||
ch.WeCom.Enabled = enabled
|
||||
case "wecom_app":
|
||||
ch.WeComApp.Enabled = enabled
|
||||
case "wecom_aibot":
|
||||
ch.WeComAIBot.Enabled = enabled
|
||||
case "whatsapp":
|
||||
ch.WhatsApp.Enabled = enabled
|
||||
case "pico":
|
||||
ch.Pico.Enabled = enabled
|
||||
case "maixcam":
|
||||
ch.MaixCam.Enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// applyChannelUpdate applies incoming config fields to the corresponding channel.
|
||||
// Empty secret fields are preserved from the existing config.
|
||||
func applyChannelUpdate(name string, ch *config.ChannelsConfig, incoming map[string]any) {
|
||||
getString := func(key string) string {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
getBool := func(key string) bool {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
getInt := func(key string) int {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
getInt64 := func(key string) int64 {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int64(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
getStringSlice := func(key string) config.FlexibleStringSlice {
|
||||
if v, ok := incoming[key]; ok {
|
||||
if arr, ok := v.([]any); ok {
|
||||
result := make(config.FlexibleStringSlice, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
getStringArray := func(key string) ([]string, bool) {
|
||||
v, ok := incoming[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
switch arr := v.(type) {
|
||||
case []any:
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, true
|
||||
case []string:
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, s := range arr {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result, true
|
||||
case string:
|
||||
if strings.TrimSpace(arr) == "" {
|
||||
return []string{}, true
|
||||
}
|
||||
parts := strings.Split(arr, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
getGroupTrigger := func() config.GroupTriggerConfig {
|
||||
if v, ok := incoming["group_trigger"]; ok {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
gt := config.GroupTriggerConfig{}
|
||||
if b, ok := m["mention_only"].(bool); ok {
|
||||
gt.MentionOnly = b
|
||||
}
|
||||
if arr, ok := m["prefixes"].([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
gt.Prefixes = append(gt.Prefixes, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return gt
|
||||
}
|
||||
}
|
||||
return config.GroupTriggerConfig{}
|
||||
}
|
||||
getTyping := func() config.TypingConfig {
|
||||
if v, ok := incoming["typing"]; ok {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
if b, ok := m["enabled"].(bool); ok {
|
||||
return config.TypingConfig{Enabled: b}
|
||||
}
|
||||
}
|
||||
}
|
||||
return config.TypingConfig{}
|
||||
}
|
||||
getPlaceholder := func() config.PlaceholderConfig {
|
||||
if v, ok := incoming["placeholder"]; ok {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
pc := config.PlaceholderConfig{}
|
||||
if b, ok := m["enabled"].(bool); ok {
|
||||
pc.Enabled = b
|
||||
}
|
||||
if s, ok := m["text"].(string); ok {
|
||||
pc.Text = s
|
||||
}
|
||||
return pc
|
||||
}
|
||||
}
|
||||
return config.PlaceholderConfig{}
|
||||
}
|
||||
|
||||
// preserveSecret returns the incoming value if non-empty, or keeps existing.
|
||||
preserveSecret := func(incoming, existing string) string {
|
||||
if incoming == "" {
|
||||
return existing
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "telegram":
|
||||
c := &ch.Telegram
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.BaseURL = getString("base_url")
|
||||
c.Proxy = getString("proxy")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "discord":
|
||||
c := &ch.Discord
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.Proxy = getString("proxy")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "slack":
|
||||
c := &ch.Slack
|
||||
c.Enabled = getBool("enabled")
|
||||
c.BotToken = preserveSecret(getString("bot_token"), c.BotToken)
|
||||
c.AppToken = preserveSecret(getString("app_token"), c.AppToken)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "feishu":
|
||||
c := &ch.Feishu
|
||||
c.Enabled = getBool("enabled")
|
||||
c.AppID = getString("app_id")
|
||||
c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret)
|
||||
c.EncryptKey = preserveSecret(getString("encrypt_key"), c.EncryptKey)
|
||||
c.VerificationToken = preserveSecret(getString("verification_token"), c.VerificationToken)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "dingtalk":
|
||||
c := &ch.DingTalk
|
||||
c.Enabled = getBool("enabled")
|
||||
c.ClientID = getString("client_id")
|
||||
c.ClientSecret = preserveSecret(getString("client_secret"), c.ClientSecret)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "line":
|
||||
c := &ch.LINE
|
||||
c.Enabled = getBool("enabled")
|
||||
c.ChannelSecret = preserveSecret(getString("channel_secret"), c.ChannelSecret)
|
||||
c.ChannelAccessToken = preserveSecret(getString("channel_access_token"), c.ChannelAccessToken)
|
||||
c.WebhookHost = getString("webhook_host")
|
||||
c.WebhookPort = getInt("webhook_port")
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "qq":
|
||||
c := &ch.QQ
|
||||
c.Enabled = getBool("enabled")
|
||||
c.AppID = getString("app_id")
|
||||
c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret)
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "onebot":
|
||||
c := &ch.OneBot
|
||||
c.Enabled = getBool("enabled")
|
||||
c.WSUrl = getString("ws_url")
|
||||
c.AccessToken = preserveSecret(getString("access_token"), c.AccessToken)
|
||||
c.ReconnectInterval = getInt("reconnect_interval")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
c.Typing = getTyping()
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "wecom":
|
||||
c := &ch.WeCom
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
|
||||
c.WebhookURL = getString("webhook_url")
|
||||
c.WebhookHost = getString("webhook_host")
|
||||
c.WebhookPort = getInt("webhook_port")
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.ReplyTimeout = getInt("reply_timeout")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "wecom_app":
|
||||
c := &ch.WeComApp
|
||||
c.Enabled = getBool("enabled")
|
||||
c.CorpID = getString("corp_id")
|
||||
c.CorpSecret = preserveSecret(getString("corp_secret"), c.CorpSecret)
|
||||
c.AgentID = getInt64("agent_id")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
|
||||
c.WebhookHost = getString("webhook_host")
|
||||
c.WebhookPort = getInt("webhook_port")
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.ReplyTimeout = getInt("reply_timeout")
|
||||
c.GroupTrigger = getGroupTrigger()
|
||||
case "wecom_aibot":
|
||||
c := &ch.WeComAIBot
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
|
||||
c.WebhookPath = getString("webhook_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.ReplyTimeout = getInt("reply_timeout")
|
||||
c.MaxSteps = getInt("max_steps")
|
||||
c.WelcomeMessage = getString("welcome_message")
|
||||
case "whatsapp":
|
||||
c := &ch.WhatsApp
|
||||
c.Enabled = getBool("enabled")
|
||||
c.BridgeURL = getString("bridge_url")
|
||||
c.UseNative = getBool("use_native")
|
||||
c.SessionStorePath = getString("session_store_path")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
case "pico":
|
||||
c := &ch.Pico
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.AllowTokenQuery = getBool("allow_token_query")
|
||||
if origins, ok := getStringArray("allow_origins"); ok {
|
||||
c.AllowOrigins = origins
|
||||
}
|
||||
c.PingInterval = getInt("ping_interval")
|
||||
c.ReadTimeout = getInt("read_timeout")
|
||||
c.WriteTimeout = getInt("write_timeout")
|
||||
c.MaxConnections = getInt("max_connections")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
c.Placeholder = getPlaceholder()
|
||||
case "maixcam":
|
||||
c := &ch.MaixCam
|
||||
c.Enabled = getBool("enabled")
|
||||
c.Host = getString("host")
|
||||
c.Port = getInt("port")
|
||||
c.AllowFrom = getStringSlice("allow_from")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
|
|||
return false, "no default model configured", nil
|
||||
}
|
||||
|
||||
modelCfg, err := cfg.GetModelConfig(modelName)
|
||||
if err != nil {
|
||||
modelCfg := lookupModelConfig(cfg, modelName)
|
||||
if modelCfg == nil {
|
||||
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +98,14 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
|
|||
return true, "", nil
|
||||
}
|
||||
|
||||
func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig {
|
||||
modelCfg, err := cfg.GetModelConfig(modelName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return modelCfg
|
||||
}
|
||||
|
||||
func isGatewayProcessAliveLocked() bool {
|
||||
return gateway.cmd != nil &&
|
||||
gateway.cmd.Process != nil &&
|
||||
|
|
|
|||
|
|
@ -42,6 +42,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
// Model list management
|
||||
h.registerModelRoutes(mux)
|
||||
|
||||
// Channel management
|
||||
// Channel catalog (for frontend navigation/config pages)
|
||||
h.registerChannelRoutes(mux)
|
||||
}
|
||||
|
|
|
|||
1
web/frontend/public/lark.svg
Normal file
1
web/frontend/public/lark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?><svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17 29C21 29 25 26.9339 28 23.4065C36 14 41.4242 16.8166 44 17.9998C38.5 20.9998 40.5 29.6233 33 35.9998C28.382 39.9259 23.4945 41.014 19 41C12.5231 40.9799 6.86226 37.7637 4 35.4063V16.9998" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.64808 15.8669C5.02231 14.9567 3.77715 14.7261 2.86694 15.3519C1.95673 15.9777 1.72615 17.2228 2.35192 18.1331L5.64808 15.8669ZM36.0021 35.7309C36.958 35.1774 37.2843 33.9539 36.7309 32.9979C36.1774 32.042 34.9539 31.7157 33.9979 32.2691L36.0021 35.7309ZM2.35192 18.1331C5.2435 22.339 10.7992 28.144 16.8865 32.2239C19.9345 34.2667 23.217 35.946 26.449 36.7324C29.6946 37.522 33.0451 37.4428 36.0021 35.7309L33.9979 32.2691C32.2049 33.3072 29.9929 33.478 27.3947 32.8458C24.783 32.2103 21.9405 30.7958 19.1135 28.9011C13.4508 25.106 8.2565 19.661 5.64808 15.8669L2.35192 18.1331Z" fill="#000"/><path d="M33.5947 17C32.84 14.7027 30.8551 9.94054 27.5947 7H11.5947C15.2174 10.6757 23.0002 16 27.0002 24" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,21 +1,22 @@
|
|||
// API client for channel management.
|
||||
// API client for channels navigation and channel-specific config flows.
|
||||
|
||||
export type ChannelConfig = Record<string, unknown>
|
||||
export type AppConfig = Record<string, unknown>
|
||||
|
||||
export interface ChannelInfo {
|
||||
export interface SupportedChannel {
|
||||
name: string
|
||||
display_name: string
|
||||
enabled: boolean
|
||||
configured: boolean
|
||||
config: ChannelConfig
|
||||
display_name?: string
|
||||
config_key: string
|
||||
variant?: string
|
||||
}
|
||||
|
||||
interface ChannelsListResponse {
|
||||
channels: ChannelInfo[]
|
||||
interface ChannelsCatalogResponse {
|
||||
channels: SupportedChannel[]
|
||||
}
|
||||
|
||||
interface ChannelActionResponse {
|
||||
interface ConfigActionResponse {
|
||||
status: string
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
const BASE_URL = ""
|
||||
|
|
@ -23,35 +24,42 @@ const BASE_URL = ""
|
|||
async function request<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}`)
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
const body = (await res.json()) as {
|
||||
error?: string
|
||||
errors?: string[]
|
||||
status?: string
|
||||
}
|
||||
if (Array.isArray(body.errors) && body.errors.length > 0) {
|
||||
message = body.errors.join("; ")
|
||||
} else if (typeof body.error === "string" && body.error.trim() !== "") {
|
||||
message = body.error
|
||||
}
|
||||
} catch {
|
||||
// Keep default fallback message if response body is not JSON.
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getChannels(): Promise<ChannelsListResponse> {
|
||||
return request<ChannelsListResponse>("/api/channels")
|
||||
export async function getChannelsCatalog(): Promise<ChannelsCatalogResponse> {
|
||||
return request<ChannelsCatalogResponse>("/api/channels/catalog")
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
name: string,
|
||||
config: ChannelConfig,
|
||||
): Promise<ChannelActionResponse> {
|
||||
return request<ChannelActionResponse>(`/api/channels/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config")
|
||||
}
|
||||
|
||||
export async function toggleChannel(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
): Promise<ChannelActionResponse> {
|
||||
return request<ChannelActionResponse>(`/api/channels/${name}/toggle`, {
|
||||
export async function patchAppConfig(
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<ConfigActionResponse> {
|
||||
return request<ConfigActionResponse>("/api/config", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
export type { ChannelsListResponse, ChannelActionResponse }
|
||||
export type { ChannelsCatalogResponse, ConfigActionResponse }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { IconChevronRight } from "@tabler/icons-react"
|
||||
import {
|
||||
IconAtom,
|
||||
IconChevronsDown,
|
||||
IconChevronsUp,
|
||||
IconKey,
|
||||
IconListDetails,
|
||||
IconMessageCircle,
|
||||
IconPlug,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
|
|
@ -27,34 +28,34 @@ import {
|
|||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
|
||||
|
||||
// Navigation data with real routes
|
||||
const navGroups = [
|
||||
interface NavItem {
|
||||
title: string
|
||||
url: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
translateTitle?: boolean
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
defaultOpen: boolean
|
||||
items: NavItem[]
|
||||
isChannelsGroup?: boolean
|
||||
}
|
||||
|
||||
const baseNavGroups: Omit<NavGroup, "items">[] = [
|
||||
{
|
||||
label: "navigation.chat",
|
||||
defaultOpen: true,
|
||||
items: [{ title: "navigation.chat", url: "/", icon: IconMessageCircle }],
|
||||
},
|
||||
{
|
||||
label: "navigation.model_group",
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ title: "navigation.models", url: "/models", icon: IconAtom },
|
||||
{ title: "navigation.credentials", url: "/credentials", icon: IconKey },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "navigation.channels_group",
|
||||
defaultOpen: true,
|
||||
items: [{ title: "navigation.channels", url: "/channels", icon: IconPlug }],
|
||||
},
|
||||
{
|
||||
label: "navigation.services",
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ title: "navigation.config", url: "/config", icon: IconSettings },
|
||||
{ title: "navigation.logs", url: "/logs", icon: IconListDetails },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -62,6 +63,73 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
const routerState = useRouterState()
|
||||
const { t } = useTranslation()
|
||||
const currentPath = routerState.location.pathname
|
||||
const {
|
||||
channelItems,
|
||||
hasMoreChannels,
|
||||
showAllChannels,
|
||||
toggleShowAllChannels,
|
||||
} = useSidebarChannels({ t })
|
||||
|
||||
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
...baseNavGroups[0],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.chat",
|
||||
url: "/",
|
||||
icon: IconMessageCircle,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseNavGroups[1],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.models",
|
||||
url: "/models",
|
||||
icon: IconAtom,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.credentials",
|
||||
url: "/credentials",
|
||||
icon: IconKey,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "navigation.channels_group",
|
||||
defaultOpen: true,
|
||||
items: channelItems.map((item) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
icon: item.icon,
|
||||
translateTitle: false,
|
||||
})),
|
||||
isChannelsGroup: true,
|
||||
},
|
||||
{
|
||||
...baseNavGroups[2],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.config",
|
||||
url: "/config",
|
||||
icon: IconSettings,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.logs",
|
||||
url: "/logs",
|
||||
icon: IconListDetails,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}, [channelItems])
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
|
|
@ -103,13 +171,34 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
isActive ? "opacity-100" : "opacity-80"
|
||||
}
|
||||
>
|
||||
{t(item.title)}
|
||||
{item.translateTitle === false
|
||||
? item.title
|
||||
: t(item.title)}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
{group.isChannelsGroup && hasMoreChannels && (
|
||||
<SidebarMenuItem key="channels-more-toggle">
|
||||
<SidebarMenuButton
|
||||
onClick={toggleShowAllChannels}
|
||||
className="text-muted-foreground hover:bg-muted/60 h-9 px-3"
|
||||
>
|
||||
{showAllChannels ? (
|
||||
<IconChevronsUp className="size-4 opacity-60" />
|
||||
) : (
|
||||
<IconChevronsDown className="size-4 opacity-60" />
|
||||
)}
|
||||
<span className="opacity-80">
|
||||
{showAllChannels
|
||||
? t("navigation.show_less_channels")
|
||||
: t("navigation.show_more_channels")}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import { IconSettings } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelInfo } from "@/api/channels"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface ChannelCardProps {
|
||||
channel: ChannelInfo
|
||||
onToggle: (name: string, enabled: boolean) => void
|
||||
onEdit: (channel: ChannelInfo) => void
|
||||
toggling: boolean
|
||||
}
|
||||
|
||||
export function ChannelCard({
|
||||
channel,
|
||||
onToggle,
|
||||
onEdit,
|
||||
toggling,
|
||||
}: ChannelCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="border-border/60 bg-card flex items-center justify-between rounded-xl border p-4 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{channel.display_name}
|
||||
</span>
|
||||
{channel.configured ? (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("channels.status.configured")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground bg-muted rounded-full px-2 py-0.5 text-[10px] font-medium">
|
||||
{t("channels.status.unconfigured")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">{channel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={channel.enabled}
|
||||
onCheckedChange={(checked) => onToggle(channel.name, checked)}
|
||||
disabled={toggling}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-8 p-0"
|
||||
onClick={() => onEdit(channel)}
|
||||
>
|
||||
<IconSettings className="size-4" />
|
||||
<span className="sr-only">{t("channels.action.configure")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
539
web/frontend/src/components/channels/channel-config-page.tsx
Normal file
539
web/frontend/src/components/channels/channel-config-page.tsx
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
type ChannelConfig,
|
||||
type SupportedChannel,
|
||||
getAppConfig,
|
||||
getChannelsCatalog,
|
||||
patchAppConfig,
|
||||
} from "@/api/channels"
|
||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
interface ChannelConfigPageProps {
|
||||
channelName: string
|
||||
}
|
||||
|
||||
const SECRET_FIELD_MAP: Record<string, string> = {
|
||||
token: "_token",
|
||||
app_secret: "_app_secret",
|
||||
client_secret: "_client_secret",
|
||||
corp_secret: "_corp_secret",
|
||||
channel_secret: "_channel_secret",
|
||||
channel_access_token: "_channel_access_token",
|
||||
access_token: "_access_token",
|
||||
bot_token: "_bot_token",
|
||||
app_token: "_app_token",
|
||||
encoding_aes_key: "_encoding_aes_key",
|
||||
encrypt_key: "_encrypt_key",
|
||||
verification_token: "_verification_token",
|
||||
password: "_password",
|
||||
nickserv_password: "_nickserv_password",
|
||||
sasl_password: "_sasl_password",
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||
const edit: ChannelConfig = { ...config }
|
||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
||||
if (secretKey in config) {
|
||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
||||
}
|
||||
}
|
||||
return edit
|
||||
}
|
||||
|
||||
function normalizeConfig(
|
||||
channel: SupportedChannel,
|
||||
rawConfig: ChannelConfig,
|
||||
): ChannelConfig {
|
||||
const config = { ...rawConfig }
|
||||
if (channel.name === "whatsapp_native") {
|
||||
config.use_native = true
|
||||
}
|
||||
if (channel.name === "whatsapp") {
|
||||
config.use_native = false
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function buildSavePayload(
|
||||
channel: SupportedChannel,
|
||||
editConfig: ChannelConfig,
|
||||
enabled: boolean,
|
||||
): ChannelConfig {
|
||||
const payload: ChannelConfig = { enabled }
|
||||
|
||||
for (const [key, value] of Object.entries(editConfig)) {
|
||||
if (key.startsWith("_")) continue
|
||||
if (key === "enabled") continue
|
||||
|
||||
if (key in SECRET_FIELD_MAP) {
|
||||
const editKey = SECRET_FIELD_MAP[key]
|
||||
const incoming = asString(editConfig[editKey])
|
||||
payload[key] = incoming !== "" ? incoming : value
|
||||
continue
|
||||
}
|
||||
|
||||
payload[key] = value
|
||||
}
|
||||
|
||||
if (channel.name === "whatsapp_native") {
|
||||
payload.use_native = true
|
||||
}
|
||||
if (channel.name === "whatsapp") {
|
||||
payload.use_native = false
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isConfigured(
|
||||
channel: SupportedChannel,
|
||||
config: ChannelConfig,
|
||||
): boolean {
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return asString(config.token) !== ""
|
||||
case "discord":
|
||||
return asString(config.token) !== ""
|
||||
case "slack":
|
||||
return asString(config.bot_token) !== ""
|
||||
case "feishu":
|
||||
return (
|
||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
||||
)
|
||||
case "dingtalk":
|
||||
return (
|
||||
asString(config.client_id) !== "" &&
|
||||
asString(config.client_secret) !== ""
|
||||
)
|
||||
case "line":
|
||||
return asString(config.channel_access_token) !== ""
|
||||
case "qq":
|
||||
return (
|
||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
||||
)
|
||||
case "onebot":
|
||||
return asString(config.ws_url) !== ""
|
||||
case "wecom":
|
||||
return asString(config.token) !== ""
|
||||
case "wecom_app":
|
||||
return (
|
||||
asString(config.corp_id) !== "" && asString(config.corp_secret) !== ""
|
||||
)
|
||||
case "wecom_aibot":
|
||||
return asString(config.token) !== ""
|
||||
case "whatsapp":
|
||||
return asString(config.bridge_url) !== ""
|
||||
case "whatsapp_native":
|
||||
return asBool(config.use_native)
|
||||
case "pico":
|
||||
return asString(config.token) !== ""
|
||||
case "maixcam":
|
||||
return asString(config.host) !== ""
|
||||
case "matrix":
|
||||
return (
|
||||
asString(config.homeserver) !== "" &&
|
||||
asString(config.user_id) !== "" &&
|
||||
asString(config.access_token) !== ""
|
||||
)
|
||||
case "irc":
|
||||
return asString(config.server) !== ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredFieldKeys(channelName: string): string[] {
|
||||
switch (channelName) {
|
||||
case "telegram":
|
||||
return ["token"]
|
||||
case "discord":
|
||||
return ["token"]
|
||||
case "slack":
|
||||
return ["bot_token"]
|
||||
case "feishu":
|
||||
return ["app_id", "app_secret"]
|
||||
case "dingtalk":
|
||||
return ["client_id", "client_secret"]
|
||||
case "line":
|
||||
return ["channel_secret", "channel_access_token"]
|
||||
case "qq":
|
||||
return ["app_id", "app_secret"]
|
||||
case "onebot":
|
||||
return ["ws_url"]
|
||||
case "wecom":
|
||||
return ["token"]
|
||||
case "wecom_app":
|
||||
return ["corp_id", "corp_secret"]
|
||||
case "wecom_aibot":
|
||||
return ["token"]
|
||||
case "whatsapp":
|
||||
return ["bridge_url"]
|
||||
case "pico":
|
||||
return ["token"]
|
||||
case "maixcam":
|
||||
return ["host"]
|
||||
case "matrix":
|
||||
return ["homeserver", "user_id", "access_token"]
|
||||
case "irc":
|
||||
return ["server"]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingRequiredValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim() === ""
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getChannelDocSlug(channelName: string): string {
|
||||
return channelName.replaceAll("_", "-")
|
||||
}
|
||||
|
||||
const CHANNELS_WITHOUT_DOCS = new Set([
|
||||
"pico",
|
||||
"wecom",
|
||||
"matrix",
|
||||
"irc",
|
||||
"whatsapp",
|
||||
"whatsapp_native",
|
||||
])
|
||||
|
||||
export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const gateway = useAtomValue(gatewayAtom)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [fetchError, setFetchError] = useState("")
|
||||
const [serverError, setServerError] = useState("")
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [channel, setChannel] = useState<SupportedChannel | null>(null)
|
||||
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [catalog, appConfig] = await Promise.all([
|
||||
getChannelsCatalog(),
|
||||
getAppConfig(),
|
||||
])
|
||||
const matched =
|
||||
catalog.channels.find((item) => item.name === channelName) ?? null
|
||||
|
||||
if (!matched) {
|
||||
setChannel(null)
|
||||
setFetchError(
|
||||
t("channels.page.notFound", {
|
||||
name: channelName,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
||||
const raw = asRecord(channelsConfig[matched.config_key])
|
||||
const normalized = normalizeConfig(matched, raw)
|
||||
|
||||
setChannel(matched)
|
||||
setBaseConfig(normalized)
|
||||
setEditConfig(buildEditConfig(normalized))
|
||||
setEnabled(asBool(normalized.enabled))
|
||||
setFetchError("")
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
} catch (e) {
|
||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [channelName, t])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const previousGatewayStatusRef = useRef(gateway.status)
|
||||
useEffect(() => {
|
||||
const previousStatus = previousGatewayStatusRef.current
|
||||
if (previousStatus !== "running" && gateway.status === "running") {
|
||||
void loadData()
|
||||
}
|
||||
previousGatewayStatusRef.current = gateway.status
|
||||
}, [gateway.status, loadData])
|
||||
|
||||
const savePayload = useMemo(() => {
|
||||
if (!channel) return null
|
||||
return buildSavePayload(channel, editConfig, enabled)
|
||||
}, [channel, editConfig, enabled])
|
||||
|
||||
const configured = useMemo(() => {
|
||||
if (!channel || !savePayload) return false
|
||||
return isConfigured(channel, savePayload)
|
||||
}, [channel, savePayload])
|
||||
|
||||
const docsUrl = useMemo(() => {
|
||||
if (!channel) return ""
|
||||
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return ""
|
||||
const language = (
|
||||
i18n.resolvedLanguage ??
|
||||
i18n.language ??
|
||||
""
|
||||
).toLowerCase()
|
||||
const base = language.startsWith("zh")
|
||||
? "https://docs.picoclaw.io/zh-Hans/docs/channels"
|
||||
: "https://docs.picoclaw.io/docs/channels"
|
||||
return `${base}/${getChannelDocSlug(channel.name)}`
|
||||
}, [channel, i18n.language, i18n.resolvedLanguage])
|
||||
|
||||
const channelDisplayName = useMemo(() => {
|
||||
if (!channel) return channelName
|
||||
return getChannelDisplayName(channel, t)
|
||||
}, [channel, channelName, t])
|
||||
|
||||
const hiddenKeys = useMemo(() => {
|
||||
if (!channel) return []
|
||||
if (channel.name === "whatsapp") {
|
||||
return ["use_native"]
|
||||
}
|
||||
if (channel.name === "whatsapp_native") {
|
||||
return ["use_native", "bridge_url"]
|
||||
}
|
||||
return []
|
||||
}, [channel])
|
||||
const requiredKeys = useMemo(
|
||||
() => getRequiredFieldKeys(channelName),
|
||||
[channelName],
|
||||
)
|
||||
|
||||
const handleChange = useCallback((key: string, value: unknown) => {
|
||||
const normalizedKey = key.startsWith("_") ? key.slice(1) : key
|
||||
setEditConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setFieldErrors((prev) => {
|
||||
if (!(key in prev) && !(normalizedKey in prev)) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
delete next[normalizedKey]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleReset = () => {
|
||||
setEditConfig(buildEditConfig(baseConfig))
|
||||
setEnabled(asBool(baseConfig.enabled))
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!channel || !savePayload) return
|
||||
|
||||
const missingRequiredFields = requiredKeys.filter((key) =>
|
||||
isMissingRequiredValue(savePayload[key]),
|
||||
)
|
||||
if (missingRequiredFields.length > 0) {
|
||||
const requiredFieldError = t("channels.validation.requiredField")
|
||||
const nextFieldErrors: Record<string, string> = {}
|
||||
for (const key of missingRequiredFields) {
|
||||
nextFieldErrors[key] = requiredFieldError
|
||||
}
|
||||
setFieldErrors(nextFieldErrors)
|
||||
setServerError("")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
try {
|
||||
await patchAppConfig({
|
||||
channels: {
|
||||
[channel.config_key]: savePayload,
|
||||
},
|
||||
})
|
||||
toast.success(t("channels.page.saveSuccess"))
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||
setServerError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderForm = () => {
|
||||
if (!channel) return null
|
||||
const isEdit = configured
|
||||
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return (
|
||||
<TelegramForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "discord":
|
||||
return (
|
||||
<DiscordForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "slack":
|
||||
return (
|
||||
<SlackForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "feishu":
|
||||
return (
|
||||
<FeishuForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<GenericForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
hiddenKeys={hiddenKeys}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={channelDisplayName}
|
||||
titleExtra={
|
||||
channel ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{enabled ? (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("channels.page.enabled")}
|
||||
</span>
|
||||
) : configured ? (
|
||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("channels.status.configured")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 justify-center overflow-y-auto px-4 pb-8 sm:px-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<IconLoader2 className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : fetchError ? (
|
||||
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm">
|
||||
{fetchError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-250 space-y-5 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<p className="font-medium">
|
||||
{t("channels.edit.title", {
|
||||
name: channelDisplayName,
|
||||
})}
|
||||
</p>
|
||||
{channel && docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("channels.page.docLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
{renderForm()}
|
||||
|
||||
{serverError && (
|
||||
<p className="text-destructive text-sm">{serverError}</p>
|
||||
)}
|
||||
|
||||
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
|
||||
<Button variant="outline" onClick={handleReset} disabled={saving}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
web/frontend/src/components/channels/channel-display-name.ts
Normal file
23
web/frontend/src/components/channels/channel-display-name.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { TFunction } from "i18next"
|
||||
|
||||
import type { SupportedChannel } from "@/api/channels"
|
||||
|
||||
export function getChannelDisplayName(
|
||||
channel: Pick<SupportedChannel, "name" | "display_name">,
|
||||
t: TFunction,
|
||||
): string {
|
||||
const key = `channels.name.${channel.name}`
|
||||
const translated = t(key)
|
||||
if (translated !== key) {
|
||||
return translated
|
||||
}
|
||||
|
||||
if (channel.display_name && channel.display_name.trim() !== "") {
|
||||
return channel.display_name
|
||||
}
|
||||
|
||||
return channel.name
|
||||
.split("_")
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
SwitchCardField,
|
||||
} from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ interface DiscordFormProps {
|
|||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
|
|
@ -23,60 +25,89 @@ function asStringArray(value: unknown): string[] {
|
|||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function DiscordForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: DiscordFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
const tokenExtraHint =
|
||||
isEdit && asString(config.token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
hint={
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.tokenPlaceholder")
|
||||
}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={asString(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={asStringArray(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>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(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.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(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>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.mentionOnly")}
|
||||
hint={t("channels.form.desc.mentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}}
|
||||
ariaLabel={t("channels.field.mentionOnly")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
|
||||
import { Field, KeyInput } from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface FeishuFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
|
|
@ -23,12 +21,34 @@ function asStringArray(value: unknown): string[] {
|
|||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
||||
export function FeishuForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: FeishuFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const appSecretExtraHint =
|
||||
isEdit && asString(config.app_secret)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const verificationExtraHint =
|
||||
isEdit && asString(config.verification_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const encryptExtraHint =
|
||||
isEdit && asString(config.encrypt_key)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field label={t("channels.field.appId")}>
|
||||
<Field
|
||||
label={t("channels.field.appId")}
|
||||
required
|
||||
hint={t("channels.form.desc.appId")}
|
||||
error={fieldErrors.app_id}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.app_id)}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
|
|
@ -38,65 +58,64 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
|||
|
||||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
hint={
|
||||
isEdit && asString(config.app_secret)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
|
||||
error={fieldErrors.app_secret}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_secret)}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={
|
||||
isEdit && asString(config.app_secret)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.app_secret,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field label={t("channels.field.verificationToken")}>
|
||||
<KeyInput
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={
|
||||
isEdit && asString(config.verification_token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("channels.field.encryptKey")}>
|
||||
<KeyInput
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={
|
||||
isEdit && asString(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={asStringArray(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>
|
||||
<Field
|
||||
label={t("channels.field.verificationToken")}
|
||||
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.verification_token,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.encryptKey")}
|
||||
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.encrypt_key,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { Field, KeyInput } from "@/components/models/shared-form"
|
||||
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
|
||||
import {
|
||||
Field,
|
||||
KeyInput,
|
||||
SwitchCardField,
|
||||
} from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface GenericFormProps {
|
||||
channelName: string
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
hiddenKeys?: string[]
|
||||
requiredKeys?: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
// Secret field names that should use masked input.
|
||||
|
|
@ -26,6 +32,9 @@ const SECRET_FIELDS = new Set([
|
|||
"encoding_aes_key",
|
||||
"encrypt_key",
|
||||
"verification_token",
|
||||
"password",
|
||||
"nickserv_password",
|
||||
"sasl_password",
|
||||
])
|
||||
|
||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||
|
|
@ -36,6 +45,7 @@ const OBJECT_FIELDS = new Set([
|
|||
"group_trigger",
|
||||
"typing",
|
||||
"placeholder",
|
||||
"allow_token_query",
|
||||
"allow_from",
|
||||
"allow_origins",
|
||||
])
|
||||
|
|
@ -47,6 +57,11 @@ function formatLabel(key: string): string {
|
|||
.join(" ")
|
||||
}
|
||||
|
||||
function formatSentenceFieldName(key: string): string {
|
||||
const label = formatLabel(key)
|
||||
return label.charAt(0).toLowerCase() + label.slice(1)
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
|
@ -56,36 +71,106 @@ function asStringArray(value: unknown): string[] {
|
|||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
export function GenericForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
hiddenKeys = [],
|
||||
requiredKeys = [],
|
||||
fieldErrors = {},
|
||||
}: GenericFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const hiddenFieldSet = new Set(hiddenKeys)
|
||||
const requiredFieldSet = new Set(requiredKeys)
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
const typingConfig = asRecord(config.typing)
|
||||
const placeholderConfig = asRecord(config.placeholder)
|
||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||
|
||||
const fields = Object.keys(config).filter(
|
||||
(k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && !OBJECT_FIELDS.has(k),
|
||||
(k) =>
|
||||
!k.startsWith("_") &&
|
||||
!SKIP_FIELDS.has(k) &&
|
||||
!OBJECT_FIELDS.has(k) &&
|
||||
!hiddenFieldSet.has(k),
|
||||
)
|
||||
|
||||
const buildHint = (key: string): string => {
|
||||
const descriptions: Record<string, string> = {
|
||||
ws_url: t("channels.form.desc.wsUrl"),
|
||||
reconnect_interval: t("channels.form.desc.reconnectInterval"),
|
||||
bridge_url: t("channels.form.desc.bridgeUrl"),
|
||||
session_store_path: t("channels.form.desc.sessionStorePath"),
|
||||
use_native: t("channels.form.desc.useNative"),
|
||||
host: t("channels.form.desc.host"),
|
||||
port: t("channels.form.desc.port"),
|
||||
homeserver: t("channels.form.desc.homeserver"),
|
||||
user_id: t("channels.form.desc.userId"),
|
||||
device_id: t("channels.form.desc.deviceId"),
|
||||
join_on_invite: t("channels.form.desc.joinOnInvite"),
|
||||
app_id: t("channels.form.desc.appId"),
|
||||
client_id: t("channels.form.desc.clientId"),
|
||||
corp_id: t("channels.form.desc.corpId"),
|
||||
agent_id: t("channels.form.desc.agentId"),
|
||||
webhook_url: t("channels.form.desc.webhookUrl"),
|
||||
webhook_host: t("channels.form.desc.webhookHost"),
|
||||
webhook_port: t("channels.form.desc.webhookPort"),
|
||||
webhook_path: t("channels.form.desc.webhookPath"),
|
||||
reply_timeout: t("channels.form.desc.replyTimeout"),
|
||||
max_steps: t("channels.form.desc.maxSteps"),
|
||||
welcome_message: t("channels.form.desc.welcomeMessage"),
|
||||
allow_token_query: t("channels.form.desc.allowTokenQuery"),
|
||||
ping_interval: t("channels.form.desc.pingInterval"),
|
||||
read_timeout: t("channels.form.desc.readTimeout"),
|
||||
write_timeout: t("channels.form.desc.writeTimeout"),
|
||||
max_connections: t("channels.form.desc.maxConnections"),
|
||||
server: t("channels.form.desc.server"),
|
||||
tls: t("channels.form.desc.tls"),
|
||||
nick: t("channels.form.desc.nick"),
|
||||
user: t("channels.form.desc.user"),
|
||||
real_name: t("channels.form.desc.realName"),
|
||||
channels: t("channels.form.desc.channels"),
|
||||
request_caps: t("channels.form.desc.requestCaps"),
|
||||
}
|
||||
return (
|
||||
descriptions[key] ??
|
||||
t("channels.form.desc.genericField", {
|
||||
field: formatSentenceFieldName(key),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fields.map((key) => {
|
||||
const isRequired = requiredFieldSet.has(key)
|
||||
if (SECRET_FIELDS.has(key)) {
|
||||
const editKey = `_${key}`
|
||||
const extraHint =
|
||||
isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : ""
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
hint={
|
||||
isEdit && config[key]
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
required={isRequired}
|
||||
hint={`${buildHint(key)}${extraHint}`}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config[editKey])}
|
||||
onChange={(v) => onChange(editKey, v)}
|
||||
placeholder={
|
||||
isEdit && Boolean(config[key])
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: ""
|
||||
}
|
||||
placeholder={maskedSecretPlaceholder(config[key])}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
|
|
@ -94,20 +179,51 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
|
|||
const value = config[key]
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<Field key={key} label={formatLabel(key)}>
|
||||
<div className="border-input flex h-9 items-center justify-end rounded-md border px-2.5">
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(key, checked)}
|
||||
aria-label={formatLabel(key)}
|
||||
/>
|
||||
</div>
|
||||
<SwitchCardField
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(key, checked)}
|
||||
ariaLabel={formatLabel(key)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(value).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
key,
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={key} label={formatLabel(key)}>
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -125,10 +241,10 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
|
|||
})}
|
||||
|
||||
{/* Allow From field */}
|
||||
{config.allow_from !== undefined && (
|
||||
{config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && (
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.field.allowFromHint")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
|
|
@ -146,32 +262,120 @@ export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
|
|||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_origins !== undefined && (
|
||||
<Field
|
||||
label={t("channels.field.allowOrigins", "Allow Origins")}
|
||||
hint={t(
|
||||
"channels.field.allowOriginsHint",
|
||||
"Comma-separated list of allowed origins. Leave empty to allow all.",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_origins).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_origins",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
{config.allow_origins !== undefined &&
|
||||
!hiddenFieldSet.has("allow_origins") && (
|
||||
<Field
|
||||
label={t("channels.field.allowOrigins")}
|
||||
hint={t("channels.form.desc.allowOrigins")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_origins).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_origins",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_token_query !== undefined &&
|
||||
!hiddenFieldSet.has("allow_token_query") && (
|
||||
<SwitchCardField
|
||||
label={formatLabel("allow_token_query")}
|
||||
hint={buildHint("allow_token_query")}
|
||||
checked={asBool(config.allow_token_query)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("allow_token_query", checked)
|
||||
}
|
||||
placeholder={t(
|
||||
"channels.field.allowOriginsPlaceholder",
|
||||
"e.g. https://example.com, http://localhost:5173",
|
||||
)}
|
||||
ariaLabel={formatLabel("allow_token_query")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.group_trigger !== undefined &&
|
||||
!hiddenFieldSet.has("group_trigger") && (
|
||||
<>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.groupTriggerMentionOnly")}
|
||||
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
||||
/>
|
||||
<Field
|
||||
label={t("channels.field.groupTriggerPrefixes")}
|
||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
prefixes: e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.placeholder !== undefined &&
|
||||
!hiddenFieldSet.has("placeholder") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
export function maskedSecretPlaceholder(value: unknown, fallback = ""): string {
|
||||
const secret = typeof value === "string" ? value.trim() : ""
|
||||
if (!secret) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const prefix = secret.slice(0, Math.min(4, secret.length))
|
||||
const suffix = secret.slice(-Math.min(3, secret.length))
|
||||
return `${prefix}***${suffix}`
|
||||
}
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
|
||||
import { Field, KeyInput } from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface SlackFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
|
|
@ -23,69 +21,66 @@ function asStringArray(value: unknown): string[] {
|
|||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
|
||||
export function SlackForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: SlackFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const botTokenExtraHint =
|
||||
isEdit && asString(config.bot_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const appTokenExtraHint =
|
||||
isEdit && asString(config.app_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.botToken")}
|
||||
hint={
|
||||
isEdit && asString(config.bot_token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
|
||||
error={fieldErrors.bot_token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._bot_token)}
|
||||
onChange={(v) => onChange("_bot_token", v)}
|
||||
placeholder={
|
||||
isEdit && asString(config.bot_token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: "xoxb-xxxx"
|
||||
}
|
||||
placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appToken")}
|
||||
hint={
|
||||
isEdit && asString(config.app_token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_token)}
|
||||
onChange={(v) => onChange("_app_token", v)}
|
||||
placeholder={
|
||||
isEdit && asString(config.app_token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: "xapp-xxxx"
|
||||
}
|
||||
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(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>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
SwitchCardField,
|
||||
} from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ interface TelegramFormProps {
|
|||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
|
|
@ -23,67 +25,127 @@ function asStringArray(value: unknown): string[] {
|
|||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
export function TelegramForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: TelegramFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const typingConfig = asRecord(config.typing)
|
||||
const placeholderConfig = asRecord(config.placeholder)
|
||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||
const tokenExtraHint =
|
||||
isEdit && asString(config.token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
hint={
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.tokenPlaceholder")
|
||||
}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field label={t("channels.field.baseUrl")}>
|
||||
<Input
|
||||
value={asString(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={asString(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={asStringArray(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>
|
||||
<Field
|
||||
label={t("channels.field.baseUrl")}
|
||||
hint={t("channels.form.desc.baseUrl")}
|
||||
>
|
||||
<Input
|
||||
value={asString(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.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(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.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(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>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
import { IconSearch } from "@tabler/icons-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelInfo } from "@/api/channels"
|
||||
import { getChannels, toggleChannel } from "@/api/channels"
|
||||
import { ChannelCard } from "@/components/channels/channel-card"
|
||||
import { EditChannelSheet } from "@/components/channels/edit-channel-sheet"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export function ChannelsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchError, setFetchError] = useState("")
|
||||
const [editingChannel, setEditingChannel] = useState<ChannelInfo | null>(null)
|
||||
const [togglingChannel, setTogglingChannel] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const fetchChannels = useCallback(async () => {
|
||||
try {
|
||||
const data = await getChannels()
|
||||
// Sort: enabled first, then configured, then alphabetical
|
||||
const sorted = [...data.channels].sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
|
||||
if (a.configured !== b.configured) return a.configured ? -1 : 1
|
||||
return a.display_name.localeCompare(b.display_name)
|
||||
})
|
||||
setChannels(sorted)
|
||||
setFetchError("")
|
||||
} catch (e) {
|
||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannels()
|
||||
}, [fetchChannels])
|
||||
|
||||
const handleToggle = async (name: string, enabled: boolean) => {
|
||||
setTogglingChannel(name)
|
||||
try {
|
||||
await toggleChannel(name, enabled)
|
||||
await fetchChannels()
|
||||
} catch {
|
||||
// Refresh to show actual state
|
||||
await fetchChannels()
|
||||
} finally {
|
||||
setTogglingChannel(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? channels.filter(
|
||||
(ch) =>
|
||||
ch.display_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
ch.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: channels
|
||||
|
||||
const enabledCount = channels.filter((ch) => ch.enabled).length
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.channels", "Channels")}
|
||||
titleExtra={
|
||||
!loading && channels.length > 0 ? (
|
||||
<span className="text-muted-foreground text-sm font-normal">
|
||||
{enabledCount} {t("channels.header.enabled")}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
</div>
|
||||
) : fetchError ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-20">
|
||||
<p className="text-destructive text-sm">{fetchError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pb-8">
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{t("channels.description")}
|
||||
</p>
|
||||
|
||||
{channels.length > 6 && (
|
||||
<div className="relative mb-4">
|
||||
<IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("channels.search")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.name}
|
||||
channel={channel}
|
||||
onToggle={handleToggle}
|
||||
onEdit={setEditingChannel}
|
||||
toggling={togglingChannel === channel.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && search && (
|
||||
<p className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("channels.noResults")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EditChannelSheet
|
||||
channel={editingChannel}
|
||||
open={editingChannel !== null}
|
||||
onClose={() => setEditingChannel(null)}
|
||||
onSaved={fetchChannels}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig, ChannelInfo } from "@/api/channels"
|
||||
import { updateChannel } from "@/api/channels"
|
||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
interface EditChannelSheetProps {
|
||||
channel: ChannelInfo | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
// Map of secret config keys to their edit-buffer keys.
|
||||
// When editing, we use _token etc. to avoid overwriting with masked values.
|
||||
const SECRET_FIELD_MAP: Record<string, string> = {
|
||||
token: "_token",
|
||||
app_secret: "_app_secret",
|
||||
client_secret: "_client_secret",
|
||||
corp_secret: "_corp_secret",
|
||||
channel_secret: "_channel_secret",
|
||||
channel_access_token: "_channel_access_token",
|
||||
access_token: "_access_token",
|
||||
bot_token: "_bot_token",
|
||||
app_token: "_app_token",
|
||||
encoding_aes_key: "_encoding_aes_key",
|
||||
encrypt_key: "_encrypt_key",
|
||||
verification_token: "_verification_token",
|
||||
}
|
||||
|
||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||
const edit: ChannelConfig = { ...config }
|
||||
// Initialize edit buffer keys for secrets as empty (user fills new values)
|
||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
||||
if (secretKey in config) {
|
||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
||||
}
|
||||
}
|
||||
return edit
|
||||
}
|
||||
|
||||
function buildSavePayload(
|
||||
channel: ChannelInfo,
|
||||
editConfig: ChannelConfig,
|
||||
): ChannelConfig {
|
||||
const payload: ChannelConfig = { enabled: channel.enabled }
|
||||
|
||||
for (const [key, value] of Object.entries(editConfig)) {
|
||||
// Skip the edit-buffer underscore keys — we use them to populate real keys
|
||||
if (key.startsWith("_")) continue
|
||||
// For secret fields, use the edit buffer value (empty means preserve existing)
|
||||
if (key in SECRET_FIELD_MAP) {
|
||||
const editKey = SECRET_FIELD_MAP[key]
|
||||
payload[key] = editConfig[editKey] ?? ""
|
||||
} else {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export function EditChannelSheet({
|
||||
channel,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditChannelSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [serverError, setServerError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
setEditConfig(buildEditConfig(channel.config))
|
||||
setServerError("")
|
||||
}
|
||||
}, [channel])
|
||||
|
||||
const handleChange = useCallback((key: string, value: unknown) => {
|
||||
setEditConfig((prev) => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!channel) return
|
||||
setSaving(true)
|
||||
setServerError("")
|
||||
try {
|
||||
await updateChannel(channel.name, buildSavePayload(channel, editConfig))
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setServerError(
|
||||
e instanceof Error ? e.message : t("channels.edit.saveError"),
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderForm = () => {
|
||||
if (!channel) return null
|
||||
const isEdit = channel.configured
|
||||
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return (
|
||||
<TelegramForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)
|
||||
case "discord":
|
||||
return (
|
||||
<DiscordForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)
|
||||
case "slack":
|
||||
return (
|
||||
<SlackForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)
|
||||
case "feishu":
|
||||
return (
|
||||
<FeishuForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<GenericForm
|
||||
channelName={channel.name}
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent side="right" className="flex flex-col sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("channels.edit.title", {
|
||||
name: channel?.display_name ?? "",
|
||||
})}
|
||||
</SheetTitle>
|
||||
<SheetDescription>{t("channels.edit.description")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">{renderForm()}</div>
|
||||
|
||||
{serverError && (
|
||||
<p className="px-1 text-sm text-red-500">{serverError}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter className="gap-2 pt-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("channels.edit.saving") : t("common.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,18 +8,24 @@ import {
|
|||
Field as UiField,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Field({ label, hint, children }: FieldProps) {
|
||||
export function Field({ label, hint, error, required, children }: FieldProps) {
|
||||
return (
|
||||
<UiField className="gap-2.5">
|
||||
<div className="space-y-1">
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
{hint && (
|
||||
<FieldDescription className="text-xs leading-normal">
|
||||
{hint}
|
||||
|
|
@ -27,6 +33,11 @@ export function Field({ label, hint, children }: FieldProps) {
|
|||
)}
|
||||
</div>
|
||||
{children}
|
||||
{error && (
|
||||
<FieldDescription className="text-destructive text-xs leading-normal">
|
||||
{error}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</UiField>
|
||||
)
|
||||
}
|
||||
|
|
@ -65,6 +76,50 @@ export function KeyInput({ value, onChange, placeholder }: KeyInputProps) {
|
|||
)
|
||||
}
|
||||
|
||||
interface SwitchCardFieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
ariaLabel?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function SwitchCardField({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: SwitchCardFieldProps) {
|
||||
return (
|
||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{hint && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
aria-label={ariaLabel ?? label}
|
||||
/>
|
||||
</div>
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
{error && (
|
||||
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AdvancedSectionProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
|
|
|||
236
web/frontend/src/hooks/use-sidebar-channels.ts
Normal file
236
web/frontend/src/hooks/use-sidebar-channels.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import {
|
||||
IconBrandChrome,
|
||||
IconBrandDingtalk,
|
||||
IconBrandDiscord,
|
||||
IconBrandLine,
|
||||
IconBrandMatrix,
|
||||
IconBrandQq,
|
||||
IconBrandSlack,
|
||||
IconBrandTelegram,
|
||||
IconBrandWechat,
|
||||
IconBrandWhatsapp,
|
||||
IconCamera,
|
||||
IconMessages,
|
||||
IconPlug,
|
||||
IconRobot,
|
||||
} from "@tabler/icons-react"
|
||||
import type { TFunction } from "i18next"
|
||||
import { useAtomValue } from "jotai"
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
type AppConfig,
|
||||
type SupportedChannel,
|
||||
getAppConfig,
|
||||
getChannelsCatalog,
|
||||
} from "@/api/channels"
|
||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
const DEFAULT_VISIBLE_CHANNELS = 5
|
||||
const CHANNEL_IMPORTANCE_ORDER = [
|
||||
"discord",
|
||||
"feishu",
|
||||
"telegram",
|
||||
"slack",
|
||||
"line",
|
||||
"wecom",
|
||||
"wecom_app",
|
||||
"wecom_aibot",
|
||||
"dingtalk",
|
||||
"qq",
|
||||
"onebot",
|
||||
"matrix",
|
||||
"pico",
|
||||
"maixcam",
|
||||
"irc",
|
||||
"whatsapp",
|
||||
"whatsapp_native",
|
||||
]
|
||||
const CHANNEL_IMPORTANCE_INDEX = new Map(
|
||||
CHANNEL_IMPORTANCE_ORDER.map((name, index) => [name, index]),
|
||||
)
|
||||
|
||||
function IconLark({ className }: { className?: string }) {
|
||||
return React.createElement("span", {
|
||||
className,
|
||||
"aria-hidden": "true",
|
||||
style: {
|
||||
display: "inline-block",
|
||||
backgroundColor: "currentColor",
|
||||
mask: "url(/lark.svg) center / contain no-repeat",
|
||||
WebkitMask: "url(/lark.svg) center / contain no-repeat",
|
||||
} as React.CSSProperties,
|
||||
})
|
||||
}
|
||||
|
||||
const CHANNEL_ICON_MAP: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
telegram: IconBrandTelegram,
|
||||
discord: IconBrandDiscord,
|
||||
slack: IconBrandSlack,
|
||||
feishu: IconLark,
|
||||
dingtalk: IconBrandDingtalk,
|
||||
line: IconBrandLine,
|
||||
qq: IconBrandQq,
|
||||
wecom: IconBrandWechat,
|
||||
wecom_app: IconBrandWechat,
|
||||
wecom_aibot: IconBrandWechat,
|
||||
whatsapp: IconBrandWhatsapp,
|
||||
whatsapp_native: IconBrandWhatsapp,
|
||||
matrix: IconBrandMatrix,
|
||||
maixcam: IconCamera,
|
||||
onebot: IconRobot,
|
||||
pico: IconBrandChrome,
|
||||
irc: IconMessages,
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function isChannelEnabled(
|
||||
channel: SupportedChannel,
|
||||
channelsConfig: Record<string, unknown>,
|
||||
): boolean {
|
||||
const channelConfig = asRecord(channelsConfig[channel.config_key])
|
||||
if (channelConfig.enabled !== true) {
|
||||
return false
|
||||
}
|
||||
|
||||
// whatsapp / whatsapp_native share one config block and are split by use_native.
|
||||
if (channel.name === "whatsapp_native") {
|
||||
return channelConfig.use_native === true
|
||||
}
|
||||
if (channel.name === "whatsapp") {
|
||||
return channelConfig.use_native !== true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function buildChannelEnabledMap(
|
||||
channels: SupportedChannel[],
|
||||
appConfig: AppConfig,
|
||||
): Record<string, boolean> {
|
||||
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const channel of channels) {
|
||||
result[channel.name] = isChannelEnabled(channel, channelsConfig)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export interface SidebarChannelNavItem {
|
||||
key: string
|
||||
title: string
|
||||
url: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
interface UseSidebarChannelsOptions {
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
||||
const gateway = useAtomValue(gatewayAtom)
|
||||
const [channels, setChannels] = React.useState<SupportedChannel[]>([])
|
||||
const [enabledMap, setEnabledMap] = React.useState<Record<string, boolean>>(
|
||||
{},
|
||||
)
|
||||
const [showAllChannels, setShowAllChannels] = React.useState(false)
|
||||
|
||||
const reloadChannels = React.useCallback((shouldApply?: () => boolean) => {
|
||||
Promise.all([
|
||||
getChannelsCatalog(),
|
||||
getAppConfig().catch(() => ({}) as AppConfig),
|
||||
])
|
||||
.then(([catalog, appConfig]) => {
|
||||
if (shouldApply && !shouldApply()) {
|
||||
return
|
||||
}
|
||||
setChannels(catalog.channels)
|
||||
setEnabledMap(buildChannelEnabledMap(catalog.channels, appConfig))
|
||||
})
|
||||
.catch(() => {
|
||||
if (shouldApply && !shouldApply()) {
|
||||
return
|
||||
}
|
||||
setChannels([])
|
||||
setEnabledMap({})
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
reloadChannels(() => active)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [reloadChannels])
|
||||
|
||||
const previousGatewayStatusRef = React.useRef(gateway.status)
|
||||
React.useEffect(() => {
|
||||
const previousStatus = previousGatewayStatusRef.current
|
||||
if (previousStatus !== "running" && gateway.status === "running") {
|
||||
reloadChannels()
|
||||
}
|
||||
previousGatewayStatusRef.current = gateway.status
|
||||
}, [gateway.status, reloadChannels])
|
||||
|
||||
const sortedChannels = React.useMemo(() => {
|
||||
const list = [...channels]
|
||||
list.sort((a, b) => {
|
||||
const aEnabled = enabledMap[a.name] === true
|
||||
const bEnabled = enabledMap[b.name] === true
|
||||
if (aEnabled !== bEnabled) {
|
||||
return aEnabled ? -1 : 1
|
||||
}
|
||||
|
||||
const aImportance =
|
||||
CHANNEL_IMPORTANCE_INDEX.get(a.name) ?? Number.MAX_SAFE_INTEGER
|
||||
const bImportance =
|
||||
CHANNEL_IMPORTANCE_INDEX.get(b.name) ?? Number.MAX_SAFE_INTEGER
|
||||
if (aImportance !== bImportance) {
|
||||
return aImportance - bImportance
|
||||
}
|
||||
|
||||
return getChannelDisplayName(a, t).localeCompare(
|
||||
getChannelDisplayName(b, t),
|
||||
)
|
||||
})
|
||||
return list
|
||||
}, [channels, enabledMap, t])
|
||||
|
||||
const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS
|
||||
const visibleChannels = showAllChannels
|
||||
? sortedChannels
|
||||
: sortedChannels.slice(0, DEFAULT_VISIBLE_CHANNELS)
|
||||
|
||||
const channelItems = React.useMemo<SidebarChannelNavItem[]>(
|
||||
() =>
|
||||
visibleChannels.map((channel) => ({
|
||||
key: channel.name,
|
||||
title: getChannelDisplayName(channel, t),
|
||||
url: `/channels/${channel.name}`,
|
||||
icon: CHANNEL_ICON_MAP[channel.name] ?? IconPlug,
|
||||
})),
|
||||
[t, visibleChannels],
|
||||
)
|
||||
|
||||
const toggleShowAllChannels = React.useCallback(() => {
|
||||
setShowAllChannels((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
channelItems,
|
||||
hasMoreChannels,
|
||||
showAllChannels,
|
||||
toggleShowAllChannels,
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
"services": "Services",
|
||||
"channels_group": "Channels",
|
||||
"channels": "Channels",
|
||||
"show_more_channels": "More",
|
||||
"show_less_channels": "Less",
|
||||
"config": "Config",
|
||||
"logs": "Logs"
|
||||
},
|
||||
|
|
@ -232,6 +234,25 @@
|
|||
"action": {
|
||||
"configure": "Configure"
|
||||
},
|
||||
"name": {
|
||||
"telegram": "Telegram",
|
||||
"discord": "Discord",
|
||||
"slack": "Slack",
|
||||
"feishu": "Feishu",
|
||||
"dingtalk": "DingTalk",
|
||||
"line": "LINE",
|
||||
"qq": "QQ",
|
||||
"onebot": "OneBot",
|
||||
"wecom": "WeCom",
|
||||
"wecom_app": "WeCom App",
|
||||
"wecom_aibot": "WeCom AI Bot",
|
||||
"whatsapp": "WhatsApp",
|
||||
"whatsapp_native": "WhatsApp Native",
|
||||
"pico": "Web",
|
||||
"maixcam": "MaixCam",
|
||||
"matrix": "Matrix",
|
||||
"irc": "IRC"
|
||||
},
|
||||
"field": {
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "Enter bot token",
|
||||
|
|
@ -244,6 +265,12 @@
|
|||
"baseUrl": "API Base URL",
|
||||
"proxy": "HTTP Proxy",
|
||||
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
||||
"mentionOnly": "Mention Only",
|
||||
"typingEnabled": "Typing Indicator",
|
||||
"placeholderEnabled": "Placeholder Message",
|
||||
"placeholderText": "Placeholder Text",
|
||||
"groupTriggerMentionOnly": "Group Mention Only",
|
||||
"groupTriggerPrefixes": "Group Trigger Prefixes",
|
||||
"allowFrom": "Allow From",
|
||||
"allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.",
|
||||
"allowFromPlaceholder": "e.g. 123456, 789012",
|
||||
|
|
@ -259,6 +286,78 @@
|
|||
"description": "Set up credentials and options for this channel.",
|
||||
"saveError": "Failed to save channel configuration",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"page": {
|
||||
"notFound": "Channel \"{{name}}\" is not supported.",
|
||||
"saveSuccess": "Channel configuration saved.",
|
||||
"saveError": "Failed to save channel configuration",
|
||||
"flowTitle": "Setup Flow",
|
||||
"flowDescription": "Complete the following steps to put this channel into service.",
|
||||
"step1": "Set credentials",
|
||||
"step2": "Enable channel",
|
||||
"step3": "Save and restart gateway",
|
||||
"enabled": "enabled",
|
||||
"disabled": "disabled",
|
||||
"docLink": "Documentation",
|
||||
"enableLabel": "Enable channel"
|
||||
},
|
||||
"form": {
|
||||
"desc": {
|
||||
"token": "Bot access token used to connect to the platform API.",
|
||||
"botToken": "Bot token used to send and receive messages.",
|
||||
"appToken": "App token used for Socket Mode connections.",
|
||||
"appId": "Unique application ID used for authentication.",
|
||||
"appSecret": "Application secret used for signing and authentication.",
|
||||
"verificationToken": "Verification token for event callbacks.",
|
||||
"encryptKey": "Encryption key used to decrypt callback payloads.",
|
||||
"baseUrl": "Platform API base URL. Official endpoint is used by default.",
|
||||
"proxy": "HTTP proxy address for outbound network access.",
|
||||
"mentionOnly": "Only respond when the bot is explicitly mentioned in group chats.",
|
||||
"typingEnabled": "Display typing status while the assistant is generating a response.",
|
||||
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
|
||||
"placeholderText": "Placeholder text shown while waiting for the final response.",
|
||||
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
|
||||
"groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.",
|
||||
"allowFrom": "Allowed user or group IDs, separated by commas.",
|
||||
"allowOrigins": "Allowed origin domains, separated by commas.",
|
||||
"wsUrl": "WebSocket service URL.",
|
||||
"reconnectInterval": "Reconnect interval after disconnection (seconds).",
|
||||
"bridgeUrl": "Bridge service URL.",
|
||||
"sessionStorePath": "Local path for session storage.",
|
||||
"useNative": "Whether to use native client mode.",
|
||||
"host": "Service host address.",
|
||||
"port": "Service port.",
|
||||
"homeserver": "Matrix homeserver URL.",
|
||||
"userId": "Account user ID.",
|
||||
"deviceId": "Device ID.",
|
||||
"joinOnInvite": "Automatically join rooms when invited.",
|
||||
"clientId": "Client ID used for platform authentication.",
|
||||
"corpId": "Enterprise Corp ID.",
|
||||
"agentId": "Enterprise application Agent ID.",
|
||||
"webhookUrl": "Full webhook URL.",
|
||||
"webhookHost": "Webhook listening host.",
|
||||
"webhookPort": "Webhook listening port.",
|
||||
"webhookPath": "Webhook route path.",
|
||||
"replyTimeout": "Reply timeout in seconds.",
|
||||
"maxSteps": "Maximum number of processing steps.",
|
||||
"welcomeMessage": "Welcome message content for new sessions.",
|
||||
"allowTokenQuery": "Allow token in URL query parameters.",
|
||||
"pingInterval": "Connection heartbeat interval in seconds.",
|
||||
"readTimeout": "Read timeout in seconds.",
|
||||
"writeTimeout": "Write timeout in seconds.",
|
||||
"maxConnections": "Maximum number of concurrent connections.",
|
||||
"server": "IRC server address.",
|
||||
"tls": "Whether to enable TLS.",
|
||||
"nick": "Bot nickname.",
|
||||
"user": "IRC username.",
|
||||
"realName": "Displayed real name.",
|
||||
"channels": "IRC channels to join.",
|
||||
"requestCaps": "IRC capability list requested on connect.",
|
||||
"genericField": "Used to configure {{field}}."
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"requiredField": "This field is required."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
"services": "服务",
|
||||
"channels_group": "频道",
|
||||
"channels": "频道",
|
||||
"show_more_channels": "更多",
|
||||
"show_less_channels": "收起",
|
||||
"config": "配置",
|
||||
"logs": "日志"
|
||||
},
|
||||
|
|
@ -232,6 +234,25 @@
|
|||
"action": {
|
||||
"configure": "配置"
|
||||
},
|
||||
"name": {
|
||||
"telegram": "Telegram",
|
||||
"discord": "Discord",
|
||||
"slack": "Slack",
|
||||
"feishu": "飞书",
|
||||
"dingtalk": "钉钉",
|
||||
"line": "LINE",
|
||||
"qq": "QQ",
|
||||
"onebot": "OneBot",
|
||||
"wecom": "企业微信",
|
||||
"wecom_app": "企业微信应用",
|
||||
"wecom_aibot": "企业微信 AI 机器人",
|
||||
"whatsapp": "WhatsApp",
|
||||
"whatsapp_native": "WhatsApp Native",
|
||||
"pico": "Web",
|
||||
"maixcam": "MaixCam",
|
||||
"matrix": "Matrix",
|
||||
"irc": "IRC"
|
||||
},
|
||||
"field": {
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "输入 Bot Token",
|
||||
|
|
@ -244,6 +265,12 @@
|
|||
"baseUrl": "API Base URL",
|
||||
"proxy": "HTTP 代理",
|
||||
"proxyHint": "可选。例如 http://127.0.0.1:7890",
|
||||
"mentionOnly": "仅提及时响应",
|
||||
"typingEnabled": "输入中提示",
|
||||
"placeholderEnabled": "占位消息",
|
||||
"placeholderText": "占位文案",
|
||||
"groupTriggerMentionOnly": "群聊仅提及时响应",
|
||||
"groupTriggerPrefixes": "群聊触发前缀",
|
||||
"allowFrom": "允许来源",
|
||||
"allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。",
|
||||
"allowFromPlaceholder": "例如 123456, 789012",
|
||||
|
|
@ -259,6 +286,78 @@
|
|||
"description": "设置此频道的凭据和选项。",
|
||||
"saveError": "保存频道配置失败",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"page": {
|
||||
"notFound": "不支持频道“{{name}}”。",
|
||||
"saveSuccess": "频道配置已保存。",
|
||||
"saveError": "保存频道配置失败",
|
||||
"flowTitle": "配置流程",
|
||||
"flowDescription": "按以下步骤完成频道接入并投入使用。",
|
||||
"step1": "填写凭据",
|
||||
"step2": "启用频道",
|
||||
"step3": "保存并重启网关",
|
||||
"enabled": "已启用",
|
||||
"disabled": "未启用",
|
||||
"docLink": "配置文档",
|
||||
"enableLabel": "启用频道"
|
||||
},
|
||||
"form": {
|
||||
"desc": {
|
||||
"token": "机器人访问令牌,用于连接平台 API。",
|
||||
"botToken": "Bot Token,用于发送与接收消息。",
|
||||
"appToken": "App Token,用于 Socket 模式连接。",
|
||||
"appId": "应用唯一标识,用于平台鉴权。",
|
||||
"appSecret": "应用密钥,用于请求签名和鉴权。",
|
||||
"verificationToken": "事件回调验证令牌。",
|
||||
"encryptKey": "消息加密密钥,用于解密回调内容。",
|
||||
"baseUrl": "平台 API 地址,默认使用官方地址。",
|
||||
"proxy": "HTTP 代理地址,用于网络访问。",
|
||||
"mentionOnly": "在群聊中仅当明确提及时才响应。",
|
||||
"typingEnabled": "在生成回复时显示“正在输入”状态。",
|
||||
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息。",
|
||||
"placeholderText": "等待最终回复期间显示的占位文案。",
|
||||
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应。",
|
||||
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔。",
|
||||
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔。",
|
||||
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔。",
|
||||
"wsUrl": "WebSocket 服务地址。",
|
||||
"reconnectInterval": "断线后的重连间隔(秒)。",
|
||||
"bridgeUrl": "桥接服务地址。",
|
||||
"sessionStorePath": "本地会话存储目录路径。",
|
||||
"useNative": "是否使用原生客户端模式连接。",
|
||||
"host": "服务监听主机地址。",
|
||||
"port": "服务监听端口。",
|
||||
"homeserver": "Matrix homeserver 地址。",
|
||||
"userId": "账号 ID。",
|
||||
"deviceId": "设备 ID。",
|
||||
"joinOnInvite": "收到邀请时是否自动加入房间。",
|
||||
"clientId": "应用客户端 ID,用于平台鉴权。",
|
||||
"corpId": "企业 ID。",
|
||||
"agentId": "企业应用 Agent ID。",
|
||||
"webhookUrl": "Webhook 完整地址。",
|
||||
"webhookHost": "Webhook 监听主机。",
|
||||
"webhookPort": "Webhook 监听端口。",
|
||||
"webhookPath": "Webhook 路径。",
|
||||
"replyTimeout": "回复超时时间(秒)。",
|
||||
"maxSteps": "最大步骤数。",
|
||||
"welcomeMessage": "新会话欢迎语内容。",
|
||||
"allowTokenQuery": "是否允许 URL Query 方式传递 Token。",
|
||||
"pingInterval": "连接心跳间隔(秒)。",
|
||||
"readTimeout": "读取超时时间(秒)。",
|
||||
"writeTimeout": "写入超时时间(秒)。",
|
||||
"maxConnections": "最大并发连接数。",
|
||||
"server": "IRC 服务器地址。",
|
||||
"tls": "是否启用 TLS 连接。",
|
||||
"nick": "机器人昵称。",
|
||||
"user": "IRC 用户名。",
|
||||
"realName": "显示名称。",
|
||||
"channels": "要加入的 IRC 频道列表。",
|
||||
"requestCaps": "连接时请求的 IRC 扩展能力列表。",
|
||||
"genericField": "用于配置{{field}}。"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"requiredField": "请填写该字段"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import { Route as ModelsRouteImport } from './routes/models'
|
|||
import { Route as LogsRouteImport } from './routes/logs'
|
||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||
import { Route as ConfigRouteImport } from './routes/config'
|
||||
import { Route as ChannelsRouteImport } from './routes/channels'
|
||||
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
||||
|
||||
const ProvidersRoute = ProvidersRouteImport.update({
|
||||
id: '/providers',
|
||||
|
|
@ -42,7 +43,7 @@ const ConfigRoute = ConfigRouteImport.update({
|
|||
path: '/config',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ChannelsRoute = ChannelsRouteImport.update({
|
||||
const ChannelsRouteRoute = ChannelsRouteRouteImport.update({
|
||||
id: '/channels',
|
||||
path: '/channels',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
|
|
@ -52,34 +53,42 @@ const IndexRoute = IndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
||||
id: '/$name',
|
||||
path: '/$name',
|
||||
getParentRoute: () => ChannelsRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/config': typeof ConfigRoute
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/config': typeof ConfigRoute
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/config': typeof ConfigRoute
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
|
|
@ -91,6 +100,7 @@ export interface FileRouteTypes {
|
|||
| '/logs'
|
||||
| '/models'
|
||||
| '/providers'
|
||||
| '/channels/$name'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -100,6 +110,7 @@ export interface FileRouteTypes {
|
|||
| '/logs'
|
||||
| '/models'
|
||||
| '/providers'
|
||||
| '/channels/$name'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
|
|
@ -109,11 +120,12 @@ export interface FileRouteTypes {
|
|||
| '/logs'
|
||||
| '/models'
|
||||
| '/providers'
|
||||
| '/channels/$name'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ChannelsRoute: typeof ChannelsRoute
|
||||
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
|
||||
ConfigRoute: typeof ConfigRoute
|
||||
CredentialsRoute: typeof CredentialsRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
|
|
@ -162,7 +174,7 @@ declare module '@tanstack/react-router' {
|
|||
id: '/channels'
|
||||
path: '/channels'
|
||||
fullPath: '/channels'
|
||||
preLoaderRoute: typeof ChannelsRouteImport
|
||||
preLoaderRoute: typeof ChannelsRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
|
|
@ -172,12 +184,31 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/channels/$name': {
|
||||
id: '/channels/$name'
|
||||
path: '/$name'
|
||||
fullPath: '/channels/$name'
|
||||
preLoaderRoute: typeof ChannelsNameRouteImport
|
||||
parentRoute: typeof ChannelsRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelsRouteRouteChildren {
|
||||
ChannelsNameRoute: typeof ChannelsNameRoute
|
||||
}
|
||||
|
||||
const ChannelsRouteRouteChildren: ChannelsRouteRouteChildren = {
|
||||
ChannelsNameRoute: ChannelsNameRoute,
|
||||
}
|
||||
|
||||
const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
|
||||
ChannelsRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ChannelsRoute: ChannelsRoute,
|
||||
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
||||
ConfigRoute: ConfigRoute,
|
||||
CredentialsRoute: CredentialsRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { ChannelsPage } from "@/components/channels/channels-page"
|
||||
|
||||
export const Route = createFileRoute("/channels")({
|
||||
component: ChannelsPage,
|
||||
})
|
||||
13
web/frontend/src/routes/channels/$name.tsx
Normal file
13
web/frontend/src/routes/channels/$name.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { ChannelConfigPage } from "@/components/channels/channel-config-page"
|
||||
|
||||
export const Route = createFileRoute("/channels/$name")({
|
||||
component: ChannelsByNameRoute,
|
||||
})
|
||||
|
||||
function ChannelsByNameRoute() {
|
||||
const { name } = Route.useParams()
|
||||
|
||||
return <ChannelConfigPage channelName={name} />
|
||||
}
|
||||
22
web/frontend/src/routes/channels/route.tsx
Normal file
22
web/frontend/src/routes/channels/route.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
createFileRoute,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/channels")({
|
||||
component: ChannelsLayout,
|
||||
})
|
||||
|
||||
function ChannelsLayout() {
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
})
|
||||
|
||||
if (pathname === "/channels") {
|
||||
return <Navigate to="/channels/$name" params={{ name: "pico" }} />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue