From eb307e942b828ed49176e0555178367b963c87b4 Mon Sep 17 00:00:00 2001 From: wenjie Date: Wed, 25 Mar 2026 16:15:04 +0800 Subject: [PATCH] feat(web): add WeCom QR binding flow to channel settings (#1994) - add backend WeCom QR flow endpoints and in-memory flow state management - add frontend WeCom binding UI with QR polling and channel enable toggle - update channel config behavior and i18n strings for WeCom and WeChat - apply minor formatting cleanup in model-related components --- web/backend/api/router.go | 6 + web/backend/api/wecom.go | 424 ++++++++++++++++++ web/frontend/src/api/channels.ts | 20 + .../channels/channel-config-page.tsx | 91 +++- .../channels/channel-forms/generic-form.tsx | 4 +- .../channels/channel-forms/wecom-form.tsx | 367 +++++++++++++++ .../src/components/models/add-model-sheet.tsx | 5 +- .../components/models/edit-model-sheet.tsx | 5 +- .../src/components/models/model-card.tsx | 3 +- web/frontend/src/i18n/locales/en.json | 18 +- web/frontend/src/i18n/locales/zh.json | 18 +- 11 files changed, 945 insertions(+), 16 deletions(-) create mode 100644 web/backend/api/wecom.go create mode 100644 web/frontend/src/components/channels/channel-forms/wecom-form.tsx diff --git a/web/backend/api/router.go b/web/backend/api/router.go index d09f68eac..ce652d4c4 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -19,6 +19,8 @@ type Handler struct { oauthState map[string]string weixinMu sync.Mutex weixinFlows map[string]*weixinFlow + wecomMu sync.Mutex + wecomFlows map[string]*wecomFlow } // NewHandler creates an instance of the API handler. @@ -29,6 +31,7 @@ func NewHandler(configPath string) *Handler { oauthFlows: make(map[string]*oauthFlow), oauthState: make(map[string]string), weixinFlows: make(map[string]*weixinFlow), + wecomFlows: make(map[string]*wecomFlow), } } @@ -75,6 +78,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // WeChat QR login flow h.registerWeixinRoutes(mux) + + // WeCom QR login flow + h.registerWecomRoutes(mux) } // Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler. diff --git a/web/backend/api/wecom.go b/web/backend/api/wecom.go new file mode 100644 index 000000000..7dcec9f49 --- /dev/null +++ b/web/backend/api/wecom.go @@ -0,0 +1,424 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "runtime" + "strconv" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + wecomFlowTTL = 5 * time.Minute + wecomFlowGCAge = 30 * time.Minute + wecomQRSourceID = "picoclaw" + wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate" + wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result" + wecomQRHTTPTimeout = 15 * time.Second + wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com" + wecomPollStartTimeout = 15 * time.Second + wecomPollStatusTimeout = 10 * time.Second +) + +const ( + wecomStatusWait = "wait" + wecomStatusScanned = "scaned" + wecomStatusConfirmed = "confirmed" + wecomStatusExpired = "expired" + wecomStatusError = "error" +) + +type wecomFlow struct { + ID string + SCode string + QRDataURI string + BotID string + Status string + Error string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +type wecomFlowResponse struct { + FlowID string `json:"flow_id"` + Status string `json:"status"` + QRDataURI string `json:"qr_data_uri,omitempty"` + BotID string `json:"bot_id,omitempty"` + Error string `json:"error,omitempty"` +} + +type wecomQRGenerateResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + SCode string `json:"scode"` + AuthURL string `json:"auth_url"` + } `json:"data"` +} + +type wecomQRQueryResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + Status string `json:"status"` + BotInfo struct { + BotID string `json:"botid"` + Secret string `json:"secret"` + } `json:"bot_info"` + } `json:"data"` +} + +// registerWecomRoutes binds WeCom QR login endpoints to the ServeMux. +func (h *Handler) registerWecomRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/wecom/flows", h.handleStartWecomFlow) + mux.HandleFunc("GET /api/wecom/flows/{id}", h.handlePollWecomFlow) +} + +// handleStartWecomFlow starts a new WeCom QR login flow. +// +// POST /api/wecom/flows +func (h *Handler) handleStartWecomFlow(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), wecomPollStartTimeout) + defer cancel() + + session, err := fetchWecomQRCode(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError) + return + } + + dataURI, err := generateQRDataURI(session.Data.AuthURL) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError) + return + } + + now := time.Now() + flow := &wecomFlow{ + ID: newWecomFlowID(), + SCode: session.Data.SCode, + QRDataURI: dataURI, + Status: wecomStatusWait, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(wecomFlowTTL), + } + h.storeWecomFlow(flow) + + logger.InfoCF("wecom", "QR flow started", map[string]any{"flow_id": flow.ID}) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) +} + +// handlePollWecomFlow polls the WeCom API for QR code status and updates the flow. +// +// GET /api/wecom/flows/{id} +func (h *Handler) handlePollWecomFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getWecomFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + if flow.Status == wecomStatusConfirmed || + flow.Status == wecomStatusExpired || + flow.Status == wecomStatusError { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + BotID: flow.BotID, + Error: flow.Error, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), wecomPollStatusTimeout) + defer cancel() + + statusResp, err := queryWecomQRCodeStatus(ctx, flow.SCode) + if err != nil { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) + return + } + + switch strings.ToLower(statusResp.Data.Status) { + case wecomStatusWait: + // no-op + case wecomStatusScanned, "scanned": + h.updateWecomFlowStatus(flowID, wecomStatusScanned) + case "success": + if statusResp.Data.BotInfo.BotID == "" || statusResp.Data.BotInfo.Secret == "" { + h.setWecomFlowError(flowID, "login confirmed but missing bot credentials") + break + } + if saveErr := h.saveWecomBinding( + statusResp.Data.BotInfo.BotID, + statusResp.Data.BotInfo.Secret, + ); saveErr != nil { + h.setWecomFlowError(flowID, fmt.Sprintf("failed to save credentials: %v", saveErr)) + logger.ErrorCF("wecom", "failed to save credentials", map[string]any{"error": saveErr.Error()}) + break + } + h.setWecomFlowConfirmed(flowID, statusResp.Data.BotInfo.BotID) + logger.InfoCF("wecom", "QR login confirmed, credentials saved", map[string]any{ + "flow_id": flowID, + "bot_id": statusResp.Data.BotInfo.BotID, + }) + case wecomStatusExpired: + h.updateWecomFlowStatus(flowID, wecomStatusExpired) + } + + flow, _ = h.getWecomFlow(flowID) + w.Header().Set("Content-Type", "application/json") + resp := wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + BotID: flow.BotID, + Error: flow.Error, + } + if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned { + resp.QRDataURI = flow.QRDataURI + } + _ = json.NewEncoder(w).Encode(resp) +} + +func (h *Handler) saveWecomBinding(botID, secret string) error { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + cfg.Channels.WeCom.Enabled = true + cfg.Channels.WeCom.BotID = botID + cfg.Channels.WeCom.SetSecret(secret) + if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" { + cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL + } + 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("wecom", "failed to restart gateway after saving binding", map[string]any{ + "error": err.Error(), + }) + } + return nil +} + +func fetchWecomQRCode(ctx context.Context) (wecomQRGenerateResponse, error) { + targetURL, err := buildWecomQRGenerateURL(wecomQRGenerateEndpoint, wecomQRSourceID, wecomPlatformCode()) + if err != nil { + return wecomQRGenerateResponse{}, err + } + + var resp wecomQRGenerateResponse + if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil { + return wecomQRGenerateResponse{}, err + } + if resp.ErrCode != 0 { + return wecomQRGenerateResponse{}, fmt.Errorf( + "errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + if resp.Data.SCode == "" || resp.Data.AuthURL == "" { + return wecomQRGenerateResponse{}, fmt.Errorf("response missing scode or auth_url") + } + return resp, nil +} + +func queryWecomQRCodeStatus(ctx context.Context, scode string) (wecomQRQueryResponse, error) { + targetURL, err := buildWecomQRQueryURL(wecomQRQueryEndpoint, scode) + if err != nil { + return wecomQRQueryResponse{}, err + } + + var resp wecomQRQueryResponse + if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil { + return wecomQRQueryResponse{}, err + } + if resp.ErrCode != 0 { + return wecomQRQueryResponse{}, fmt.Errorf( + "errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + return resp, nil +} + +func buildWecomQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err) + } + + query := u.Query() + query.Set("source", sourceID) + query.Set("sourceID", sourceID) + query.Set("plat", strconv.Itoa(platformCode)) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func buildWecomQRQueryURL(baseURL, scode string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR query URL: %w", err) + } + + query := u.Query() + query.Set("scode", scode) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func doWecomJSONGet(ctx context.Context, targetURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: wecomQRHTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return fmt.Errorf("unexpected status %s", resp.Status) + } + return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode JSON response: %w", err) + } + return nil +} + +func wecomPlatformCode() int { + switch runtime.GOOS { + case "darwin": + return 1 + case "windows": + return 2 + case "linux": + return 3 + default: + return 0 + } +} + +func newWecomFlowID() string { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("wc_%d", time.Now().UnixNano()) + } + return "wc_" + hex.EncodeToString(buf) +} + +func (h *Handler) storeWecomFlow(flow *wecomFlow) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + h.gcWecomFlowsLocked(time.Now()) + h.wecomFlows[flow.ID] = flow +} + +func (h *Handler) getWecomFlow(flowID string) (*wecomFlow, bool) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + h.gcWecomFlowsLocked(time.Now()) + flow, ok := h.wecomFlows[flowID] + if !ok { + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) updateWecomFlowStatus(flowID, status string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = status + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWecomFlowConfirmed(flowID, botID string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = wecomStatusConfirmed + flow.BotID = botID + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWecomFlowError(flowID, errMsg string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = wecomStatusError + flow.Error = errMsg + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) gcWecomFlowsLocked(now time.Time) { + for id, flow := range h.wecomFlows { + if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned { + if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) { + flow.Status = wecomStatusExpired + flow.UpdatedAt = now + } + } + if flow.Status != wecomStatusWait && + flow.Status != wecomStatusScanned && + now.Sub(flow.UpdatedAt) > wecomFlowGCAge { + delete(h.wecomFlows, id) + } + } +} diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index d4c3ac74b..85550ca81 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -72,6 +72,14 @@ export interface WeixinFlowResponse { error?: string } +export interface WecomFlowResponse { + flow_id: string + status: "wait" | "scaned" | "confirmed" | "expired" | "error" + qr_data_uri?: string + bot_id?: string + error?: string +} + export async function startWeixinFlow(): Promise { return request("/api/weixin/flows", { method: "POST" }) } @@ -84,4 +92,16 @@ export async function pollWeixinFlow( ) } +export async function startWecomFlow(): Promise { + return request("/api/wecom/flows", { method: "POST" }) +} + +export async function pollWecomFlow( + flowID: string, +): Promise { + return request( + `/api/wecom/flows/${encodeURIComponent(flowID)}`, + ) +} + export type { ChannelsCatalogResponse, ConfigActionResponse } diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index 7f1f695bc..6af821ac9 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -1,4 +1,4 @@ -import { IconLoader2 } from "@tabler/icons-react" +import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -15,6 +15,7 @@ import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" import { GenericForm } from "@/components/channels/channel-forms/generic-form" import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" +import { WecomForm } from "@/components/channels/channel-forms/wecom-form" import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" @@ -186,7 +187,7 @@ function getRequiredFieldKeys(channelName: string): string[] { case "onebot": return ["ws_url"] case "wecom": - return ["bot_id", "secret"] + return [] case "whatsapp": return ["bridge_url"] case "pico": @@ -326,6 +327,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return getChannelDisplayName(channel, t) }, [channel, channelName, t]) + const hidesPageLevelEnableToggle = channel?.name === "wecom" + const hiddenKeys = useMemo(() => { if (!channel) return [] if (channel.name === "whatsapp") { @@ -410,6 +413,36 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { } }, [loadData, t]) + const handleWecomBindSuccess = 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 handleWecomEnabledChange = useCallback( + async (nextEnabled: boolean) => { + try { + setEnabled(nextEnabled) + 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 = () => { if (!channel) return null const isEdit = configured @@ -460,6 +493,27 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onBindSuccess={() => void handleWeixinBindSuccess()} /> ) + case "wecom": + return ( + <> + void handleWecomBindSuccess()} + onEnabledChange={(nextEnabled) => + void handleWecomEnabledChange(nextEnabled) + } + /> + + + ) default: return ( -
-

- {t("channels.page.enableLabel")} -

- -
+ {channel?.name === "weixin" && ( +
+
+ +
+

+ {t("channels.weixin.warningTitle")} +

+

+ {t("channels.weixin.warningDesc")} +

+
+
+
+ )} + + {!hidesPageLevelEnableToggle && ( +
+

+ {t("channels.page.enableLabel")} +

+ +
+ )} {renderForm()} diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index 1a872542b..936802944 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -123,7 +123,9 @@ export function GenericForm({ bot_id: t("channels.form.desc.appId"), websocket_url: t("channels.form.desc.wsUrl"), dm_policy: t("channels.form.desc.genericField", { field: "DM policy" }), - group_policy: t("channels.form.desc.genericField", { field: "group policy" }), + group_policy: t("channels.form.desc.genericField", { + field: "group policy", + }), group_allow_from: t("channels.form.desc.allowFrom"), send_thinking_message: t("channels.form.desc.genericField", { field: "thinking message behavior", diff --git a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx new file mode 100644 index 000000000..744c87ba2 --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx @@ -0,0 +1,367 @@ +import { + IconCheck, + IconLoader2, + IconQrcode, + IconRefresh, + IconX, +} from "@tabler/icons-react" +import { useCallback, useEffect, useRef, useState } from "react" +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 { Switch } from "@/components/ui/switch" + +type BindingState = + | "idle" + | "loading" + | "waiting" + | "scaned" + | "confirmed" + | "expired" + | "error" + +interface WecomFormProps { + config: ChannelConfig + isEdit: boolean + onBindSuccess?: () => void + onEnabledChange?: (enabled: boolean) => void +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export function WecomForm({ + config, + isEdit, + onBindSuccess, + onEnabledChange, +}: WecomFormProps) { + const { t } = useTranslation() + + const [bindState, setBindState] = useState("idle") + const [qrDataURI, setQrDataURI] = useState(null) + const [botID, setBotID] = useState(null) + const [errorMsg, setErrorMsg] = useState("") + const [enabled, setEnabled] = useState(config.enabled === true) + const [toggleSaving, setToggleSaving] = useState(false) + const [toggleError, setToggleError] = useState("") + + const pollTimerRef = useRef | null>(null) + const pollGenerationRef = useRef(0) + const existingBotID = asString(config.bot_id) + const isBound = isEdit && existingBotID !== "" + + const stopPolling = useCallback(() => { + pollGenerationRef.current += 1 + if (pollTimerRef.current !== null) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + }, []) + + useEffect(() => () => stopPolling(), [stopPolling]) + + useEffect(() => { + setEnabled(config.enabled === true) + }, [config.enabled]) + + useEffect(() => { + if (!existingBotID) return + stopPolling() + setBotID(existingBotID) + setBindState("confirmed") + setErrorMsg("") + }, [existingBotID, stopPolling]) + + const startPolling = useCallback( + (id: string) => { + stopPolling() + const generation = pollGenerationRef.current + let inFlight = false + pollTimerRef.current = setInterval(async () => { + if (inFlight) return + inFlight = true + try { + const resp = await pollWecomFlow(id) + if (generation !== pollGenerationRef.current) { + return + } + if (resp.status === "scaned") { + setBindState("scaned") + } else if (resp.status === "confirmed") { + stopPolling() + setBotID(resp.bot_id ?? existingBotID ?? null) + setBindState("confirmed") + onBindSuccess?.() + } else if (resp.status === "expired") { + stopPolling() + setBindState("expired") + } else if (resp.status === "error") { + stopPolling() + setBindState("error") + setErrorMsg(resp.error ?? t("channels.wecom.errorGeneric")) + } + } catch { + // transient network error — keep polling + } finally { + inFlight = false + } + }, 2000) + }, + [existingBotID, onBindSuccess, stopPolling, t], + ) + + const handleEnabledChange = useCallback( + async (checked: boolean) => { + if (!existingBotID || toggleSaving) { + return + } + setToggleSaving(true) + setToggleError("") + try { + await patchAppConfig({ + channels: { + wecom: { + enabled: checked, + }, + }, + }) + setEnabled(checked) + onEnabledChange?.(checked) + } catch (e) { + setToggleError( + e instanceof Error ? e.message : t("channels.wecom.errorGeneric"), + ) + } finally { + setToggleSaving(false) + } + }, + [existingBotID, onEnabledChange, t, toggleSaving], + ) + + const handleBind = async () => { + setBindState("loading") + setErrorMsg("") + setToggleError("") + setQrDataURI(null) + stopPolling() + try { + const resp = await startWecomFlow() + setQrDataURI(resp.qr_data_uri ?? null) + setBindState("waiting") + startPolling(resp.flow_id) + } catch (e) { + setBindState("error") + setErrorMsg( + e instanceof Error ? e.message : t("channels.wecom.errorGeneric"), + ) + } + } + + const handleRebind = () => { + stopPolling() + setBindState("idle") + setQrDataURI(null) + setBotID(null) + setErrorMsg("") + void handleBind() + } + + const renderBindSection = () => { + if (bindState === "idle") { + if (isBound) { + return ( +
+
+ + {t("channels.wecom.bound")} +
+ {existingBotID && ( +

+ {existingBotID} +

+ )} + +
+ ) + } + return ( +
+

+ {t("channels.wecom.notBound")} +

+ +
+ ) + } + + if (bindState === "loading") { + return ( +
+ +

+ {t("channels.wecom.generating")} +

+
+ ) + } + + if (bindState === "waiting" || bindState === "scaned") { + return ( +
+ {qrDataURI ? ( + WeCom QR Code + ) : ( +
+ +
+ )} + {bindState === "scaned" ? ( +
+ + {t("channels.wecom.scanned")} +
+ ) : ( +

+ {t("channels.wecom.scanHint")} +

+ )} + +
+ ) + } + + if (bindState === "confirmed") { + return ( +
+
+ +
+

+ {t("channels.wecom.bound")} +

+ {botID && ( +

{botID}

+ )} + +
+ ) + } + + if (bindState === "expired") { + return ( +
+
+ +
+

+ {t("channels.wecom.expired")} +

+ +
+ ) + } + + if (bindState === "error") { + return ( +
+
+ +
+

+ {errorMsg || t("channels.wecom.errorGeneric")} +

+ +
+ ) + } + + return null + } + + return ( +
+
+
+
+

+ {t("channels.page.enableLabel")} +

+

+ {isBound + ? t("channels.wecom.enableDesc") + : t("channels.wecom.enableBindFirst")} +

+
+ void handleEnabledChange(checked)} + /> +
+ {toggleError && ( +

{toggleError}

+ )} +
+ +
+
+

{t("channels.wecom.bindTitle")}

+

+ {t("channels.wecom.bindDesc")} +

+
+ {renderBindSection()} +
+
+ ) +} diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index c0c48994f..de9481391 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -12,7 +12,6 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" import { Sheet, SheetContent, @@ -21,6 +20,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" interface AddForm { modelName: string @@ -103,7 +103,8 @@ export function AddModelSheet({ } const setField = - (key: keyof AddForm) => (e: React.ChangeEvent) => { + (key: keyof AddForm) => + (e: React.ChangeEvent) => { setForm((f) => ({ ...f, [key]: e.target.value })) if (fieldErrors[key]) { setFieldErrors((prev) => ({ ...prev, [key]: undefined })) diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 13678f03d..d1cba6719 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -12,7 +12,6 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" import { Sheet, SheetContent, @@ -21,6 +20,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" interface EditForm { apiKey: string @@ -92,7 +92,8 @@ export function EditModelSheet({ }, [model]) const setField = - (key: keyof EditForm) => (e: React.ChangeEvent) => + (key: keyof EditForm) => + (e: React.ChangeEvent) => setForm((f) => ({ ...f, [key]: e.target.value })) const handleSave = async () => { diff --git a/web/frontend/src/components/models/model-card.tsx b/web/frontend/src/components/models/model-card.tsx index 319cb11a3..c554410a8 100644 --- a/web/frontend/src/components/models/model-card.tsx +++ b/web/frontend/src/components/models/model-card.tsx @@ -28,7 +28,8 @@ export function ModelCard({ }: ModelCardProps) { const { t } = useTranslation() const isOAuth = model.auth_method === "oauth" - const canSetDefault = model.configured && !model.is_default && !model.is_virtual + const canSetDefault = + model.configured && !model.is_default && !model.is_virtual return (