Merge pull request #1 from KristjanKruusRIA/main
Sync upstream changes into my-custom-setup
This commit is contained in:
commit
ec562af936
15 changed files with 556 additions and 97 deletions
|
|
@ -636,8 +636,9 @@ func (m *mockCustomTool) Description() string {
|
||||||
|
|
||||||
func (m *mockCustomTool) Parameters() map[string]any {
|
func (m *mockCustomTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{},
|
"properties": map[string]any{},
|
||||||
|
"additionalProperties": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1395,6 +1395,18 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Load existing security config and merge with migrated one to prevent data loss
|
||||||
|
existingSec, secErr := loadSecurityConfig(securityPath(path))
|
||||||
|
if secErr != nil {
|
||||||
|
logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr})
|
||||||
|
}
|
||||||
|
if existingSec != nil && cfg.security != nil {
|
||||||
|
cfg.security = mergeSecurityConfig(existingSec, cfg.security)
|
||||||
|
// Re-apply the merged security config to update all channels and models
|
||||||
|
if err = applySecurityConfig(cfg, cfg.security); err != nil {
|
||||||
|
logger.WarnF("failed to re-apply merged security config during migration", map[string]any{"error": err})
|
||||||
|
}
|
||||||
|
}
|
||||||
defer func(cfg *Config) {
|
defer func(cfg *Config) {
|
||||||
_ = SaveConfig(path, cfg)
|
_ = SaveConfig(path, cfg)
|
||||||
}(cfg)
|
}(cfg)
|
||||||
|
|
|
||||||
|
|
@ -566,3 +566,118 @@ func TestMigration_Integration_ModelNameField(t *testing.T) {
|
||||||
t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
|
t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMigration_PreservesExistingSecurityConfig tests that when migrating from v0 to v1,
|
||||||
|
// existing .security.yml values (e.g., loaded from environment variables) are preserved
|
||||||
|
// and not overwritten by empty values from the legacy config.
|
||||||
|
func TestMigration_PreservesExistingSecurityConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
securityPath := filepath.Join(tmpDir, ".security.yml")
|
||||||
|
|
||||||
|
// Create a legacy config (version 0) with model_list and channel config
|
||||||
|
// The model_list doesn't have api_keys, they should come from existing .security.yml
|
||||||
|
legacyConfig := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "openai",
|
||||||
|
"model": "openai/gpt-4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gateway": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 18790
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {"enabled": true}
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval": 30
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Create an existing .security.yml with values that might come from env vars
|
||||||
|
existingSecurity := `model_list:
|
||||||
|
openai:0:
|
||||||
|
api_keys:
|
||||||
|
- sk-existing-key-from-env
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: existing-telegram-token-from-env
|
||||||
|
discord:
|
||||||
|
token: existing-discord-token-from-env
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- existing-brave-key
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
|
||||||
|
t.Fatalf("Failed to write legacy config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(securityPath, []byte(existingSecurity), 0o600); err != nil {
|
||||||
|
t.Fatalf("Failed to write existing security config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the config - this should trigger migration
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the migrated config has the existing security values
|
||||||
|
// Telegram token should be preserved
|
||||||
|
if cfg.Channels.Telegram.Token() != "existing-telegram-token-from-env" {
|
||||||
|
t.Errorf("Telegram token was overwritten: got %q, want %q",
|
||||||
|
cfg.Channels.Telegram.Token(), "existing-telegram-token-from-env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord token should be preserved (even though legacy config didn't have it)
|
||||||
|
if cfg.Channels.Discord.Token() != "existing-discord-token-from-env" {
|
||||||
|
t.Errorf("Discord token was overwritten: got %q, want %q",
|
||||||
|
cfg.Channels.Discord.Token(), "existing-discord-token-from-env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model API key should be preserved
|
||||||
|
if cfg.ModelList[0].APIKey() != "sk-existing-key-from-env" {
|
||||||
|
t.Errorf("Model API key was overwritten: got %q, want %q",
|
||||||
|
cfg.ModelList[0].APIKey(), "sk-existing-key-from-env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brave API key should be preserved
|
||||||
|
if cfg.Tools.Web.Brave.APIKey() != "existing-brave-key" {
|
||||||
|
t.Errorf("Brave API key was overwritten: got %q, want %q",
|
||||||
|
cfg.Tools.Web.Brave.APIKey(), "existing-brave-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the security config from disk to verify it wasn't corrupted
|
||||||
|
reloadedSec, err := loadSecurityConfig(securityPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to reload security config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reloadedSec.Channels.Telegram == nil ||
|
||||||
|
reloadedSec.Channels.Telegram.Token != "existing-telegram-token-from-env" {
|
||||||
|
t.Error("Telegram token not preserved in .security.yml file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if reloadedSec.Channels.Discord == nil || reloadedSec.Channels.Discord.Token != "existing-discord-token-from-env" {
|
||||||
|
t.Error("Discord token not preserved in .security.yml file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,142 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
|
||||||
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'.
|
||||||
|
// This is used during config migration to preserve existing security data while adding new entries.
|
||||||
|
func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig {
|
||||||
|
if existing == nil {
|
||||||
|
return normalizeSecurityConfig(newer)
|
||||||
|
}
|
||||||
|
if newer == nil {
|
||||||
|
return normalizeSecurityConfig(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := normalizeSecurityConfig(nil)
|
||||||
|
|
||||||
|
// Merge ModelList: prefer newer if it has keys, otherwise use existing
|
||||||
|
for k, v := range existing.ModelList {
|
||||||
|
result.ModelList[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range newer.ModelList {
|
||||||
|
if len(v.APIKeys) > 0 {
|
||||||
|
result.ModelList[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge Channels
|
||||||
|
if existing.Channels != nil {
|
||||||
|
result.Channels = existing.Channels
|
||||||
|
}
|
||||||
|
if newer.Channels != nil {
|
||||||
|
if result.Channels == nil {
|
||||||
|
result.Channels = &ChannelsSecurity{}
|
||||||
|
}
|
||||||
|
mergeChannelsSecurity(result.Channels, newer.Channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge Web
|
||||||
|
if existing.Web != nil {
|
||||||
|
result.Web = existing.Web
|
||||||
|
}
|
||||||
|
if newer.Web != nil {
|
||||||
|
if result.Web == nil {
|
||||||
|
result.Web = &WebToolsSecurity{}
|
||||||
|
}
|
||||||
|
mergeWebToolsSecurity(result.Web, newer.Web)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge Skills
|
||||||
|
if existing.Skills != nil {
|
||||||
|
result.Skills = existing.Skills
|
||||||
|
}
|
||||||
|
if newer.Skills != nil {
|
||||||
|
if result.Skills == nil {
|
||||||
|
result.Skills = &SkillsSecurity{}
|
||||||
|
}
|
||||||
|
mergeSkillsSecurity(result.Skills, newer.Skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeChannelsSecurity(dst, src *ChannelsSecurity) {
|
||||||
|
if src.Telegram != nil && src.Telegram.Token != "" {
|
||||||
|
dst.Telegram = src.Telegram
|
||||||
|
}
|
||||||
|
if src.Feishu != nil &&
|
||||||
|
(src.Feishu.AppSecret != "" || src.Feishu.EncryptKey != "" || src.Feishu.VerificationToken != "") {
|
||||||
|
dst.Feishu = src.Feishu
|
||||||
|
}
|
||||||
|
if src.Discord != nil && src.Discord.Token != "" {
|
||||||
|
dst.Discord = src.Discord
|
||||||
|
}
|
||||||
|
if src.Weixin != nil && src.Weixin.Token != "" {
|
||||||
|
dst.Weixin = src.Weixin
|
||||||
|
}
|
||||||
|
if src.QQ != nil && src.QQ.AppSecret != "" {
|
||||||
|
dst.QQ = src.QQ
|
||||||
|
}
|
||||||
|
if src.DingTalk != nil && src.DingTalk.ClientSecret != "" {
|
||||||
|
dst.DingTalk = src.DingTalk
|
||||||
|
}
|
||||||
|
if src.Slack != nil && (src.Slack.BotToken != "" || src.Slack.AppToken != "") {
|
||||||
|
dst.Slack = src.Slack
|
||||||
|
}
|
||||||
|
if src.Matrix != nil && src.Matrix.AccessToken != "" {
|
||||||
|
dst.Matrix = src.Matrix
|
||||||
|
}
|
||||||
|
if src.LINE != nil && (src.LINE.ChannelSecret != "" || src.LINE.ChannelAccessToken != "") {
|
||||||
|
dst.LINE = src.LINE
|
||||||
|
}
|
||||||
|
if src.OneBot != nil && src.OneBot.AccessToken != "" {
|
||||||
|
dst.OneBot = src.OneBot
|
||||||
|
}
|
||||||
|
if src.WeCom != nil && (src.WeCom.Token != "" || src.WeCom.EncodingAESKey != "") {
|
||||||
|
dst.WeCom = src.WeCom
|
||||||
|
}
|
||||||
|
if src.WeComApp != nil &&
|
||||||
|
(src.WeComApp.CorpSecret != "" || src.WeComApp.Token != "" || src.WeComApp.EncodingAESKey != "") {
|
||||||
|
dst.WeComApp = src.WeComApp
|
||||||
|
}
|
||||||
|
if src.WeComAIBot != nil &&
|
||||||
|
(src.WeComAIBot.Secret != "" || src.WeComAIBot.Token != "" || src.WeComAIBot.EncodingAESKey != "") {
|
||||||
|
dst.WeComAIBot = src.WeComAIBot
|
||||||
|
}
|
||||||
|
if src.Pico != nil && src.Pico.Token != "" {
|
||||||
|
dst.Pico = src.Pico
|
||||||
|
}
|
||||||
|
if src.IRC != nil && (src.IRC.Password != "" || src.IRC.NickServPassword != "" || src.IRC.SASLPassword != "") {
|
||||||
|
dst.IRC = src.IRC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeWebToolsSecurity(dst, src *WebToolsSecurity) {
|
||||||
|
if src.Brave != nil && len(src.Brave.APIKeys) > 0 {
|
||||||
|
dst.Brave = src.Brave
|
||||||
|
}
|
||||||
|
if src.Tavily != nil && len(src.Tavily.APIKeys) > 0 {
|
||||||
|
dst.Tavily = src.Tavily
|
||||||
|
}
|
||||||
|
if src.Perplexity != nil && len(src.Perplexity.APIKeys) > 0 {
|
||||||
|
dst.Perplexity = src.Perplexity
|
||||||
|
}
|
||||||
|
if src.GLMSearch != nil && src.GLMSearch.APIKey != "" {
|
||||||
|
dst.GLMSearch = src.GLMSearch
|
||||||
|
}
|
||||||
|
if src.BaiduSearch != nil && src.BaiduSearch.APIKey != "" {
|
||||||
|
dst.BaiduSearch = src.BaiduSearch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeSkillsSecurity(dst, src *SkillsSecurity) {
|
||||||
|
if src.Github != nil && src.Github.Token != "" {
|
||||||
|
dst.Github = src.Github
|
||||||
|
}
|
||||||
|
if src.ClawHub != nil && src.ClawHub.AuthToken != "" {
|
||||||
|
dst.ClawHub = src.ClawHub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
|
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
|
||||||
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
||||||
// Computed once on first access via sync.Once.
|
// Computed once on first access via sync.Once.
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
h.setWeixinFlowError(flowID, "login confirmed but missing bot_token")
|
h.setWeixinFlowError(flowID, "login confirmed but missing bot_token")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if saveErr := h.saveWeixinToken(statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil {
|
if saveErr := h.saveWeixinBinding(statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil {
|
||||||
h.setWeixinFlowError(flowID, fmt.Sprintf("failed to save token: %v", saveErr))
|
h.setWeixinFlowError(flowID, fmt.Sprintf("failed to save token: %v", saveErr))
|
||||||
logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()})
|
logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()})
|
||||||
break
|
break
|
||||||
|
|
@ -203,17 +203,34 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveWeixinToken writes the token and account ID into the config file.
|
// saveWeixinBinding writes the token/account ID, enables the Weixin channel,
|
||||||
func (h *Handler) saveWeixinToken(token, accountID string) error {
|
// and best-effort restarts the gateway when it is currently running.
|
||||||
|
func (h *Handler) saveWeixinBinding(token, accountID string) error {
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load config: %w", err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
cfg.Channels.Weixin.SetToken(token)
|
cfg.Channels.Weixin.SetToken(token)
|
||||||
|
cfg.Channels.Weixin.Enabled = true
|
||||||
if accountID != "" {
|
if accountID != "" {
|
||||||
cfg.Channels.Weixin.AccountID = accountID
|
cfg.Channels.Weixin.AccountID = accountID
|
||||||
}
|
}
|
||||||
return config.SaveConfig(h.configPath, cfg)
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := h.gatewayStatusData()
|
||||||
|
gatewayStatus, _ := status["gateway_status"].(string)
|
||||||
|
if gatewayStatus != "running" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.RestartGateway(); err != nil {
|
||||||
|
logger.ErrorCF("weixin", "failed to restart gateway after saving binding", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateQRDataURI encodes content as a QR code PNG and returns a data URI.
|
// generateQRDataURI encodes content as a QR code PNG and returns a data URI.
|
||||||
|
|
|
||||||
56
web/backend/api/weixin_test.go
Normal file
56
web/backend/api/weixin_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) {
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalHealthGet := gatewayHealthGet
|
||||||
|
gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(
|
||||||
|
`{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(os.Getpid()) + `}`,
|
||||||
|
)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
gatewayHealthGet = originalHealthGet
|
||||||
|
})
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
if err := h.saveWeixinBinding("bot-token", "bot-account"); err != nil {
|
||||||
|
t.Fatalf("saveWeixinBinding() error = %v, want nil after config save succeeds", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
savedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := savedCfg.Channels.Weixin.Token(); got != "bot-token" {
|
||||||
|
t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token")
|
||||||
|
}
|
||||||
|
if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {
|
||||||
|
t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account")
|
||||||
|
}
|
||||||
|
if !savedCfg.Channels.Weixin.Enabled {
|
||||||
|
t.Fatalf("Weixin.Enabled = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,8 +76,12 @@ export async function startWeixinFlow(): Promise<WeixinFlowResponse> {
|
||||||
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
|
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollWeixinFlow(flowID: string): Promise<WeixinFlowResponse> {
|
export async function pollWeixinFlow(
|
||||||
return request<WeixinFlowResponse>(`/api/weixin/flows/${encodeURIComponent(flowID)}`)
|
flowID: string,
|
||||||
|
): Promise<WeixinFlowResponse> {
|
||||||
|
return request<WeixinFlowResponse>(
|
||||||
|
`/api/weixin/flows/${encodeURIComponent(flowID)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ChannelsCatalogResponse, ConfigActionResponse }
|
export type { ChannelsCatalogResponse, ConfigActionResponse }
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,17 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const routerState = useRouterState()
|
const routerState = useRouterState()
|
||||||
const { t } = useTranslation()
|
const { i18n, t } = useTranslation()
|
||||||
const currentPath = routerState.location.pathname
|
const currentPath = routerState.location.pathname
|
||||||
const {
|
const {
|
||||||
channelItems,
|
channelItems,
|
||||||
hasMoreChannels,
|
hasMoreChannels,
|
||||||
showAllChannels,
|
showAllChannels,
|
||||||
toggleShowAllChannels,
|
toggleShowAllChannels,
|
||||||
} = useSidebarChannels({ t })
|
} = useSidebarChannels({
|
||||||
|
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
||||||
|
t,
|
||||||
|
})
|
||||||
|
|
||||||
const navGroups: NavGroup[] = React.useMemo(() => {
|
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useAtomValue } from "jotai"
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ChannelConfig,
|
type ChannelConfig,
|
||||||
|
|
@ -21,7 +19,8 @@ import { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
import { useGateway } from "@/hooks/use-gateway"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
interface ChannelConfigPageProps {
|
interface ChannelConfigPageProps {
|
||||||
channelName: string
|
channelName: string
|
||||||
|
|
@ -241,7 +240,7 @@ const CHANNELS_WITHOUT_DOCS = new Set([
|
||||||
|
|
||||||
export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const gateway = useAtomValue(gatewayAtom)
|
const { state: gatewayState } = useGateway()
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -254,56 +253,59 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
const loadData = useCallback(async (silent = false) => {
|
const loadData = useCallback(
|
||||||
if (!silent) setLoading(true)
|
async (silent = false) => {
|
||||||
try {
|
if (!silent) setLoading(true)
|
||||||
const [catalog, appConfig] = await Promise.all([
|
try {
|
||||||
getChannelsCatalog(),
|
const [catalog, appConfig] = await Promise.all([
|
||||||
getAppConfig(),
|
getChannelsCatalog(),
|
||||||
])
|
getAppConfig(),
|
||||||
const matched =
|
])
|
||||||
catalog.channels.find((item) => item.name === channelName) ?? null
|
const matched =
|
||||||
|
catalog.channels.find((item) => item.name === channelName) ?? null
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
setChannel(null)
|
setChannel(null)
|
||||||
setFetchError(
|
setFetchError(
|
||||||
t("channels.page.notFound", {
|
t("channels.page.notFound", {
|
||||||
name: channelName,
|
name: channelName,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
||||||
|
const raw = asRecord(channelsConfig[matched.config_key])
|
||||||
|
const normalized = normalizeConfig(matched, raw)
|
||||||
|
|
||||||
|
setChannel(matched)
|
||||||
|
setBaseConfig(normalized)
|
||||||
|
setEditConfig(buildEditConfig(normalized))
|
||||||
|
setEnabled(asBool(normalized.enabled))
|
||||||
|
setFetchError("")
|
||||||
|
setServerError("")
|
||||||
|
setFieldErrors({})
|
||||||
|
} catch (e) {
|
||||||
|
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||||
|
} finally {
|
||||||
|
if (!silent) setLoading(false)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
[channelName, t],
|
||||||
const raw = asRecord(channelsConfig[matched.config_key])
|
)
|
||||||
const normalized = normalizeConfig(matched, raw)
|
|
||||||
|
|
||||||
setChannel(matched)
|
|
||||||
setBaseConfig(normalized)
|
|
||||||
setEditConfig(buildEditConfig(normalized))
|
|
||||||
setEnabled(asBool(normalized.enabled))
|
|
||||||
setFetchError("")
|
|
||||||
setServerError("")
|
|
||||||
setFieldErrors({})
|
|
||||||
} catch (e) {
|
|
||||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
|
||||||
} finally {
|
|
||||||
if (!silent) setLoading(false)
|
|
||||||
}
|
|
||||||
}, [channelName, t])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
const previousGatewayStatusRef = useRef(gateway.status)
|
const previousGatewayStatusRef = useRef(gatewayState)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousStatus = previousGatewayStatusRef.current
|
const previousStatus = previousGatewayStatusRef.current
|
||||||
if (previousStatus !== "running" && gateway.status === "running") {
|
if (previousStatus !== "running" && gatewayState === "running") {
|
||||||
void loadData()
|
void loadData()
|
||||||
}
|
}
|
||||||
previousGatewayStatusRef.current = gateway.status
|
previousGatewayStatusRef.current = gatewayState
|
||||||
}, [gateway.status, loadData])
|
}, [gatewayState, loadData])
|
||||||
|
|
||||||
const savePayload = useMemo(() => {
|
const savePayload = useMemo(() => {
|
||||||
if (!channel) return null
|
if (!channel) return null
|
||||||
|
|
@ -396,18 +398,28 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
[channel.config_key]: savePayload,
|
[channel.config_key]: savePayload,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
toast.success(t("channels.page.saveSuccess"))
|
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message =
|
const message =
|
||||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||||
setServerError(message)
|
setServerError(message)
|
||||||
toast.error(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleWeixinBindSuccess = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setEnabled(true)
|
||||||
|
await Promise.all([loadData(true), refreshGatewayState({ force: true })])
|
||||||
|
} catch (e) {
|
||||||
|
const message =
|
||||||
|
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||||
|
setServerError(message)
|
||||||
|
await loadData(true)
|
||||||
|
}
|
||||||
|
}, [loadData, t])
|
||||||
|
|
||||||
const renderForm = () => {
|
const renderForm = () => {
|
||||||
if (!channel) return null
|
if (!channel) return null
|
||||||
const isEdit = configured
|
const isEdit = configured
|
||||||
|
|
@ -455,7 +467,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
isEdit={isEdit}
|
||||||
onBindSuccess={() => void loadData(true)}
|
onBindSuccess={() => void handleWeixinBindSuccess()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { IconLoader2, IconRefresh, IconCheck, IconX, IconQrcode } from "@tabler/icons-react"
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconLoader2,
|
||||||
|
IconQrcode,
|
||||||
|
IconRefresh,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
|
@ -8,7 +14,14 @@ import { Field } from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
type BindingState = "idle" | "loading" | "waiting" | "scaned" | "confirmed" | "expired" | "error"
|
type BindingState =
|
||||||
|
| "idle"
|
||||||
|
| "loading"
|
||||||
|
| "waiting"
|
||||||
|
| "scaned"
|
||||||
|
| "confirmed"
|
||||||
|
| "expired"
|
||||||
|
| "error"
|
||||||
|
|
||||||
interface WeixinFormProps {
|
interface WeixinFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
|
|
@ -26,7 +39,12 @@ function asStringArray(value: unknown): string[] {
|
||||||
return value.filter((item): item is string => typeof item === "string")
|
return value.filter((item): item is string => typeof item === "string")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFormProps) {
|
export function WeixinForm({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
isEdit,
|
||||||
|
onBindSuccess,
|
||||||
|
}: WeixinFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [bindState, setBindState] = useState<BindingState>("idle")
|
const [bindState, setBindState] = useState<BindingState>("idle")
|
||||||
|
|
@ -35,10 +53,12 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
const [errorMsg, setErrorMsg] = useState("")
|
const [errorMsg, setErrorMsg] = useState("")
|
||||||
|
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const pollGenerationRef = useRef(0)
|
||||||
const isBound = isEdit && asString(config.account_id) !== ""
|
const isBound = isEdit && asString(config.account_id) !== ""
|
||||||
const existingAccountID = asString(config.account_id)
|
const existingAccountID = asString(config.account_id)
|
||||||
|
|
||||||
const stopPolling = useCallback(() => {
|
const stopPolling = useCallback(() => {
|
||||||
|
pollGenerationRef.current += 1
|
||||||
if (pollTimerRef.current !== null) {
|
if (pollTimerRef.current !== null) {
|
||||||
clearInterval(pollTimerRef.current)
|
clearInterval(pollTimerRef.current)
|
||||||
pollTimerRef.current = null
|
pollTimerRef.current = null
|
||||||
|
|
@ -47,17 +67,32 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
|
|
||||||
useEffect(() => () => stopPolling(), [stopPolling])
|
useEffect(() => () => stopPolling(), [stopPolling])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!existingAccountID) return
|
||||||
|
stopPolling()
|
||||||
|
setAccountID(existingAccountID)
|
||||||
|
setBindState("confirmed")
|
||||||
|
setErrorMsg("")
|
||||||
|
}, [existingAccountID, stopPolling])
|
||||||
|
|
||||||
const startPolling = useCallback(
|
const startPolling = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
const generation = pollGenerationRef.current
|
||||||
|
let inFlight = false
|
||||||
pollTimerRef.current = setInterval(async () => {
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
if (inFlight) return
|
||||||
|
inFlight = true
|
||||||
try {
|
try {
|
||||||
const resp = await pollWeixinFlow(id)
|
const resp = await pollWeixinFlow(id)
|
||||||
|
if (generation !== pollGenerationRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (resp.status === "scaned") {
|
if (resp.status === "scaned") {
|
||||||
setBindState("scaned")
|
setBindState("scaned")
|
||||||
} else if (resp.status === "confirmed") {
|
} else if (resp.status === "confirmed") {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
setAccountID(resp.account_id ?? null)
|
setAccountID(resp.account_id ?? existingAccountID ?? null)
|
||||||
setBindState("confirmed")
|
setBindState("confirmed")
|
||||||
onBindSuccess?.()
|
onBindSuccess?.()
|
||||||
} else if (resp.status === "expired") {
|
} else if (resp.status === "expired") {
|
||||||
|
|
@ -70,10 +105,12 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// transient network error — keep polling
|
// transient network error — keep polling
|
||||||
|
} finally {
|
||||||
|
inFlight = false
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
},
|
},
|
||||||
[stopPolling, onBindSuccess, t],
|
[existingAccountID, stopPolling, onBindSuccess, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleBind = async () => {
|
const handleBind = async () => {
|
||||||
|
|
@ -88,7 +125,9 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
startPolling(resp.flow_id)
|
startPolling(resp.flow_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setBindState("error")
|
setBindState("error")
|
||||||
setErrorMsg(e instanceof Error ? e.message : t("channels.weixin.errorGeneric"))
|
setErrorMsg(
|
||||||
|
e instanceof Error ? e.message : t("channels.weixin.errorGeneric"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,9 +150,16 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
{t("channels.weixin.bound")}
|
{t("channels.weixin.bound")}
|
||||||
</div>
|
</div>
|
||||||
{existingAccountID && (
|
{existingAccountID && (
|
||||||
<p className="text-xs text-muted-foreground font-mono">{existingAccountID}</p>
|
<p className="text-muted-foreground font-mono text-xs">
|
||||||
|
{existingAccountID}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={handleRebind} className="mt-1 gap-2">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRebind}
|
||||||
|
className="mt-1 gap-2"
|
||||||
|
>
|
||||||
<IconRefresh size={14} />
|
<IconRefresh size={14} />
|
||||||
{t("channels.weixin.rebind")}
|
{t("channels.weixin.rebind")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -122,7 +168,9 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
<p className="text-sm text-muted-foreground">{t("channels.weixin.notBound")}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("channels.weixin.notBound")}
|
||||||
|
</p>
|
||||||
<Button onClick={handleBind} className="gap-2">
|
<Button onClick={handleBind} className="gap-2">
|
||||||
<IconQrcode size={16} />
|
<IconQrcode size={16} />
|
||||||
{t("channels.weixin.bind")}
|
{t("channels.weixin.bind")}
|
||||||
|
|
@ -134,8 +182,13 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
if (bindState === "loading") {
|
if (bindState === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3 py-8">
|
<div className="flex flex-col items-center gap-3 py-8">
|
||||||
<IconLoader2 className="animate-spin text-muted-foreground" size={32} />
|
<IconLoader2
|
||||||
<p className="text-sm text-muted-foreground">{t("channels.weixin.generating")}</p>
|
className="text-muted-foreground animate-spin"
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("channels.weixin.generating")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -147,11 +200,14 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
<img
|
<img
|
||||||
src={qrDataURI}
|
src={qrDataURI}
|
||||||
alt="WeChat QR Code"
|
alt="WeChat QR Code"
|
||||||
className="h-48 w-48 rounded-xl border border-border/60 bg-white p-2 shadow-sm"
|
className="border-border/60 h-48 w-48 rounded-xl border bg-white p-2 shadow-sm"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-48 w-48 items-center justify-center rounded-xl border border-border/60 bg-muted">
|
<div className="border-border/60 bg-muted flex h-48 w-48 items-center justify-center rounded-xl border">
|
||||||
<IconLoader2 className="animate-spin text-muted-foreground" size={32} />
|
<IconLoader2
|
||||||
|
className="text-muted-foreground animate-spin"
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{bindState === "scaned" ? (
|
{bindState === "scaned" ? (
|
||||||
|
|
@ -160,9 +216,16 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
{t("channels.weixin.scanned")}
|
{t("channels.weixin.scanned")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">{t("channels.weixin.scanHint")}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("channels.weixin.scanHint")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm" onClick={handleRebind} className="text-muted-foreground">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRebind}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
<IconRefresh size={14} className="mr-1" />
|
<IconRefresh size={14} className="mr-1" />
|
||||||
{t("channels.weixin.refresh")}
|
{t("channels.weixin.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -174,15 +237,25 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3 py-6">
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
|
||||||
<IconCheck size={28} className="text-emerald-600 dark:text-emerald-400" />
|
<IconCheck
|
||||||
|
size={28}
|
||||||
|
className="text-emerald-600 dark:text-emerald-400"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
{t("channels.weixin.bound")}
|
{t("channels.weixin.bound")}
|
||||||
</p>
|
</p>
|
||||||
{accountID && (
|
{accountID && (
|
||||||
<p className="text-xs text-muted-foreground font-mono">{accountID}</p>
|
<p className="text-muted-foreground font-mono text-xs">
|
||||||
|
{accountID}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={handleRebind} className="mt-1 gap-2">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRebind}
|
||||||
|
className="mt-1 gap-2"
|
||||||
|
>
|
||||||
<IconRefresh size={14} />
|
<IconRefresh size={14} />
|
||||||
{t("channels.weixin.rebind")}
|
{t("channels.weixin.rebind")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -196,7 +269,9 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
|
||||||
<IconX size={28} className="text-amber-600 dark:text-amber-400" />
|
<IconX size={28} className="text-amber-600 dark:text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400">{t("channels.weixin.expired")}</p>
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
{t("channels.weixin.expired")}
|
||||||
|
</p>
|
||||||
<Button onClick={handleRebind} className="gap-2">
|
<Button onClick={handleRebind} className="gap-2">
|
||||||
<IconRefresh size={14} />
|
<IconRefresh size={14} />
|
||||||
{t("channels.weixin.retry")}
|
{t("channels.weixin.retry")}
|
||||||
|
|
@ -208,10 +283,12 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
if (bindState === "error") {
|
if (bindState === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
<div className="bg-destructive/10 flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
<IconX size={28} className="text-destructive" />
|
<IconX size={28} className="text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-destructive">{errorMsg || t("channels.weixin.errorGeneric")}</p>
|
<p className="text-destructive text-sm">
|
||||||
|
{errorMsg || t("channels.weixin.errorGeneric")}
|
||||||
|
</p>
|
||||||
<Button variant="outline" onClick={handleRebind} className="gap-2">
|
<Button variant="outline" onClick={handleRebind} className="gap-2">
|
||||||
<IconRefresh size={14} />
|
<IconRefresh size={14} />
|
||||||
{t("channels.weixin.retry")}
|
{t("channels.weixin.retry")}
|
||||||
|
|
@ -226,10 +303,14 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* QR Bind Section */}
|
{/* QR Bind Section */}
|
||||||
<div className="rounded-xl border border-border/60 bg-muted/30">
|
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
||||||
<div className="border-b border-border/60 px-4 py-3">
|
<div className="border-border/60 border-b px-4 py-3">
|
||||||
<p className="text-sm font-medium">{t("channels.weixin.bindTitle")}</p>
|
<p className="text-sm font-medium">
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{t("channels.weixin.bindDesc")}</p>
|
{t("channels.weixin.bindTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{t("channels.weixin.bindDesc")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{renderBindSection()}
|
{renderBindSection()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ interface UserMessageProps {
|
||||||
export function UserMessage({ content }: UserMessageProps) {
|
export function UserMessage({ content }: UserMessageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-end gap-1.5">
|
<div className="flex w-full flex-col items-end gap-1.5">
|
||||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm whitespace-pre-wrap">
|
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed whitespace-pre-wrap text-white shadow-sm">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
EMPTY_FORM.cronExecTimeoutMinutes,
|
EMPTY_FORM.cronExecTimeoutMinutes,
|
||||||
),
|
),
|
||||||
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
||||||
contextWindow: asNumberString(defaults.context_window, EMPTY_FORM.contextWindow),
|
contextWindow: asNumberString(
|
||||||
|
defaults.context_window,
|
||||||
|
EMPTY_FORM.contextWindow,
|
||||||
|
),
|
||||||
maxToolIterations: asNumberString(
|
maxToolIterations: asNumberString(
|
||||||
defaults.max_tool_iterations,
|
defaults.max_tool_iterations,
|
||||||
EMPTY_FORM.maxToolIterations,
|
EMPTY_FORM.maxToolIterations,
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,7 @@ import { getChannelDisplayName } from "@/components/channels/channel-display-nam
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
import { gatewayAtom } from "@/store/gateway"
|
||||||
|
|
||||||
const DEFAULT_VISIBLE_CHANNELS = 4
|
const DEFAULT_VISIBLE_CHANNELS = 4
|
||||||
const CHANNEL_IMPORTANCE_ORDER = [
|
const CHANNEL_IMPORTANCE_TAIL = [
|
||||||
"discord",
|
|
||||||
"feishu",
|
|
||||||
"telegram",
|
|
||||||
"slack",
|
"slack",
|
||||||
"line",
|
"line",
|
||||||
"wecom",
|
"wecom",
|
||||||
|
|
@ -47,9 +44,13 @@ const CHANNEL_IMPORTANCE_ORDER = [
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"whatsapp_native",
|
"whatsapp_native",
|
||||||
]
|
]
|
||||||
const CHANNEL_IMPORTANCE_INDEX = new Map(
|
|
||||||
CHANNEL_IMPORTANCE_ORDER.map((name, index) => [name, index]),
|
function getChannelImportanceOrder(language: string): string[] {
|
||||||
)
|
const priority = language.startsWith("zh")
|
||||||
|
? ["feishu", "weixin", "discord", "telegram"]
|
||||||
|
: ["discord", "telegram", "feishu", "weixin"]
|
||||||
|
return [...priority, ...CHANNEL_IMPORTANCE_TAIL]
|
||||||
|
}
|
||||||
|
|
||||||
function IconLark({ className }: { className?: string }) {
|
function IconLark({ className }: { className?: string }) {
|
||||||
return React.createElement("span", {
|
return React.createElement("span", {
|
||||||
|
|
@ -75,6 +76,7 @@ const CHANNEL_ICON_MAP: Record<
|
||||||
dingtalk: IconBrandDingtalk,
|
dingtalk: IconBrandDingtalk,
|
||||||
line: IconBrandLine,
|
line: IconBrandLine,
|
||||||
qq: IconBrandQq,
|
qq: IconBrandQq,
|
||||||
|
weixin: IconBrandWechat,
|
||||||
wecom: IconBrandWechat,
|
wecom: IconBrandWechat,
|
||||||
wecom_app: IconBrandWechat,
|
wecom_app: IconBrandWechat,
|
||||||
wecom_aibot: IconBrandWechat,
|
wecom_aibot: IconBrandWechat,
|
||||||
|
|
@ -134,10 +136,11 @@ export interface SidebarChannelNavItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseSidebarChannelsOptions {
|
interface UseSidebarChannelsOptions {
|
||||||
|
language: string
|
||||||
t: TFunction
|
t: TFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) {
|
||||||
const gateway = useAtomValue(gatewayAtom)
|
const gateway = useAtomValue(gatewayAtom)
|
||||||
const [channels, setChannels] = React.useState<SupportedChannel[]>([])
|
const [channels, setChannels] = React.useState<SupportedChannel[]>([])
|
||||||
const [enabledMap, setEnabledMap] = React.useState<Record<string, boolean>>(
|
const [enabledMap, setEnabledMap] = React.useState<Record<string, boolean>>(
|
||||||
|
|
@ -183,6 +186,12 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
||||||
previousGatewayStatusRef.current = gateway.status
|
previousGatewayStatusRef.current = gateway.status
|
||||||
}, [gateway.status, reloadChannels])
|
}, [gateway.status, reloadChannels])
|
||||||
|
|
||||||
|
const channelImportanceIndex = React.useMemo(() => {
|
||||||
|
return new Map(
|
||||||
|
getChannelImportanceOrder(language).map((name, index) => [name, index]),
|
||||||
|
)
|
||||||
|
}, [language])
|
||||||
|
|
||||||
const sortedChannels = React.useMemo(() => {
|
const sortedChannels = React.useMemo(() => {
|
||||||
const list = [...channels]
|
const list = [...channels]
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
|
|
@ -193,9 +202,9 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const aImportance =
|
const aImportance =
|
||||||
CHANNEL_IMPORTANCE_INDEX.get(a.name) ?? Number.MAX_SAFE_INTEGER
|
channelImportanceIndex.get(a.name) ?? Number.MAX_SAFE_INTEGER
|
||||||
const bImportance =
|
const bImportance =
|
||||||
CHANNEL_IMPORTANCE_INDEX.get(b.name) ?? Number.MAX_SAFE_INTEGER
|
channelImportanceIndex.get(b.name) ?? Number.MAX_SAFE_INTEGER
|
||||||
if (aImportance !== bImportance) {
|
if (aImportance !== bImportance) {
|
||||||
return aImportance - bImportance
|
return aImportance - bImportance
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +214,7 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
return list
|
return list
|
||||||
}, [channels, enabledMap, t])
|
}, [channelImportanceIndex, channels, enabledMap, t])
|
||||||
|
|
||||||
const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS
|
const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS
|
||||||
const visibleChannels = showAllChannels
|
const visibleChannels = showAllChannels
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,9 @@
|
||||||
"weixin": "WeChat"
|
"weixin": "WeChat"
|
||||||
},
|
},
|
||||||
"weixin": {
|
"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.",
|
||||||
|
"bindEnableSuccess": "WeChat connected and the channel has been enabled automatically.",
|
||||||
"bindTitle": "WeChat Account Binding",
|
"bindTitle": "WeChat Account Binding",
|
||||||
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
||||||
"bind": "Bind WeChat",
|
"bind": "Bind WeChat",
|
||||||
|
|
@ -289,7 +292,9 @@
|
||||||
"saveError": "Failed to save channel configuration",
|
"saveError": "Failed to save channel configuration",
|
||||||
"enabled": "enabled",
|
"enabled": "enabled",
|
||||||
"docLink": "Documentation",
|
"docLink": "Documentation",
|
||||||
"enableLabel": "Enable channel"
|
"enableLabel": "Enable channel",
|
||||||
|
"restartRequiredTitle": "Gateway restart required",
|
||||||
|
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,9 @@
|
||||||
"weixin": "微信"
|
"weixin": "微信"
|
||||||
},
|
},
|
||||||
"weixin": {
|
"weixin": {
|
||||||
|
"warningTitle": "测试阶段,请谨慎使用",
|
||||||
|
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
|
||||||
|
"bindEnableSuccess": "微信已连接,频道已自动启用。",
|
||||||
"bindTitle": "微信账号绑定",
|
"bindTitle": "微信账号绑定",
|
||||||
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||||
"bind": "绑定微信",
|
"bind": "绑定微信",
|
||||||
|
|
@ -289,7 +292,9 @@
|
||||||
"saveError": "保存频道配置失败",
|
"saveError": "保存频道配置失败",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"docLink": "配置文档",
|
"docLink": "配置文档",
|
||||||
"enableLabel": "启用频道"
|
"enableLabel": "启用频道",
|
||||||
|
"restartRequiredTitle": "需要重启服务",
|
||||||
|
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue