feat(web): add channel management page for web console

Add a complete channel management UI that allows users to configure
messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly
from the web console instead of manually editing config.json.

Backend: GET/PUT/PATCH API endpoints for listing, updating, and
toggling channels with secret field masking.

Frontend: Channel cards grid with enable/disable toggles, per-channel
configuration sheets with dedicated forms for major platforms and a
generic fallback for others.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guoguo 2026-03-06 03:28:27 -08:00
parent f245fb06ba
commit 5ae8044c72
17 changed files with 1642 additions and 2 deletions

View file

@ -91,7 +91,7 @@ build: generate
build-launcher: build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
@if [ ! -d web/backend/dist ]; then \ @if [ ! -f web/backend/dist/index.html ]; then \
echo "Building frontend..."; \ echo "Building frontend..."; \
cd web/frontend && pnpm install && pnpm build:backend; \ cd web/frontend && pnpm install && pnpm build:backend; \
fi fi

609
web/backend/api/channels.go Normal file
View file

@ -0,0 +1,609 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerChannelRoutes binds channel management endpoints to the ServeMux.
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/channels", h.handleListChannels)
mux.HandleFunc("PUT /api/channels/{name}", h.handleUpdateChannel)
mux.HandleFunc("PATCH /api/channels/{name}/toggle", h.handleToggleChannel)
}
// channelMeta holds static metadata for each supported channel.
type channelMeta struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
}
var channelRegistry = []channelMeta{
{Name: "telegram", DisplayName: "Telegram"},
{Name: "discord", DisplayName: "Discord"},
{Name: "slack", DisplayName: "Slack"},
{Name: "feishu", DisplayName: "Feishu"},
{Name: "dingtalk", DisplayName: "DingTalk"},
{Name: "line", DisplayName: "LINE"},
{Name: "qq", DisplayName: "QQ"},
{Name: "onebot", DisplayName: "OneBot"},
{Name: "wecom", DisplayName: "WeCom"},
{Name: "wecom_app", DisplayName: "WeCom App"},
{Name: "wecom_aibot", DisplayName: "WeCom AI Bot"},
{Name: "whatsapp", DisplayName: "WhatsApp"},
{Name: "pico", DisplayName: "Pico (Web)"},
{Name: "maixcam", DisplayName: "MaixCAM"},
}
// channelResponse is the JSON structure returned for each channel in the list.
type channelResponse struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
Config map[string]any `json:"config"`
}
// handleListChannels returns all channels with their enabled status and masked secrets.
//
// GET /api/channels
func (h *Handler) handleListChannels(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
channels := make([]channelResponse, 0, len(channelRegistry))
for _, meta := range channelRegistry {
cr := channelResponse{
Name: meta.Name,
DisplayName: meta.DisplayName,
}
cr.Enabled, cr.Configured, cr.Config = extractChannelInfo(meta.Name, &cfg.Channels)
channels = append(channels, cr)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"channels": channels,
})
}
// handleUpdateChannel replaces a channel's configuration.
// Secret fields sent as empty strings are preserved from the existing config.
//
// PUT /api/channels/{name}
func (h *Handler) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !isValidChannel(name) {
http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var incoming map[string]any
if err = json.Unmarshal(body, &incoming); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
applyChannelUpdate(name, &cfg.Channels, incoming)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleToggleChannel enables or disables a channel.
//
// PATCH /api/channels/{name}/toggle
func (h *Handler) handleToggleChannel(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !isValidChannel(name) {
http.Error(w, fmt.Sprintf("Unknown channel: %s", name), http.StatusNotFound)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Enabled bool `json:"enabled"`
}
if err = json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
setChannelEnabled(name, &cfg.Channels, req.Enabled)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func isValidChannel(name string) bool {
for _, m := range channelRegistry {
if m.Name == name {
return true
}
}
return false
}
// extractChannelInfo returns enabled, configured status and masked config for a channel.
func extractChannelInfo(name string, ch *config.ChannelsConfig) (enabled, configured bool, cfg map[string]any) {
cfg = make(map[string]any)
switch name {
case "telegram":
c := ch.Telegram
enabled = c.Enabled
configured = c.Token != ""
cfg["token"] = maskAPIKey(c.Token)
cfg["base_url"] = c.BaseURL
cfg["proxy"] = c.Proxy
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
cfg["typing"] = c.Typing
cfg["placeholder"] = c.Placeholder
case "discord":
c := ch.Discord
enabled = c.Enabled
configured = c.Token != ""
cfg["token"] = maskAPIKey(c.Token)
cfg["proxy"] = c.Proxy
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
cfg["typing"] = c.Typing
cfg["placeholder"] = c.Placeholder
case "slack":
c := ch.Slack
enabled = c.Enabled
configured = c.BotToken != ""
cfg["bot_token"] = maskAPIKey(c.BotToken)
cfg["app_token"] = maskAPIKey(c.AppToken)
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
cfg["typing"] = c.Typing
cfg["placeholder"] = c.Placeholder
case "feishu":
c := ch.Feishu
enabled = c.Enabled
configured = c.AppID != "" && c.AppSecret != ""
cfg["app_id"] = c.AppID
cfg["app_secret"] = maskAPIKey(c.AppSecret)
cfg["encrypt_key"] = maskAPIKey(c.EncryptKey)
cfg["verification_token"] = maskAPIKey(c.VerificationToken)
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
cfg["placeholder"] = c.Placeholder
case "dingtalk":
c := ch.DingTalk
enabled = c.Enabled
configured = c.ClientID != "" && c.ClientSecret != ""
cfg["client_id"] = c.ClientID
cfg["client_secret"] = maskAPIKey(c.ClientSecret)
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
case "line":
c := ch.LINE
enabled = c.Enabled
configured = c.ChannelSecret != "" && c.ChannelAccessToken != ""
cfg["channel_secret"] = maskAPIKey(c.ChannelSecret)
cfg["channel_access_token"] = maskAPIKey(c.ChannelAccessToken)
cfg["webhook_host"] = c.WebhookHost
cfg["webhook_port"] = c.WebhookPort
cfg["webhook_path"] = c.WebhookPath
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
cfg["typing"] = c.Typing
cfg["placeholder"] = c.Placeholder
case "qq":
c := ch.QQ
enabled = c.Enabled
configured = c.AppID != "" && c.AppSecret != ""
cfg["app_id"] = c.AppID
cfg["app_secret"] = maskAPIKey(c.AppSecret)
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
case "onebot":
c := ch.OneBot
enabled = c.Enabled
configured = c.WSUrl != ""
cfg["ws_url"] = c.WSUrl
cfg["access_token"] = maskAPIKey(c.AccessToken)
cfg["reconnect_interval"] = c.ReconnectInterval
cfg["allow_from"] = []string(c.AllowFrom)
cfg["group_trigger"] = c.GroupTrigger
cfg["typing"] = c.Typing
cfg["placeholder"] = c.Placeholder
case "wecom":
c := ch.WeCom
enabled = c.Enabled
configured = c.Token != ""
cfg["token"] = maskAPIKey(c.Token)
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
cfg["webhook_url"] = c.WebhookURL
cfg["webhook_host"] = c.WebhookHost
cfg["webhook_port"] = c.WebhookPort
cfg["webhook_path"] = c.WebhookPath
cfg["allow_from"] = []string(c.AllowFrom)
cfg["reply_timeout"] = c.ReplyTimeout
cfg["group_trigger"] = c.GroupTrigger
case "wecom_app":
c := ch.WeComApp
enabled = c.Enabled
configured = c.CorpID != "" && c.CorpSecret != ""
cfg["corp_id"] = c.CorpID
cfg["corp_secret"] = maskAPIKey(c.CorpSecret)
cfg["agent_id"] = c.AgentID
cfg["token"] = maskAPIKey(c.Token)
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
cfg["webhook_host"] = c.WebhookHost
cfg["webhook_port"] = c.WebhookPort
cfg["webhook_path"] = c.WebhookPath
cfg["allow_from"] = []string(c.AllowFrom)
cfg["reply_timeout"] = c.ReplyTimeout
cfg["group_trigger"] = c.GroupTrigger
case "wecom_aibot":
c := ch.WeComAIBot
enabled = c.Enabled
configured = c.Token != ""
cfg["token"] = maskAPIKey(c.Token)
cfg["encoding_aes_key"] = maskAPIKey(c.EncodingAESKey)
cfg["webhook_path"] = c.WebhookPath
cfg["allow_from"] = []string(c.AllowFrom)
cfg["reply_timeout"] = c.ReplyTimeout
cfg["max_steps"] = c.MaxSteps
cfg["welcome_message"] = c.WelcomeMessage
case "whatsapp":
c := ch.WhatsApp
enabled = c.Enabled
configured = c.BridgeURL != "" || c.UseNative
cfg["bridge_url"] = c.BridgeURL
cfg["use_native"] = c.UseNative
cfg["session_store_path"] = c.SessionStorePath
cfg["allow_from"] = []string(c.AllowFrom)
case "pico":
c := ch.Pico
enabled = c.Enabled
configured = true // Always considered configured (built-in WebSocket channel)
cfg["token"] = maskAPIKey(c.Token)
cfg["allow_token_query"] = c.AllowTokenQuery
cfg["allow_origins"] = c.AllowOrigins
cfg["ping_interval"] = c.PingInterval
cfg["read_timeout"] = c.ReadTimeout
cfg["write_timeout"] = c.WriteTimeout
cfg["max_connections"] = c.MaxConnections
cfg["allow_from"] = []string(c.AllowFrom)
cfg["placeholder"] = c.Placeholder
case "maixcam":
c := ch.MaixCam
enabled = c.Enabled
configured = c.Host != ""
cfg["host"] = c.Host
cfg["port"] = c.Port
cfg["allow_from"] = []string(c.AllowFrom)
}
return
}
// setChannelEnabled sets the enabled flag for a channel.
func setChannelEnabled(name string, ch *config.ChannelsConfig, enabled bool) {
switch name {
case "telegram":
ch.Telegram.Enabled = enabled
case "discord":
ch.Discord.Enabled = enabled
case "slack":
ch.Slack.Enabled = enabled
case "feishu":
ch.Feishu.Enabled = enabled
case "dingtalk":
ch.DingTalk.Enabled = enabled
case "line":
ch.LINE.Enabled = enabled
case "qq":
ch.QQ.Enabled = enabled
case "onebot":
ch.OneBot.Enabled = enabled
case "wecom":
ch.WeCom.Enabled = enabled
case "wecom_app":
ch.WeComApp.Enabled = enabled
case "wecom_aibot":
ch.WeComAIBot.Enabled = enabled
case "whatsapp":
ch.WhatsApp.Enabled = enabled
case "pico":
ch.Pico.Enabled = enabled
case "maixcam":
ch.MaixCam.Enabled = enabled
}
}
// applyChannelUpdate applies incoming config fields to the corresponding channel.
// Empty secret fields are preserved from the existing config.
func applyChannelUpdate(name string, ch *config.ChannelsConfig, incoming map[string]any) {
getString := func(key string) string {
if v, ok := incoming[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
getBool := func(key string) bool {
if v, ok := incoming[key]; ok {
if b, ok := v.(bool); ok {
return b
}
}
return false
}
getInt := func(key string) int {
if v, ok := incoming[key]; ok {
if f, ok := v.(float64); ok {
return int(f)
}
}
return 0
}
getInt64 := func(key string) int64 {
if v, ok := incoming[key]; ok {
if f, ok := v.(float64); ok {
return int64(f)
}
}
return 0
}
getStringSlice := func(key string) config.FlexibleStringSlice {
if v, ok := incoming[key]; ok {
if arr, ok := v.([]any); ok {
result := make(config.FlexibleStringSlice, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}
}
return nil
}
getGroupTrigger := func() config.GroupTriggerConfig {
if v, ok := incoming["group_trigger"]; ok {
if m, ok := v.(map[string]any); ok {
gt := config.GroupTriggerConfig{}
if b, ok := m["mention_only"].(bool); ok {
gt.MentionOnly = b
}
if arr, ok := m["prefixes"].([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
gt.Prefixes = append(gt.Prefixes, s)
}
}
}
return gt
}
}
return config.GroupTriggerConfig{}
}
getTyping := func() config.TypingConfig {
if v, ok := incoming["typing"]; ok {
if m, ok := v.(map[string]any); ok {
if b, ok := m["enabled"].(bool); ok {
return config.TypingConfig{Enabled: b}
}
}
}
return config.TypingConfig{}
}
getPlaceholder := func() config.PlaceholderConfig {
if v, ok := incoming["placeholder"]; ok {
if m, ok := v.(map[string]any); ok {
pc := config.PlaceholderConfig{}
if b, ok := m["enabled"].(bool); ok {
pc.Enabled = b
}
if s, ok := m["text"].(string); ok {
pc.Text = s
}
return pc
}
}
return config.PlaceholderConfig{}
}
// preserveSecret returns the incoming value if non-empty, or keeps existing.
preserveSecret := func(incoming, existing string) string {
if incoming == "" {
return existing
}
return incoming
}
switch name {
case "telegram":
c := &ch.Telegram
c.Enabled = getBool("enabled")
c.Token = preserveSecret(getString("token"), c.Token)
c.BaseURL = getString("base_url")
c.Proxy = getString("proxy")
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
c.Typing = getTyping()
c.Placeholder = getPlaceholder()
case "discord":
c := &ch.Discord
c.Enabled = getBool("enabled")
c.Token = preserveSecret(getString("token"), c.Token)
c.Proxy = getString("proxy")
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
c.Typing = getTyping()
c.Placeholder = getPlaceholder()
case "slack":
c := &ch.Slack
c.Enabled = getBool("enabled")
c.BotToken = preserveSecret(getString("bot_token"), c.BotToken)
c.AppToken = preserveSecret(getString("app_token"), c.AppToken)
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
c.Typing = getTyping()
c.Placeholder = getPlaceholder()
case "feishu":
c := &ch.Feishu
c.Enabled = getBool("enabled")
c.AppID = getString("app_id")
c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret)
c.EncryptKey = preserveSecret(getString("encrypt_key"), c.EncryptKey)
c.VerificationToken = preserveSecret(getString("verification_token"), c.VerificationToken)
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
c.Placeholder = getPlaceholder()
case "dingtalk":
c := &ch.DingTalk
c.Enabled = getBool("enabled")
c.ClientID = getString("client_id")
c.ClientSecret = preserveSecret(getString("client_secret"), c.ClientSecret)
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
case "line":
c := &ch.LINE
c.Enabled = getBool("enabled")
c.ChannelSecret = preserveSecret(getString("channel_secret"), c.ChannelSecret)
c.ChannelAccessToken = preserveSecret(getString("channel_access_token"), c.ChannelAccessToken)
c.WebhookHost = getString("webhook_host")
c.WebhookPort = getInt("webhook_port")
c.WebhookPath = getString("webhook_path")
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
c.Typing = getTyping()
c.Placeholder = getPlaceholder()
case "qq":
c := &ch.QQ
c.Enabled = getBool("enabled")
c.AppID = getString("app_id")
c.AppSecret = preserveSecret(getString("app_secret"), c.AppSecret)
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
case "onebot":
c := &ch.OneBot
c.Enabled = getBool("enabled")
c.WSUrl = getString("ws_url")
c.AccessToken = preserveSecret(getString("access_token"), c.AccessToken)
c.ReconnectInterval = getInt("reconnect_interval")
c.AllowFrom = getStringSlice("allow_from")
c.GroupTrigger = getGroupTrigger()
c.Typing = getTyping()
c.Placeholder = getPlaceholder()
case "wecom":
c := &ch.WeCom
c.Enabled = getBool("enabled")
c.Token = preserveSecret(getString("token"), c.Token)
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
c.WebhookURL = getString("webhook_url")
c.WebhookHost = getString("webhook_host")
c.WebhookPort = getInt("webhook_port")
c.WebhookPath = getString("webhook_path")
c.AllowFrom = getStringSlice("allow_from")
c.ReplyTimeout = getInt("reply_timeout")
c.GroupTrigger = getGroupTrigger()
case "wecom_app":
c := &ch.WeComApp
c.Enabled = getBool("enabled")
c.CorpID = getString("corp_id")
c.CorpSecret = preserveSecret(getString("corp_secret"), c.CorpSecret)
c.AgentID = getInt64("agent_id")
c.Token = preserveSecret(getString("token"), c.Token)
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
c.WebhookHost = getString("webhook_host")
c.WebhookPort = getInt("webhook_port")
c.WebhookPath = getString("webhook_path")
c.AllowFrom = getStringSlice("allow_from")
c.ReplyTimeout = getInt("reply_timeout")
c.GroupTrigger = getGroupTrigger()
case "wecom_aibot":
c := &ch.WeComAIBot
c.Enabled = getBool("enabled")
c.Token = preserveSecret(getString("token"), c.Token)
c.EncodingAESKey = preserveSecret(getString("encoding_aes_key"), c.EncodingAESKey)
c.WebhookPath = getString("webhook_path")
c.AllowFrom = getStringSlice("allow_from")
c.ReplyTimeout = getInt("reply_timeout")
c.MaxSteps = getInt("max_steps")
c.WelcomeMessage = getString("welcome_message")
case "whatsapp":
c := &ch.WhatsApp
c.Enabled = getBool("enabled")
c.BridgeURL = getString("bridge_url")
c.UseNative = getBool("use_native")
c.SessionStorePath = getString("session_store_path")
c.AllowFrom = getStringSlice("allow_from")
case "pico":
c := &ch.Pico
c.Enabled = getBool("enabled")
c.Token = preserveSecret(getString("token"), c.Token)
c.AllowTokenQuery = getBool("allow_token_query")
if v, ok := incoming["allow_origins"]; ok {
if arr, ok := v.([]any); ok {
origins := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
origins = append(origins, s)
}
}
c.AllowOrigins = origins
}
}
c.PingInterval = getInt("ping_interval")
c.ReadTimeout = getInt("read_timeout")
c.WriteTimeout = getInt("write_timeout")
c.MaxConnections = getInt("max_connections")
c.AllowFrom = getStringSlice("allow_from")
c.Placeholder = getPlaceholder()
case "maixcam":
c := &ch.MaixCam
c.Enabled = getBool("enabled")
c.Host = getString("host")
c.Port = getInt("port")
c.AllowFrom = getStringSlice("allow_from")
}
}

View file

@ -41,4 +41,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Model list management // Model list management
h.registerModelRoutes(mux) h.registerModelRoutes(mux)
// Channel management
h.registerChannelRoutes(mux)
} }

View file

@ -0,0 +1,55 @@
// API client for channel management.
export interface ChannelInfo {
name: string
display_name: string
enabled: boolean
configured: boolean
config: Record<string, any>
}
interface ChannelsListResponse {
channels: ChannelInfo[]
}
interface ChannelActionResponse {
status: string
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getChannels(): Promise<ChannelsListResponse> {
return request<ChannelsListResponse>("/api/channels")
}
export async function updateChannel(
name: string,
config: Record<string, any>,
): Promise<ChannelActionResponse> {
return request<ChannelActionResponse>(`/api/channels/${name}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
})
}
export async function toggleChannel(
name: string,
enabled: boolean,
): Promise<ChannelActionResponse> {
return request<ChannelActionResponse>(`/api/channels/${name}/toggle`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
})
}
export type { ChannelsListResponse, ChannelActionResponse }

View file

@ -4,6 +4,7 @@ import {
IconKey, IconKey,
IconListDetails, IconListDetails,
IconMessageCircle, IconMessageCircle,
IconPlug,
IconSettings, IconSettings,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { Link, useRouterState } from "@tanstack/react-router" import { Link, useRouterState } from "@tanstack/react-router"
@ -46,6 +47,7 @@ const navGroups = [
label: "navigation.services", label: "navigation.services",
defaultOpen: true, defaultOpen: true,
items: [ items: [
{ title: "navigation.channels", url: "/channels", icon: IconPlug },
{ title: "navigation.config", url: "/config", icon: IconSettings }, { title: "navigation.config", url: "/config", icon: IconSettings },
{ title: "navigation.logs", url: "/logs", icon: IconListDetails }, { title: "navigation.logs", url: "/logs", icon: IconListDetails },
], ],

View file

@ -0,0 +1,64 @@
import { IconSettings } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { ChannelInfo } from "@/api/channels"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
interface ChannelCardProps {
channel: ChannelInfo
onToggle: (name: string, enabled: boolean) => void
onEdit: (channel: ChannelInfo) => void
toggling: boolean
}
export function ChannelCard({
channel,
onToggle,
onEdit,
toggling,
}: ChannelCardProps) {
const { t } = useTranslation()
return (
<div className="border-border/60 bg-card flex items-center justify-between rounded-xl border p-4 transition-colors">
<div className="flex items-center gap-3">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">
{channel.display_name}
</span>
{channel.configured ? (
<span className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full px-2 py-0.5 text-[10px] font-medium">
{t("channels.status.configured")}
</span>
) : (
<span className="text-muted-foreground bg-muted rounded-full px-2 py-0.5 text-[10px] font-medium">
{t("channels.status.unconfigured")}
</span>
)}
</div>
<span className="text-muted-foreground text-xs">{channel.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={channel.enabled}
onCheckedChange={(checked) => onToggle(channel.name, checked)}
disabled={toggling}
/>
<Button
size="sm"
variant="ghost"
className="size-8 p-0"
onClick={() => onEdit(channel)}
>
<IconSettings className="size-4" />
<span className="sr-only">
{t("channels.action.configure")}
</span>
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import {
AdvancedSection,
Field,
KeyInput,
} from "@/components/models/shared-form"
interface DiscordFormProps {
config: Record<string, any>
onChange: (key: string, value: any) => void
isEdit: boolean
}
export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
const { t } = useTranslation()
return (
<div className="space-y-5">
<Field
label={t("channels.field.token")}
hint={
isEdit && config.token
? t("channels.field.secretHintSet")
: undefined
}
>
<KeyInput
value={config._token ?? ""}
onChange={(v) => onChange("_token", v)}
placeholder={
isEdit && config.token
? t("channels.field.secretPlaceholderSet")
: t("channels.field.tokenPlaceholder")
}
/>
</Field>
<AdvancedSection>
<Field
label={t("channels.field.proxy")}
hint={t("channels.field.proxyHint")}
>
<Input
value={config.proxy ?? ""}
onChange={(e) => onChange("proxy", e.target.value)}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.field.allowFromHint")}
>
<Input
value={(config.allow_from ?? []).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</AdvancedSection>
</div>
)
}

View file

@ -0,0 +1,92 @@
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import {
AdvancedSection,
Field,
KeyInput,
} from "@/components/models/shared-form"
interface FeishuFormProps {
config: Record<string, any>
onChange: (key: string, value: any) => void
isEdit: boolean
}
export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
const { t } = useTranslation()
return (
<div className="space-y-5">
<Field label={t("channels.field.appId")}>
<Input
value={config.app_id ?? ""}
onChange={(e) => onChange("app_id", e.target.value)}
placeholder="cli_xxxx"
/>
</Field>
<Field
label={t("channels.field.appSecret")}
hint={
isEdit && config.app_secret
? t("channels.field.secretHintSet")
: undefined
}
>
<KeyInput
value={config._app_secret ?? ""}
onChange={(v) => onChange("_app_secret", v)}
placeholder={
isEdit && config.app_secret
? t("channels.field.secretPlaceholderSet")
: t("channels.field.secretPlaceholder")
}
/>
</Field>
<AdvancedSection>
<Field label={t("channels.field.verificationToken")}>
<KeyInput
value={config._verification_token ?? ""}
onChange={(v) => onChange("_verification_token", v)}
placeholder={
isEdit && config.verification_token
? t("channels.field.secretPlaceholderSet")
: t("channels.field.secretPlaceholder")
}
/>
</Field>
<Field label={t("channels.field.encryptKey")}>
<KeyInput
value={config._encrypt_key ?? ""}
onChange={(v) => onChange("_encrypt_key", v)}
placeholder={
isEdit && config.encrypt_key
? t("channels.field.secretPlaceholderSet")
: t("channels.field.secretPlaceholder")
}
/>
</Field>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.field.allowFromHint")}
>
<Input
value={(config.allow_from ?? []).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</AdvancedSection>
</div>
)
}

View file

@ -0,0 +1,132 @@
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import { Field, KeyInput } from "@/components/models/shared-form"
interface GenericFormProps {
channelName: string
config: Record<string, any>
onChange: (key: string, value: any) => void
isEdit: boolean
}
// Secret field names that should use masked input.
const SECRET_FIELDS = new Set([
"token",
"app_secret",
"client_secret",
"corp_secret",
"channel_secret",
"channel_access_token",
"access_token",
"bot_token",
"app_token",
"encoding_aes_key",
"encrypt_key",
"verification_token",
])
// Fields to skip in the generic form (handled by enabled toggle or internal).
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
// Fields that are objects/nested — show as JSON or skip.
const OBJECT_FIELDS = new Set([
"group_trigger",
"typing",
"placeholder",
"allow_from",
])
function formatLabel(key: string): string {
return key
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
export function GenericForm({
config,
onChange,
isEdit,
}: GenericFormProps) {
const { t } = useTranslation()
const fields = Object.keys(config).filter(
(k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && !OBJECT_FIELDS.has(k),
)
return (
<div className="space-y-5">
{fields.map((key) => {
if (SECRET_FIELDS.has(key)) {
const editKey = `_${key}`
return (
<Field
key={key}
label={formatLabel(key)}
hint={
isEdit && config[key]
? t("channels.field.secretHintSet")
: undefined
}
>
<KeyInput
value={config[editKey] ?? ""}
onChange={(v) => onChange(editKey, v)}
placeholder={
isEdit && config[key]
? t("channels.field.secretPlaceholderSet")
: ""
}
/>
</Field>
)
}
const value = config[key]
if (typeof value === "boolean") {
return null // Booleans are less common in generic; skip for now
}
return (
<Field key={key} label={formatLabel(key)}>
<Input
value={String(value ?? "")}
onChange={(e) => {
// Attempt to preserve number types
const v = e.target.value
if (typeof config[key] === "number") {
onChange(key, v === "" ? 0 : Number(v))
} else {
onChange(key, v)
}
}}
/>
</Field>
)
})}
{/* Allow From field */}
{config.allow_from !== undefined && (
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.field.allowFromHint")}
>
<Input
value={(config.allow_from ?? []).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
)}
</div>
)
}

View file

@ -0,0 +1,81 @@
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import {
AdvancedSection,
Field,
KeyInput,
} from "@/components/models/shared-form"
interface SlackFormProps {
config: Record<string, any>
onChange: (key: string, value: any) => void
isEdit: boolean
}
export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
const { t } = useTranslation()
return (
<div className="space-y-5">
<Field
label={t("channels.field.botToken")}
hint={
isEdit && config.bot_token
? t("channels.field.secretHintSet")
: undefined
}
>
<KeyInput
value={config._bot_token ?? ""}
onChange={(v) => onChange("_bot_token", v)}
placeholder={
isEdit && config.bot_token
? t("channels.field.secretPlaceholderSet")
: "xoxb-xxxx"
}
/>
</Field>
<Field
label={t("channels.field.appToken")}
hint={
isEdit && config.app_token
? t("channels.field.secretHintSet")
: undefined
}
>
<KeyInput
value={config._app_token ?? ""}
onChange={(v) => onChange("_app_token", v)}
placeholder={
isEdit && config.app_token
? t("channels.field.secretPlaceholderSet")
: "xapp-xxxx"
}
/>
</Field>
<AdvancedSection>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.field.allowFromHint")}
>
<Input
value={(config.allow_from ?? []).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</AdvancedSection>
</div>
)
}

View file

@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import {
AdvancedSection,
Field,
KeyInput,
} from "@/components/models/shared-form"
interface TelegramFormProps {
config: Record<string, any>
onChange: (key: string, value: any) => void
isEdit: boolean
}
export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
const { t } = useTranslation()
return (
<div className="space-y-5">
<Field
label={t("channels.field.token")}
hint={
isEdit && config.token
? t("channels.field.secretHintSet")
: undefined
}
>
<KeyInput
value={config._token ?? ""}
onChange={(v) => onChange("_token", v)}
placeholder={
isEdit && config.token
? t("channels.field.secretPlaceholderSet")
: t("channels.field.tokenPlaceholder")
}
/>
</Field>
<AdvancedSection>
<Field label={t("channels.field.baseUrl")}>
<Input
value={config.base_url ?? ""}
onChange={(e) => onChange("base_url", e.target.value)}
placeholder="https://api.telegram.org"
/>
</Field>
<Field
label={t("channels.field.proxy")}
hint={t("channels.field.proxyHint")}
>
<Input
value={config.proxy ?? ""}
onChange={(e) => onChange("proxy", e.target.value)}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.field.allowFromHint")}
>
<Input
value={(config.allow_from ?? []).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</AdvancedSection>
</div>
)
}

View file

@ -0,0 +1,137 @@
import { IconSearch } from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import type { ChannelInfo } from "@/api/channels"
import { getChannels, toggleChannel } from "@/api/channels"
import { ChannelCard } from "@/components/channels/channel-card"
import { EditChannelSheet } from "@/components/channels/edit-channel-sheet"
import { PageHeader } from "@/components/page-header"
import { Input } from "@/components/ui/input"
export function ChannelsPage() {
const { t } = useTranslation()
const [channels, setChannels] = useState<ChannelInfo[]>([])
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState("")
const [editingChannel, setEditingChannel] = useState<ChannelInfo | null>(null)
const [togglingChannel, setTogglingChannel] = useState<string | null>(null)
const [search, setSearch] = useState("")
const fetchChannels = useCallback(async () => {
try {
const data = await getChannels()
// Sort: enabled first, then configured, then alphabetical
const sorted = [...data.channels].sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
if (a.configured !== b.configured) return a.configured ? -1 : 1
return a.display_name.localeCompare(b.display_name)
})
setChannels(sorted)
setFetchError("")
} catch (e) {
setFetchError(
e instanceof Error ? e.message : t("channels.loadError"),
)
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
fetchChannels()
}, [fetchChannels])
const handleToggle = async (name: string, enabled: boolean) => {
setTogglingChannel(name)
try {
await toggleChannel(name, enabled)
await fetchChannels()
} catch {
// Refresh to show actual state
await fetchChannels()
} finally {
setTogglingChannel(null)
}
}
const filtered = search
? channels.filter(
(ch) =>
ch.display_name.toLowerCase().includes(search.toLowerCase()) ||
ch.name.toLowerCase().includes(search.toLowerCase()),
)
: channels
const enabledCount = channels.filter((ch) => ch.enabled).length
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.channels", "Channels")}
titleExtra={
!loading && channels.length > 0 ? (
<span className="text-muted-foreground text-sm font-normal">
{enabledCount} {t("channels.header.enabled")}
</span>
) : undefined
}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : fetchError ? (
<div className="flex flex-col items-center justify-center gap-2 py-20">
<p className="text-destructive text-sm">{fetchError}</p>
</div>
) : (
<div className="pb-8">
<p className="text-muted-foreground mb-4 text-sm">
{t("channels.description")}
</p>
{channels.length > 6 && (
<div className="relative mb-4">
<IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("channels.search")}
className="pl-9"
/>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((channel) => (
<ChannelCard
key={channel.name}
channel={channel}
onToggle={handleToggle}
onEdit={setEditingChannel}
toggling={togglingChannel === channel.name}
/>
))}
</div>
{filtered.length === 0 && search && (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("channels.noResults")}
</p>
)}
</div>
)}
</div>
<EditChannelSheet
channel={editingChannel}
open={editingChannel !== null}
onClose={() => setEditingChannel(null)}
onSaved={fetchChannels}
/>
</div>
)
}

View file

@ -0,0 +1,196 @@
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import type { ChannelInfo } from "@/api/channels"
import { updateChannel } from "@/api/channels"
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
interface EditChannelSheetProps {
channel: ChannelInfo | null
open: boolean
onClose: () => void
onSaved: () => void
}
// Map of secret config keys to their edit-buffer keys.
// When editing, we use _token etc. to avoid overwriting with masked values.
const SECRET_FIELD_MAP: Record<string, string> = {
token: "_token",
app_secret: "_app_secret",
client_secret: "_client_secret",
corp_secret: "_corp_secret",
channel_secret: "_channel_secret",
channel_access_token: "_channel_access_token",
access_token: "_access_token",
bot_token: "_bot_token",
app_token: "_app_token",
encoding_aes_key: "_encoding_aes_key",
encrypt_key: "_encrypt_key",
verification_token: "_verification_token",
}
function buildEditConfig(config: Record<string, any>): Record<string, any> {
const edit: Record<string, any> = { ...config }
// Initialize edit buffer keys for secrets as empty (user fills new values)
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
if (secretKey in config) {
edit[SECRET_FIELD_MAP[secretKey]] = ""
}
}
return edit
}
function buildSavePayload(
channel: ChannelInfo,
editConfig: Record<string, any>,
): Record<string, any> {
const payload: Record<string, any> = { enabled: channel.enabled }
for (const [key, value] of Object.entries(editConfig)) {
// Skip the edit-buffer underscore keys — we use them to populate real keys
if (key.startsWith("_")) continue
// For secret fields, use the edit buffer value (empty means preserve existing)
if (key in SECRET_FIELD_MAP) {
const editKey = SECRET_FIELD_MAP[key]
payload[key] = editConfig[editKey] ?? ""
} else {
payload[key] = value
}
}
return payload
}
export function EditChannelSheet({
channel,
open,
onClose,
onSaved,
}: EditChannelSheetProps) {
const { t } = useTranslation()
const [editConfig, setEditConfig] = useState<Record<string, any>>({})
const [saving, setSaving] = useState(false)
const [serverError, setServerError] = useState("")
useEffect(() => {
if (channel) {
setEditConfig(buildEditConfig(channel.config))
setServerError("")
}
}, [channel])
const handleChange = useCallback((key: string, value: any) => {
setEditConfig((prev) => ({ ...prev, [key]: value }))
}, [])
const handleSave = async () => {
if (!channel) return
setSaving(true)
setServerError("")
try {
await updateChannel(channel.name, buildSavePayload(channel, editConfig))
onSaved()
onClose()
} catch (e) {
setServerError(
e instanceof Error ? e.message : t("channels.edit.saveError"),
)
} finally {
setSaving(false)
}
}
const renderForm = () => {
if (!channel) return null
const isEdit = channel.configured
switch (channel.name) {
case "telegram":
return (
<TelegramForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
case "discord":
return (
<DiscordForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
case "slack":
return (
<SlackForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
case "feishu":
return (
<FeishuForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
default:
return (
<GenericForm
channelName={channel.name}
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
/>
)
}
}
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="right" className="flex flex-col sm:max-w-md">
<SheetHeader>
<SheetTitle>
{t("channels.edit.title", {
name: channel?.display_name ?? "",
})}
</SheetTitle>
<SheetDescription>
{t("channels.edit.description")}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-1 py-4">{renderForm()}</div>
{serverError && (
<p className="px-1 text-sm text-red-500">{serverError}</p>
)}
<SheetFooter className="gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("channels.edit.saving") : t("common.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View file

@ -5,6 +5,7 @@
"models": "Models", "models": "Models",
"credentials": "Credentials", "credentials": "Credentials",
"services": "Services", "services": "Services",
"channels": "Channels",
"config": "Config", "config": "Config",
"logs": "Logs" "logs": "Logs"
}, },
@ -215,6 +216,47 @@
"saveError": "Failed to save" "saveError": "Failed to save"
} }
}, },
"channels": {
"description": "Configure messaging channels to connect your AI agent to chat platforms.",
"loadError": "Failed to load channels",
"search": "Search channels...",
"noResults": "No channels match your search.",
"header": {
"enabled": "enabled"
},
"status": {
"configured": "Configured",
"unconfigured": "Not configured"
},
"action": {
"configure": "Configure"
},
"field": {
"token": "Bot Token",
"tokenPlaceholder": "Enter bot token",
"botToken": "Bot Token",
"appToken": "App Token",
"appId": "App ID",
"appSecret": "App Secret",
"verificationToken": "Verification Token",
"encryptKey": "Encrypt Key",
"baseUrl": "API Base URL",
"proxy": "HTTP Proxy",
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
"allowFrom": "Allow From",
"allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.",
"allowFromPlaceholder": "e.g. 123456, 789012",
"secretPlaceholder": "Enter secret",
"secretPlaceholderSet": "Leave blank to keep existing",
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
},
"edit": {
"title": "Configure {{name}}",
"description": "Set up credentials and options for this channel.",
"saveError": "Failed to save channel configuration",
"saving": "Saving..."
}
},
"pages": { "pages": {
"providers": { "providers": {
"description": "Manage AI model providers and configurations." "description": "Manage AI model providers and configurations."

View file

@ -5,6 +5,7 @@
"models": "模型", "models": "模型",
"credentials": "凭据", "credentials": "凭据",
"services": "服务", "services": "服务",
"channels": "频道",
"config": "配置", "config": "配置",
"logs": "日志" "logs": "日志"
}, },
@ -215,6 +216,47 @@
"saveError": "保存失败" "saveError": "保存失败"
} }
}, },
"channels": {
"description": "配置消息频道,将 AI 助手连接到各聊天平台。",
"loadError": "加载频道列表失败",
"search": "搜索频道...",
"noResults": "没有匹配的频道。",
"header": {
"enabled": "已启用"
},
"status": {
"configured": "已配置",
"unconfigured": "未配置"
},
"action": {
"configure": "配置"
},
"field": {
"token": "Bot Token",
"tokenPlaceholder": "输入 Bot Token",
"botToken": "Bot Token",
"appToken": "App Token",
"appId": "App ID",
"appSecret": "App Secret",
"verificationToken": "Verification Token",
"encryptKey": "Encrypt Key",
"baseUrl": "API Base URL",
"proxy": "HTTP 代理",
"proxyHint": "可选。例如 http://127.0.0.1:7890",
"allowFrom": "允许来源",
"allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。",
"allowFromPlaceholder": "例如 123456, 789012",
"secretPlaceholder": "输入密钥",
"secretPlaceholderSet": "留空保持原有值不变",
"secretHintSet": "已设置密钥,留空表示不修改。"
},
"edit": {
"title": "配置 {{name}}",
"description": "设置此频道的凭据和选项。",
"saveError": "保存频道配置失败",
"saving": "保存中..."
}
},
"pages": { "pages": {
"providers": { "providers": {
"description": "管理各个 AI 模型服务商的接入配置。" "description": "管理各个 AI 模型服务商的接入配置。"

View file

@ -14,6 +14,7 @@ import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs' import { Route as LogsRouteImport } from './routes/logs'
import { Route as CredentialsRouteImport } from './routes/credentials' import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config' import { Route as ConfigRouteImport } from './routes/config'
import { Route as ChannelsRouteImport } from './routes/channels'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
const ProvidersRoute = ProvidersRouteImport.update({ const ProvidersRoute = ProvidersRouteImport.update({
@ -41,6 +42,11 @@ const ConfigRoute = ConfigRouteImport.update({
path: '/config', path: '/config',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ChannelsRoute = ChannelsRouteImport.update({
id: '/channels',
path: '/channels',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@ -49,6 +55,7 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/channels': typeof ChannelsRoute
'/config': typeof ConfigRoute '/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute '/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
@ -57,6 +64,7 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/channels': typeof ChannelsRoute
'/config': typeof ConfigRoute '/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute '/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
@ -66,6 +74,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/channels': typeof ChannelsRoute
'/config': typeof ConfigRoute '/config': typeof ConfigRoute
'/credentials': typeof CredentialsRoute '/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
@ -76,16 +85,25 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/channels'
| '/config' | '/config'
| '/credentials' | '/credentials'
| '/logs' | '/logs'
| '/models' | '/models'
| '/providers' | '/providers'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/config' | '/credentials' | '/logs' | '/models' | '/providers' to:
| '/'
| '/channels'
| '/config'
| '/credentials'
| '/logs'
| '/models'
| '/providers'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/channels'
| '/config' | '/config'
| '/credentials' | '/credentials'
| '/logs' | '/logs'
@ -95,6 +113,7 @@ export interface FileRouteTypes {
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
ChannelsRoute: typeof ChannelsRoute
ConfigRoute: typeof ConfigRoute ConfigRoute: typeof ConfigRoute
CredentialsRoute: typeof CredentialsRoute CredentialsRoute: typeof CredentialsRoute
LogsRoute: typeof LogsRoute LogsRoute: typeof LogsRoute
@ -139,6 +158,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ConfigRouteImport preLoaderRoute: typeof ConfigRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/channels': {
id: '/channels'
path: '/channels'
fullPath: '/channels'
preLoaderRoute: typeof ChannelsRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@ -151,6 +177,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
ChannelsRoute: ChannelsRoute,
ConfigRoute: ConfigRoute, ConfigRoute: ConfigRoute,
CredentialsRoute: CredentialsRoute, CredentialsRoute: CredentialsRoute,
LogsRoute: LogsRoute, LogsRoute: LogsRoute,

View file

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