feat(web): improve Weixin channel binding flow (#1968)

- persist Weixin bindings, enable the channel automatically, and try to restart the gateway
- refresh frontend channel and gateway state after successful binding
- harden QR polling state handling and update related channel UI behavior
- localize sidebar channel priority, add Weixin icon support, and add backend test coverage
This commit is contained in:
wenjie 2026-03-24 20:33:32 +08:00 committed by GitHub
parent 8b6cbd9909
commit 4d7a629b79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 290 additions and 95 deletions

View file

@ -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.

View 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")
}
}

View file

@ -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 }

View file

@ -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 [

View file

@ -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:

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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

View file

@ -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": {

View file

@ -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": {