refactor(web): load channel configs without exposing secret values (#2277)

* refactor(web): load channel configs without exposing secret values

- add a dedicated channel config API that returns sanitized config plus
  configured secret metadata
- update channel config pages and forms to use secret presence for
  placeholders, validation, reset, and save behavior
- refresh the channel settings layout and clean up related i18n copy
- add backend tests for the new channel config endpoint

* fix(config): restore missing strings import
This commit is contained in:
wenjie
2026-04-02 19:09:33 +08:00
committed by GitHub
parent e075be6b10
commit dad5dcc30f
16 changed files with 1232 additions and 809 deletions
+1
View File
@@ -7,6 +7,7 @@ import (
"math/rand"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
+184
View File
@@ -3,6 +3,8 @@ package api
import (
"encoding/json"
"net/http"
"github.com/sipeed/picoclaw/pkg/config"
)
type channelCatalogItem struct {
@@ -30,9 +32,22 @@ var channelCatalog = []channelCatalogItem{
{Name: "irc", ConfigKey: "irc"},
}
type channelConfigResponse struct {
Config any `json:"config"`
ConfiguredSecrets []string `json:"configured_secrets"`
ConfigKey string `json:"config_key"`
Variant string `json:"variant,omitempty"`
}
type channelSecretPresence struct {
key string
configured bool
}
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig)
}
// handleListChannelCatalog returns the channels supported by backend.
@@ -44,3 +59,172 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques
"channels": channelCatalog,
})
}
// handleGetChannelConfig returns safe channel config plus secret presence metadata.
//
// GET /api/channels/{name}/config
func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
channelName := r.PathValue("name")
item, ok := findChannelCatalogItem(channelName)
if !ok {
http.Error(w, "Channel not found", http.StatusNotFound)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, "Failed to load config", http.StatusInternalServerError)
return
}
resp := buildChannelConfigResponse(cfg, item)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
for _, item := range channelCatalog {
if item.Name == name {
return item, true
}
}
return channelCatalogItem{}, false
}
func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse {
resp := channelConfigResponse{
ConfiguredSecrets: []string{},
ConfigKey: item.ConfigKey,
Variant: item.Variant,
}
switch item.Name {
case "weixin":
channelCfg := cfg.Channels.Weixin
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
)
channelCfg.Token = config.SecureString{}
resp.Config = channelCfg
case "telegram":
channelCfg := cfg.Channels.Telegram
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
)
channelCfg.Token = config.SecureString{}
resp.Config = channelCfg
case "discord":
channelCfg := cfg.Channels.Discord
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
)
channelCfg.Token = config.SecureString{}
resp.Config = channelCfg
case "slack":
channelCfg := cfg.Channels.Slack
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "bot_token", configured: channelCfg.BotToken.String() != ""},
channelSecretPresence{key: "app_token", configured: channelCfg.AppToken.String() != ""},
)
channelCfg.BotToken = config.SecureString{}
channelCfg.AppToken = config.SecureString{}
resp.Config = channelCfg
case "feishu":
channelCfg := cfg.Channels.Feishu
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
channelSecretPresence{key: "encrypt_key", configured: channelCfg.EncryptKey.String() != ""},
channelSecretPresence{key: "verification_token", configured: channelCfg.VerificationToken.String() != ""},
)
channelCfg.AppSecret = config.SecureString{}
channelCfg.EncryptKey = config.SecureString{}
channelCfg.VerificationToken = config.SecureString{}
resp.Config = channelCfg
case "dingtalk":
channelCfg := cfg.Channels.DingTalk
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "client_secret", configured: channelCfg.ClientSecret.String() != ""},
)
channelCfg.ClientSecret = config.SecureString{}
resp.Config = channelCfg
case "line":
channelCfg := cfg.Channels.LINE
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "channel_secret", configured: channelCfg.ChannelSecret.String() != ""},
channelSecretPresence{
key: "channel_access_token",
configured: channelCfg.ChannelAccessToken.String() != "",
},
)
channelCfg.ChannelSecret = config.SecureString{}
channelCfg.ChannelAccessToken = config.SecureString{}
resp.Config = channelCfg
case "qq":
channelCfg := cfg.Channels.QQ
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
)
channelCfg.AppSecret = config.SecureString{}
resp.Config = channelCfg
case "onebot":
channelCfg := cfg.Channels.OneBot
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
)
channelCfg.AccessToken = config.SecureString{}
resp.Config = channelCfg
case "wecom":
channelCfg := cfg.Channels.WeCom
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "secret", configured: channelCfg.Secret.String() != ""},
)
channelCfg.Secret = config.SecureString{}
resp.Config = channelCfg
case "whatsapp", "whatsapp_native":
resp.Config = cfg.Channels.WhatsApp
case "pico":
channelCfg := cfg.Channels.Pico
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
)
channelCfg.Token = config.SecureString{}
resp.Config = channelCfg
case "maixcam":
resp.Config = cfg.Channels.MaixCam
case "matrix":
channelCfg := cfg.Channels.Matrix
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
)
channelCfg.AccessToken = config.SecureString{}
resp.Config = channelCfg
case "irc":
channelCfg := cfg.Channels.IRC
resp.ConfiguredSecrets = collectConfiguredSecrets(
channelSecretPresence{key: "password", configured: channelCfg.Password.String() != ""},
channelSecretPresence{key: "nickserv_password", configured: channelCfg.NickServPassword.String() != ""},
channelSecretPresence{key: "sasl_password", configured: channelCfg.SASLPassword.String() != ""},
)
channelCfg.Password = config.SecureString{}
channelCfg.NickServPassword = config.SecureString{}
channelCfg.SASLPassword = config.SecureString{}
resp.Config = channelCfg
default:
resp.Config = map[string]any{}
}
return resp
}
func collectConfiguredSecrets(secrets ...channelSecretPresence) []string {
configured := make([]string, 0, len(secrets))
for _, secret := range secrets {
if secret.configured {
configured = append(configured, secret.key)
}
}
return configured
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.Channels.Feishu.Enabled = true
cfg.Channels.Feishu.AppID = "cli_test_app"
cfg.Channels.Feishu.AppSecret = *config.NewSecureString("feishu-secret-from-security")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(
"GET /api/channels/feishu/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
if strings.Contains(rec.Body.String(), "feishu-secret-from-security") {
t.Fatalf("response leaked secret value: %s", rec.Body.String())
}
var resp struct {
Config map[string]any `json:"config"`
ConfiguredSecrets []string `json:"configured_secrets"`
ConfigKey string `json:"config_key"`
Variant string `json:"variant"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got := resp.ConfigKey; got != "feishu" {
t.Fatalf("config_key = %q, want %q", got, "feishu")
}
if got := resp.Config["app_id"]; got != "cli_test_app" {
t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app")
}
if _, exists := resp.Config["app_secret"]; exists {
t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"])
}
if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" {
t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets)
}
}
func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
+15 -2
View File
@@ -1,5 +1,3 @@
// API client for channels navigation and channel-specific config flows.
import { launcherFetch } from "@/api/http"
export type ChannelConfig = Record<string, unknown>
@@ -12,6 +10,13 @@ export interface SupportedChannel {
variant?: string
}
export interface ChannelConfigResponse {
config: ChannelConfig
configured_secrets: string[]
config_key: string
variant?: string
}
interface ChannelsCatalogResponse {
channels: SupportedChannel[]
}
@@ -54,6 +59,14 @@ export async function getAppConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config")
}
export async function getChannelConfig(
channelName: string,
): Promise<ChannelConfigResponse> {
return request<ChannelConfigResponse>(
`/api/channels/${encodeURIComponent(channelName)}/config`,
)
}
export async function patchAppConfig(
patch: Record<string, unknown>,
): Promise<ConfigActionResponse> {
@@ -0,0 +1,101 @@
import type { ChannelConfig } from "@/api/channels"
export const SECRET_FIELD_MAP = {
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",
secret: "_secret",
password: "_password",
nickserv_password: "_nickserv_password",
sasl_password: "_sasl_password",
} as const
const CHANNEL_SECRET_FIELDS: Record<string, string[]> = {
weixin: ["token"],
telegram: ["token"],
discord: ["token"],
slack: ["bot_token", "app_token"],
feishu: ["app_secret", "encrypt_key", "verification_token"],
dingtalk: ["client_secret"],
line: ["channel_secret", "channel_access_token"],
qq: ["app_secret"],
onebot: ["access_token"],
wecom: ["secret"],
pico: ["token"],
matrix: ["access_token"],
irc: ["password", "nickserv_password", "sasl_password"],
}
const SECRET_FIELD_SET = new Set(Object.keys(SECRET_FIELD_MAP))
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
export function isSecretField(key: string): boolean {
return SECRET_FIELD_SET.has(key)
}
export function buildEditConfig(
channelName: string,
config: ChannelConfig,
): ChannelConfig {
const edit: ChannelConfig = { ...config }
for (const key of CHANNEL_SECRET_FIELDS[channelName] ?? []) {
if (!(key in edit)) {
edit[key] = ""
}
const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP]
if (editKey) {
edit[editKey] = ""
}
}
return edit
}
export function hasConfiguredSecret(
configuredSecrets: readonly string[],
key: string,
): boolean {
return configuredSecrets.includes(key)
}
export function getFieldValueForValidation(
config: ChannelConfig,
configuredSecrets: readonly string[],
key: string,
): unknown {
const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP]
if (editKey) {
const incoming = asString(config[editKey]).trim()
if (incoming !== "") {
return incoming
}
if (hasConfiguredSecret(configuredSecrets, key)) {
return true
}
}
return config[key]
}
export function getSecretInputPlaceholder(
configuredSecrets: readonly string[],
key: string,
configuredPlaceholder: string,
fallback = "",
): string {
return hasConfiguredSecret(configuredSecrets, key)
? configuredPlaceholder
: fallback
}
@@ -1,14 +1,20 @@
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"
import { IconLoader2 } from "@tabler/icons-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
type ChannelConfig,
type SupportedChannel,
getAppConfig,
getChannelConfig,
getChannelsCatalog,
patchAppConfig,
} from "@/api/channels"
import {
SECRET_FIELD_MAP,
buildEditConfig,
getFieldValueForValidation,
isSecretField,
} from "@/components/channels/channel-config-fields"
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"
@@ -27,24 +33,6 @@ 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>
@@ -60,14 +48,6 @@ function asBool(value: unknown): boolean {
return value === true
}
function buildEditConfig(config: ChannelConfig): ChannelConfig {
const edit: ChannelConfig = { ...config }
for (const editKey of Object.values(SECRET_FIELD_MAP)) {
edit[editKey] = ""
}
return edit
}
function normalizeConfig(
channel: SupportedChannel,
rawConfig: ChannelConfig,
@@ -92,7 +72,7 @@ function buildSavePayload(
for (const [key, value] of Object.entries(editConfig)) {
if (key.startsWith("_")) continue
if (key === "enabled") continue
if (key in SECRET_FIELD_MAP) continue
if (isSecretField(key)) continue
payload[key] = value
}
@@ -103,8 +83,9 @@ function buildSavePayload(
payload[secretKey] = incoming
continue
}
if (secretKey in editConfig) {
payload[secretKey] = editConfig[secretKey]
const existing = asString(editConfig[secretKey]).trim()
if (existing !== "") {
payload[secretKey] = existing
}
}
@@ -121,51 +102,50 @@ function buildSavePayload(
function isConfigured(
channel: SupportedChannel,
config: ChannelConfig,
configuredSecrets: readonly string[],
): boolean {
const hasValue = (key: string) =>
!isMissingRequiredValue(
getFieldValueForValidation(config, configuredSecrets, key),
)
switch (channel.name) {
case "telegram":
return asString(config.token) !== ""
return hasValue("token")
case "discord":
return asString(config.token) !== ""
return hasValue("token")
case "slack":
return asString(config.bot_token) !== ""
return hasValue("bot_token")
case "feishu":
return (
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
)
return hasValue("app_id") && hasValue("app_secret")
case "dingtalk":
return (
asString(config.client_id) !== "" &&
asString(config.client_secret) !== ""
)
return hasValue("client_id") && hasValue("client_secret")
case "line":
return asString(config.channel_access_token) !== ""
return hasValue("channel_secret") && hasValue("channel_access_token")
case "qq":
return (
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
)
return hasValue("app_id") && hasValue("app_secret")
case "onebot":
return asString(config.ws_url) !== ""
return hasValue("ws_url")
case "weixin":
return asString(config.account_id) !== ""
return hasValue("account_id")
case "wecom":
return asString(config.bot_id) !== ""
return hasValue("bot_id")
case "whatsapp":
return asString(config.bridge_url) !== ""
return hasValue("bridge_url")
case "whatsapp_native":
return asBool(config.use_native)
case "pico":
return asString(config.token) !== ""
return hasValue("token")
case "maixcam":
return asString(config.host) !== ""
return hasValue("host")
case "matrix":
return (
asString(config.homeserver) !== "" &&
asString(config.user_id) !== "" &&
asString(config.access_token) !== ""
hasValue("homeserver") &&
hasValue("user_id") &&
hasValue("access_token")
)
case "irc":
return asString(config.server) !== ""
return hasValue("server")
default:
return false
}
@@ -245,21 +225,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const [channel, setChannel] = useState<SupportedChannel | null>(null)
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
const [enabled, setEnabled] = useState(false)
const loadData = useCallback(
async (silent = false) => {
if (!silent) setLoading(true)
try {
const [catalog, appConfig] = await Promise.all([
getChannelsCatalog(),
getAppConfig(),
])
const catalog = await getChannelsCatalog()
const matched =
catalog.channels.find((item) => item.name === channelName) ?? null
if (!matched) {
setChannel(null)
setBaseConfig({})
setEditConfig({})
setConfiguredSecrets([])
setEnabled(false)
setFetchError(
t("channels.page.notFound", {
name: channelName,
@@ -268,18 +250,20 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
return
}
const channelsConfig = asRecord(asRecord(appConfig).channels)
const raw = asRecord(channelsConfig[matched.config_key])
const channelConfig = await getChannelConfig(channelName)
const raw = asRecord(channelConfig.config)
const normalized = normalizeConfig(matched, raw)
setChannel(matched)
setBaseConfig(normalized)
setEditConfig(buildEditConfig(normalized))
setEditConfig(buildEditConfig(matched.name, normalized))
setConfiguredSecrets(channelConfig.configured_secrets ?? [])
setEnabled(asBool(normalized.enabled))
setFetchError("")
setServerError("")
setFieldErrors({})
} catch (e) {
setConfiguredSecrets([])
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
} finally {
if (!silent) setLoading(false)
@@ -307,9 +291,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
}, [channel, editConfig, enabled])
const configured = useMemo(() => {
if (!channel || !savePayload) return false
return isConfigured(channel, savePayload)
}, [channel, savePayload])
if (!channel) return false
return isConfigured(channel, editConfig, configuredSecrets)
}, [channel, configuredSecrets, editConfig])
const docsUrl = useMemo(() => {
if (!channel) return ""
@@ -362,7 +346,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
}, [])
const handleReset = () => {
setEditConfig(buildEditConfig(baseConfig))
if (!channel) return
setEditConfig(buildEditConfig(channel.name, baseConfig))
setEnabled(asBool(baseConfig.enabled))
setServerError("")
setFieldErrors({})
@@ -372,7 +357,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
if (!channel || !savePayload) return
const missingRequiredFields = requiredKeys.filter((key) =>
isMissingRequiredValue(savePayload[key]),
isMissingRequiredValue(
getFieldValueForValidation(editConfig, configuredSecrets, key),
),
)
if (missingRequiredFields.length > 0) {
const requiredFieldError = t("channels.validation.requiredField")
@@ -456,7 +443,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<TelegramForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
/>
)
@@ -465,7 +452,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<DiscordForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
/>
)
@@ -474,7 +461,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<SlackForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
/>
)
@@ -483,7 +470,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<FeishuForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
/>
)
@@ -510,7 +497,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<GenericForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
configuredSecrets={configuredSecrets}
hiddenKeys={[...hiddenKeys, "bot_id"]}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
@@ -522,7 +509,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<GenericForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
configuredSecrets={configuredSecrets}
hiddenKeys={hiddenKeys}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
@@ -536,19 +523,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<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
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>
)
}
/>
@@ -562,46 +547,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
{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", {
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>
{channel?.name === "weixin" && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<div className="flex items-start gap-3">
<IconAlertTriangle
size={18}
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
<div className="space-y-1">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
{t("channels.weixin.warningTitle")}
</p>
<p className="text-sm text-amber-700/90 dark:text-amber-300/90">
{t("channels.weixin.warningDesc")}
</p>
</div>
</div>
</div>
)}
<div className="w-full max-w-4xl space-y-6 pt-5">
{!hidesPageLevelEnableToggle && (
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
@@ -1,14 +1,15 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
interface DiscordFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
configuredSecrets: string[]
fieldErrors?: Record<string, string>
}
@@ -35,75 +36,83 @@ function asRecord(value: unknown): Record<string, unknown> {
export function DiscordForm({
config,
onChange,
isEdit,
configuredSecrets,
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")}
required
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
error={fieldErrors.token}
>
<KeyInput
value={asString(config._token)}
onChange={(v) => onChange("_token", v)}
placeholder={maskedSecretPlaceholder(
config.token,
t("channels.field.tokenPlaceholder"),
)}
/>
</Field>
<div className="space-y-6">
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field
label={t("channels.field.token")}
required
hint={t("channels.form.desc.token")}
error={fieldErrors.token}
>
<KeyInput
value={asString(config._token)}
onChange={(v) => onChange("_token", v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
"token",
t("channels.field.secretHintSet"),
t("channels.field.tokenPlaceholder"),
)}
/>
</Field>
</CardContent>
</Card>
<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>
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<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>
<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>
</CardContent>
</Card>
</div>
)
}
@@ -1,14 +1,15 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
interface FeishuFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
configuredSecrets: string[]
fieldErrors?: Record<string, string>
}
@@ -28,104 +29,111 @@ function asStringArray(value: unknown): string[] {
export function FeishuForm({
config,
onChange,
isEdit,
configuredSecrets,
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")}
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)}
placeholder="cli_xxxx"
/>
</Field>
<div className="space-y-6">
<Card className="py-3 shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<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)}
placeholder="cli_xxxx"
/>
</Field>
<Field
label={t("channels.field.appSecret")}
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={maskedSecretPlaceholder(
config.app_secret,
t("channels.field.secretPlaceholder"),
)}
/>
</Field>
<Field
label={t("channels.field.appSecret")}
required
hint={t("channels.form.desc.appSecret")}
error={fieldErrors.app_secret}
>
<KeyInput
value={asString(config._app_secret)}
onChange={(v) => onChange("_app_secret", v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
"app_secret",
t("channels.field.secretHintSet"),
t("channels.field.secretPlaceholder"),
)}
/>
</Field>
</CardContent>
</Card>
<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>
<SwitchCardField
label={t("channels.field.isLark")}
hint={t("channels.form.desc.isLark")}
checked={asBool(config.is_lark)}
onCheckedChange={(checked) => onChange("is_lark", checked)}
/>
<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>
<Card className="py-3 shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field
label={t("channels.field.verificationToken")}
hint={t("channels.form.desc.verificationToken")}
>
<KeyInput
value={asString(config._verification_token)}
onChange={(v) => onChange("_verification_token", v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
"verification_token",
t("channels.field.secretHintSet"),
t("channels.field.secretPlaceholder"),
)}
/>
</Field>
<Field
label={t("channels.field.encryptKey")}
hint={t("channels.form.desc.encryptKey")}
>
<KeyInput
value={asString(config._encrypt_key)}
onChange={(v) => onChange("_encrypt_key", v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
"encrypt_key",
t("channels.field.secretHintSet"),
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>
<SwitchCardField
label={t("channels.field.isLark")}
hint={t("channels.form.desc.isLark")}
checked={asBool(config.is_lark)}
onCheckedChange={(checked) => onChange("is_lark", checked)}
ariaLabel={t("channels.field.isLark")}
/>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -1,39 +1,23 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
getSecretInputPlaceholder,
isSecretField,
} from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
interface GenericFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
configuredSecrets?: string[]
hiddenKeys?: string[]
requiredKeys?: string[]
fieldErrors?: Record<string, string>
}
// 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",
"secret",
"password",
"nickserv_password",
"sasl_password",
])
// Fields to skip in the generic form (handled by enabled toggle or internal).
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
@@ -83,7 +67,7 @@ function asBool(value: unknown): boolean {
export function GenericForm({
config,
onChange,
isEdit,
configuredSecrets = [],
hiddenKeys = [],
requiredKeys = [],
fieldErrors = {},
@@ -96,7 +80,7 @@ export function GenericForm({
const placeholderConfig = asRecord(config.placeholder)
const placeholderEnabled = asBool(placeholderConfig.enabled)
const fields = Object.keys(config).filter(
const rawFields = Object.keys(config).filter(
(k) =>
!k.startsWith("_") &&
!SKIP_FIELDS.has(k) &&
@@ -160,231 +144,291 @@ export function GenericForm({
)
}
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)}
required={isRequired}
hint={`${buildHint(key)}${extraHint}`}
error={fieldErrors[key]}
>
<KeyInput
value={asString(config[editKey])}
onChange={(v) => onChange(editKey, v)}
placeholder={maskedSecretPlaceholder(config[key])}
/>
</Field>
)
}
const value = config[key]
if (typeof value === "boolean") {
return (
<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)}
required={isRequired}
hint={buildHint(key)}
error={fieldErrors[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 && !hiddenFieldSet.has("allow_from") && (
const renderField = (key: string) => {
const isRequired = requiredFieldSet.has(key)
if (isSecretField(key)) {
const editKey = `_${key}`
return (
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}
key={key}
label={formatLabel(key)}
required={isRequired}
hint={buildHint(key)}
error={fieldErrors[key]}
>
<KeyInput
value={asString(config[editKey])}
onChange={(v) => onChange(editKey, v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
key,
t("channels.field.secretHintSet"),
t("channels.field.secretPlaceholder"),
)}
/>
</Field>
)
}
const value = config[key]
if (typeof value === "boolean") {
return (
<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(config.allow_from).join(", ")}
value={asStringArray(value).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
key,
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
)}
)
}
{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)
return (
<Field
key={key}
label={formatLabel(key)}
required={isRequired}
hint={buildHint(key)}
error={fieldErrors[key]}
>
<Input
value={String(value ?? "")}
onChange={(e) => {
const v = e.target.value
if (typeof config[key] === "number") {
onChange(key, v === "" ? 0 : Number(v))
} else {
onChange(key, v)
}
ariaLabel={formatLabel("allow_token_query")}
/>
)}
{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")}
}}
/>
</Field>
)
}
const isBasicField = (key: string) => {
if (requiredFieldSet.has(key)) return true
if (
key.endsWith("id") ||
key.endsWith("token") ||
key.endsWith("secret") ||
key.endsWith("url") ||
key === "server" ||
key === "host" ||
key === "port"
) {
return true
}
return false
}
const basicFields = rawFields.filter(isBasicField)
const advancedFields = rawFields.filter((key) => !isBasicField(key))
const hasAdvancedContent =
advancedFields.length > 0 ||
(config.allow_from !== undefined && !hiddenFieldSet.has("allow_from")) ||
(config.allow_origins !== undefined &&
!hiddenFieldSet.has("allow_origins")) ||
(config.allow_token_query !== undefined &&
!hiddenFieldSet.has("allow_token_query")) ||
(config.group_trigger !== undefined &&
!hiddenFieldSet.has("group_trigger")) ||
(config.typing !== undefined && !hiddenFieldSet.has("typing")) ||
(config.placeholder !== undefined && !hiddenFieldSet.has("placeholder"))
return (
<div className="space-y-6">
{basicFields.length > 0 && (
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
{basicFields.map(renderField)}
</CardContent>
</Card>
)}
{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,
})
{hasAdvancedContent && (
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
{advancedFields.map(renderField)}
{config.allow_from !== undefined &&
!hiddenFieldSet.has("allow_from") && (
<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>
)}
{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") && (
<div>
<SwitchCardField
label={formatLabel("allow_token_query")}
hint={buildHint("allow_token_query")}
checked={asBool(config.allow_token_query)}
onCheckedChange={(checked) =>
onChange("allow_token_query", checked)
}
ariaLabel={formatLabel("allow_token_query")}
/>
</div>
)}
{config.group_trigger !== undefined &&
!hiddenFieldSet.has("group_trigger") && (
<>
<div>
<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")}
/>
</div>
<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") && (
<div>
<SwitchCardField
label={t("channels.field.typingEnabled")}
hint={t("channels.form.desc.typingEnabled")}
checked={asBool(typingConfig.enabled)}
onCheckedChange={(checked) =>
onChange("typing", { ...typingConfig, enabled: checked })
}
placeholder={t("channels.field.placeholderText")}
aria-label={t("channels.field.placeholderText")}
ariaLabel={t("channels.field.typingEnabled")}
/>
</div>
)}
</SwitchCardField>
)}
{config.placeholder !== undefined &&
!hiddenFieldSet.has("placeholder") && (
<div>
<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>
)}
</CardContent>
</Card>
)}
</div>
)
}
@@ -1,14 +1,15 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
interface SlackFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
configuredSecrets: string[]
fieldErrors?: Record<string, string>
}
@@ -24,63 +25,73 @@ function asStringArray(value: unknown): string[] {
export function SlackForm({
config,
onChange,
isEdit,
configuredSecrets,
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")}
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={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
/>
</Field>
<div className="space-y-6">
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field
label={t("channels.field.botToken")}
required
hint={t("channels.form.desc.botToken")}
error={fieldErrors.bot_token}
>
<KeyInput
value={asString(config._bot_token)}
onChange={(v) => onChange("_bot_token", v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
"bot_token",
t("channels.field.secretHintSet"),
"xoxb-xxxx",
)}
/>
</Field>
<Field
label={t("channels.field.appToken")}
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
>
<KeyInput
value={asString(config._app_token)}
onChange={(v) => onChange("_app_token", v)}
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
/>
</Field>
<Field
label={t("channels.field.appToken")}
hint={t("channels.form.desc.appToken")}
>
<KeyInput
value={asString(config._app_token)}
onChange={(v) => onChange("_app_token", v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
"app_token",
t("channels.field.secretHintSet"),
"xapp-xxxx",
)}
/>
</Field>
</CardContent>
</Card>
<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>
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<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>
</CardContent>
</Card>
</div>
)
}
@@ -1,14 +1,15 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
interface TelegramFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
configuredSecrets: string[]
fieldErrors?: Record<string, string>
}
@@ -35,113 +36,124 @@ function asBool(value: unknown): boolean {
export function TelegramForm({
config,
onChange,
isEdit,
configuredSecrets,
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")}
required
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
error={fieldErrors.token}
>
<KeyInput
value={asString(config._token)}
onChange={(v) => onChange("_token", v)}
placeholder={maskedSecretPlaceholder(
config.token,
t("channels.field.tokenPlaceholder"),
)}
/>
</Field>
<div className="space-y-6">
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field
label={t("channels.field.token")}
required
hint={t("channels.form.desc.token")}
error={fieldErrors.token}
>
<KeyInput
value={asString(config._token)}
onChange={(v) => onChange("_token", v)}
placeholder={getSecretInputPlaceholder(
configuredSecrets,
"token",
t("channels.field.secretHintSet"),
t("channels.field.tokenPlaceholder"),
)}
/>
</Field>
<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">
<Field
label={t("channels.field.baseUrl")}
hint={t("channels.form.desc.baseUrl")}
>
<Input
value={asString(placeholderConfig.text)}
value={asString(config.base_url)}
onChange={(e) => onChange("base_url", e.target.value)}
placeholder="https://api.telegram.org"
/>
</Field>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<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("placeholder", {
...placeholderConfig,
text: e.target.value,
})
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.placeholderText")}
aria-label={t("channels.field.placeholderText")}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
<div>
<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")}
/>
</div>
)}
</SwitchCardField>
<div>
<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>
</CardContent>
</Card>
</div>
)
}
@@ -11,6 +11,13 @@ import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Switch } from "@/components/ui/switch"
type BindingState =
@@ -329,39 +336,32 @@ export function WecomForm({
}
return (
<div className="space-y-5">
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{isBound
? t("channels.wecom.enableDesc")
: t("channels.wecom.enableBindFirst")}
</p>
</div>
<div className="space-y-6">
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
<p className="text-sm font-medium">{t("channels.page.enableLabel")}</p>
<div className="flex flex-col items-end gap-2">
<Switch
checked={enabled}
disabled={!isBound || toggleSaving}
onCheckedChange={(checked) => void handleEnabledChange(checked)}
/>
{toggleError && (
<p className="text-destructive max-w-60 text-right text-xs leading-normal">
{toggleError}
</p>
)}
</div>
{toggleError && (
<p className="text-destructive mt-2 text-sm">{toggleError}</p>
)}
</div>
<div className="border-border/60 bg-muted/30 rounded-xl border">
<div className="border-border/60 border-b px-4 py-3">
<p className="text-sm font-medium">{t("channels.wecom.bindTitle")}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{t("channels.wecom.bindDesc")}
</p>
</div>
{renderBindSection()}
</div>
<Card className="shadow-sm">
<CardHeader className="border-border/60 border-b px-6">
<CardTitle className="text-foreground text-sm font-medium">
{t("channels.wecom.bindTitle")}
</CardTitle>
<CardDescription>{t("channels.wecom.bindDesc")}</CardDescription>
</CardHeader>
<CardContent className="p-0">{renderBindSection()}</CardContent>
</Card>
</div>
)
}
@@ -12,6 +12,13 @@ import type { ChannelConfig } from "@/api/channels"
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
import { Field } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
type BindingState =
@@ -301,51 +308,50 @@ export function WeixinForm({
}
return (
<div className="space-y-5">
{/* QR Bind Section */}
<div className="border-border/60 bg-muted/30 rounded-xl border">
<div className="border-border/60 border-b px-4 py-3">
<p className="text-sm font-medium">
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="border-border/60 border-b px-6">
<CardTitle className="text-foreground text-sm font-medium">
{t("channels.weixin.bindTitle")}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{t("channels.weixin.bindDesc")}
</p>
</div>
{renderBindSection()}
</div>
</CardTitle>
<CardDescription>{t("channels.weixin.bindDesc")}</CardDescription>
</CardHeader>
<CardContent className="p-0">{renderBindSection()}</CardContent>
</Card>
{/* allow_from */}
<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>
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<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>
{/* proxy */}
<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://localhost:7890"
/>
</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://localhost:7890"
/>
</Field>
</CardContent>
</Card>
</div>
)
}
+32 -15
View File
@@ -34,23 +34,28 @@ export function Field({
}: FieldProps) {
if (layout === "setting-row") {
return (
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
<div className="max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]">
<FieldLabel>
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
<div className="w-full min-w-0">
<FieldLabel className="leading-relaxed break-words whitespace-normal">
{label}
{required && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
{hint && (
<FieldDescription className="text-xs leading-normal break-words">
<FieldDescription className="mt-1 text-xs leading-relaxed break-words whitespace-normal">
{hint}
</FieldDescription>
)}
</div>
<div className={cn("w-full md:justify-self-center", controlClassName)}>
<div
className={cn(
"w-full md:max-w-[28rem] md:justify-self-end",
controlClassName,
)}
>
{children}
</div>
{error && (
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
{error}
</FieldDescription>
)}
@@ -125,6 +130,7 @@ interface SwitchCardFieldProps {
disabled?: boolean
children?: ReactNode
layout?: FieldLayout
transparent?: boolean
}
export function SwitchCardField({
@@ -137,19 +143,22 @@ export function SwitchCardField({
disabled,
children,
layout = "default",
transparent,
}: SwitchCardFieldProps) {
if (layout === "setting-row") {
return (
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
<div className="max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]">
<p className="text-sm font-medium">{label}</p>
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
<div className="w-full min-w-0">
<p className="text-sm leading-relaxed font-medium break-words whitespace-normal">
{label}
</p>
{hint && (
<p className="text-muted-foreground mt-0.5 text-xs leading-normal break-words">
<p className="text-muted-foreground mt-1 text-xs leading-relaxed break-words whitespace-normal">
{hint}
</p>
)}
</div>
<div className="flex items-center md:justify-self-center">
<div className="flex items-center md:justify-self-end">
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
@@ -157,9 +166,13 @@ export function SwitchCardField({
aria-label={ariaLabel ?? label}
/>
</div>
{children && <div className="md:col-start-2">{children}</div>}
{children && (
<div className="mt-1 flex w-full justify-end md:col-start-2">
<div className="w-full md:max-w-[28rem]">{children}</div>
</div>
)}
{error && (
<p className="text-destructive text-xs leading-normal md:col-start-2">
<p className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
{error}
</p>
)}
@@ -168,7 +181,11 @@ export function SwitchCardField({
}
return (
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
<div
className={cn(
transparent ? "py-1" : "border-border/60 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>
@@ -185,7 +202,7 @@ export function SwitchCardField({
aria-label={ariaLabel ?? label}
/>
</div>
{children && <div className="mt-3">{children}</div>}
{children && <div className="mt-4">{children}</div>}
{error && (
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
)}
-9
View File
@@ -244,10 +244,6 @@
},
"channels": {
"loadError": "Failed to load channels",
"edit": "Configure {{name}}",
"status": {
"configured": "Configured"
},
"name": {
"telegram": "Telegram",
"discord": "Discord",
@@ -267,8 +263,6 @@
"weixin": "WeChat"
},
"weixin": {
"warningTitle": "Testing phase, use with caution",
"warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.",
"bindTitle": "WeChat Account Binding",
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
"bind": "Bind WeChat",
@@ -286,8 +280,6 @@
"wecom": {
"bindTitle": "WeCom Binding",
"bindDesc": "Scan the QR code with WeCom to bind your AI Bot.",
"enableDesc": "Once bound, you can enable or disable the channel here.",
"enableBindFirst": "Bind the bot first, then enable the channel.",
"bind": "Bind WeCom",
"rebind": "Re-bind",
"bound": "WeCom Bound",
@@ -329,7 +321,6 @@
"notFound": "Channel \"{{name}}\" is not supported.",
"saveSuccess": "Channel configuration saved.",
"saveError": "Failed to save channel configuration",
"enabled": "enabled",
"docLink": "Documentation",
"enableLabel": "Enable channel",
"restartRequiredTitle": "Gateway restart required",
+52 -61
View File
@@ -244,10 +244,6 @@
},
"channels": {
"loadError": "加载频道列表失败",
"edit": "配置 {{name}}",
"status": {
"configured": "已配置"
},
"name": {
"telegram": "Telegram",
"discord": "Discord",
@@ -267,8 +263,6 @@
"weixin": "微信"
},
"weixin": {
"warningTitle": "测试阶段,请谨慎使用",
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
"bindTitle": "微信账号绑定",
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
"bind": "绑定微信",
@@ -286,8 +280,6 @@
"wecom": {
"bindTitle": "企业微信绑定",
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
"enableDesc": "绑定后可在这里直接启用或停用频道。",
"enableBindFirst": "请先完成绑定,然后再启用频道。",
"bind": "绑定企业微信",
"rebind": "重新绑定",
"bound": "企业微信已绑定",
@@ -323,13 +315,12 @@
"allowOrigins": "允许来源域名",
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
"secretPlaceholder": "输入密钥",
"secretHintSet": "已设置密钥,留空表示不修改"
"secretHintSet": "配置已保存,留空表示不修改"
},
"page": {
"notFound": "不支持频道“{{name}}”。",
"saveSuccess": "频道配置已保存。",
"saveError": "保存频道配置失败",
"enabled": "已启用",
"docLink": "配置文档",
"enableLabel": "启用频道",
"restartRequiredTitle": "需要重启服务",
@@ -337,58 +328,58 @@
},
"form": {
"desc": {
"token": "机器人访问令牌,用于连接平台 API",
"botToken": "Bot Token,用于发送与接收消息",
"token": "机器人访问令牌,用于连接平台 API",
"botToken": "Bot Token,用于发送与接收消息",
"appToken": "App Token,用于 Socket 模式连接。",
"appId": "应用唯一标识,用于平台鉴权",
"appSecret": "应用密钥,用于请求签名和鉴权",
"verificationToken": "事件回调验证令牌",
"encryptKey": "消息加密密钥,用于解密回调内容",
"baseUrl": "平台 API 地址,默认使用官方地址",
"proxy": "HTTP 代理地址,用于网络访问",
"mentionOnly": "在群聊中仅当明确提及时才响应",
"typingEnabled": "在生成回复时显示“正在输入”状态",
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn",
"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 扩展能力列表",
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传",
"genericField": "用于配置{{field}}"
"appId": "应用唯一标识,用于平台鉴权",
"appSecret": "应用密钥,用于请求签名和鉴权",
"verificationToken": "事件回调验证令牌",
"encryptKey": "消息加密密钥,用于解密回调内容",
"baseUrl": "平台 API 地址,默认使用官方地址",
"proxy": "HTTP 代理地址,用于网络访问",
"mentionOnly": "在群聊中仅当明确提及时才响应",
"typingEnabled": "在生成回复时显示“正在输入”状态",
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn",
"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 扩展能力列表",
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传",
"genericField": "用于配置{{field}}"
}
},
"validation": {