From 9933a69bf8e7fb523056029994baf619ee5fc312 Mon Sep 17 00:00:00 2001 From: Sivan Date: Mon, 8 Jun 2026 01:09:55 +0800 Subject: [PATCH 01/73] feat: add bot channel settings UI --- desktop/bound_array_contract_test.go | 4 +- .../frontend/src/components/SettingsPanel.tsx | 625 +++++++++++++- desktop/frontend/src/lib/bridge.ts | 53 ++ desktop/frontend/src/lib/types.ts | 51 +- desktop/frontend/src/locales/en.ts | 67 ++ desktop/frontend/src/locales/zh.ts | 67 ++ desktop/frontend/src/styles.css | 180 +++++ desktop/settings_app.go | 156 ++++ internal/bot/feishu/feishu.go | 585 ++++++++++++++ internal/bot/gateway.go | 481 +++++++++++ internal/bot/gateway_test.go | 175 ++++ internal/bot/hash.go | 15 + internal/bot/qq/adapter.go | 71 ++ internal/bot/qq/gateway.go | 397 +++++++++ internal/bot/render.go | 246 ++++++ internal/bot/session.go | 174 ++++ internal/bot/session_test.go | 201 +++++ internal/bot/types.go | 133 +++ internal/bot/weixin/weixin.go | 761 ++++++++++++++++++ internal/cli/bot.go | 381 +++++++++ internal/cli/cli.go | 5 +- internal/config/config.go | 51 ++ internal/i18n/messages_en.go | 1 + internal/i18n/messages_zh.go | 1 + reasonix.example.toml | 39 + 25 files changed, 4913 insertions(+), 7 deletions(-) create mode 100644 internal/bot/feishu/feishu.go create mode 100644 internal/bot/gateway.go create mode 100644 internal/bot/gateway_test.go create mode 100644 internal/bot/hash.go create mode 100644 internal/bot/qq/adapter.go create mode 100644 internal/bot/qq/gateway.go create mode 100644 internal/bot/render.go create mode 100644 internal/bot/session.go create mode 100644 internal/bot/session_test.go create mode 100644 internal/bot/types.go create mode 100644 internal/bot/weixin/weixin.go create mode 100644 internal/cli/bot.go diff --git a/desktop/bound_array_contract_test.go b/desktop/bound_array_contract_test.go index 269ddc290..ab8d8c2af 100644 --- a/desktop/bound_array_contract_test.go +++ b/desktop/bound_array_contract_test.go @@ -41,7 +41,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) { } if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil || got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil || - got.Sandbox.AllowWrite == nil { + got.Sandbox.AllowWrite == nil || + got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil || + got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil { t.Fatalf("Settings() contains nil array fields: %+v", got) } } diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 374ec2434..08067c653 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -19,14 +19,14 @@ import { } from "../lib/theme"; import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize"; import { FONT_FAMILIES, applyFontFamily, getFontFamily, type FontFamily } from "../lib/fontFamily"; -import type { NetworkView, ProviderView, SettingsTab, SettingsView } from "../lib/types"; +import type { BotSettingsView, NetworkView, ProviderView, SettingsTab, SettingsView } from "../lib/types"; import { InlineConfirmButton } from "./InlineConfirmButton"; import { Tooltip } from "./Tooltip"; import { AnchoredPopover } from "./AnchoredPopover"; import { MCPServersSettingsPage, SkillsSettingsPage } from "./CapabilitiesPanel"; import { MemorySettingsPage } from "./MemoryPanel"; -const SETTINGS_TABS: SettingsTab[] = ["general", "models", "mcp", "skills", "memory", "permissions", "sandbox", "network", "appearance", "updates"]; +const SETTINGS_TABS: SettingsTab[] = ["general", "models", "bots", "mcp", "skills", "memory", "permissions", "sandbox", "network", "appearance", "updates"]; // SettingsPanel is the desktop settings centre — a centred modal with left // navigation and a right content area. It hosts all settings pages plus MCP, @@ -92,7 +92,7 @@ export function SettingsPanel({ onClose, onChanged, initialTab }: { onClose: () // The settings-reliant pages (general, models, network, permissions, // sandbox, appearance, updates) need SettingsView loaded. MCP, Skills, and Memory // load their own data and render regardless. - const needsSettings = tab === "general" || tab === "models" || tab === "network" || tab === "permissions" || tab === "sandbox" || tab === "appearance" || tab === "updates"; + const needsSettings = tab === "general" || tab === "models" || tab === "bots" || tab === "network" || tab === "permissions" || tab === "sandbox" || tab === "appearance" || tab === "updates"; return (
{ if (e.target === e.currentTarget) onClose(); }}> @@ -125,6 +125,7 @@ export function SettingsPanel({ onClose, onChanged, initialTab }: { onClose: () <> {tab === "general" && s && } {tab === "models" && s && } + {tab === "bots" && s && } {tab === "mcp" && } {tab === "skills" && } {tab === "memory" && } @@ -274,6 +275,8 @@ function settingsTabLabel(id: SettingsTab, t: ReturnType): string { return t("settings.tab.models"); case "providers": return t("settings.tab.providers"); + case "bots": + return t("settings.tab.bots"); case "mcp": return t("settings.tab.mcp"); case "skills": @@ -301,6 +304,8 @@ function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType): string return `${modelProviderLabel(provider, providerView, t)} · ${model}`; } +function botSettingsMeta(bot: BotSettingsView, t: ReturnType): string { + if (!bot.enabled) return t("settings.botMetaOff"); + const channels = [bot.qq.enabled, bot.feishu.enabled, bot.weixin.enabled].filter(Boolean).length; + if (channels === 0) return t("settings.botMetaNoChannels"); + return t("settings.botMetaChannels", { n: channels }); +} + // allRefs flattens providers into "provider/model" refs for the model selectors. function allRefs(s: SettingsView): string[] { const out: string[] = []; @@ -363,6 +375,7 @@ const REASONING_PROTOCOLS: readonly string[] = ["", "deepseek", "openai", "none" const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const; const LANGUAGE_PREFS: LangPref[] = ["", "zh", "en"]; const AUTO_PLAN_MODES = ["off", "on"] as const; +const BOT_FEISHU_MODES = ["webhook", "websocket"] as const; type ProxyMode = (typeof PROXY_MODES)[number]; type AutoPlanMode = (typeof AUTO_PLAN_MODES)[number]; @@ -390,6 +403,68 @@ function normalizeReasoningProtocol(protocol: string | undefined): string { return REASONING_PROTOCOLS.includes(protocol ?? "") ? protocol ?? "" : ""; } +function defaultBotSettings(): BotSettingsView { + return { + enabled: false, + model: "", + maxSteps: 0, + debounceMs: 1500, + allowlist: { + enabled: true, + allowAll: false, + qqUsers: [], + feishuUsers: [], + weixinUsers: [], + qqGroups: [], + feishuGroups: [], + weixinGroups: [], + }, + qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false }, + feishu: { + enabled: false, + appId: "", + appSecretEnv: "FEISHU_BOT_APP_SECRET", + secretSet: false, + verificationToken: "", + mode: "webhook", + webhookPort: 8080, + requireMention: true, + }, + weixin: { + enabled: false, + accountId: "default", + tokenEnv: "WEIXIN_BOT_TOKEN", + tokenSet: false, + apiBase: "https://ilinkai.weixin.qq.com", + }, + }; +} + +function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSettingsView { + const fallback = defaultBotSettings(); + const allowlist = bot?.allowlist ?? fallback.allowlist; + const mode = bot?.feishu?.mode === "websocket" ? "websocket" : "webhook"; + return { + ...fallback, + ...bot, + maxSteps: Math.max(0, Number(bot?.maxSteps ?? fallback.maxSteps) || 0), + debounceMs: Number(bot?.debounceMs) || fallback.debounceMs, + allowlist: { + ...fallback.allowlist, + ...allowlist, + qqUsers: asArray(allowlist.qqUsers), + feishuUsers: asArray(allowlist.feishuUsers), + weixinUsers: asArray(allowlist.weixinUsers), + qqGroups: asArray(allowlist.qqGroups), + feishuGroups: asArray(allowlist.feishuGroups), + weixinGroups: asArray(allowlist.weixinGroups), + }, + qq: { ...fallback.qq, ...bot?.qq }, + feishu: { ...fallback.feishu, ...bot?.feishu, mode }, + weixin: { ...fallback.weixin, ...bot?.weixin }, + }; +} + function normalizeSettingsView(view: SettingsView | null | undefined): SettingsView | null { if (!view) return null; const permissions = view.permissions ?? { mode: "ask", allow: [], ask: [], deny: [] }; @@ -428,6 +503,7 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV proxy: network.proxy ?? { type: "socks5", server: "", port: 0, username: "", password: "" }, }, agent, + bot: normalizeBotSettings(view.bot), autoPlan: normalizeAutoPlan(view.autoPlan), desktopLanguage: normalizeLangPref(view.desktopLanguage), desktopTheme: normalizeThemePreference(view.desktopTheme), @@ -647,6 +723,547 @@ function NetworkSection({ s, busy, apply }: SectionProps) { ); } +type BotChannelID = "qq" | "feishu" | "weixin"; + +function BotsSection({ s, busy, apply }: SectionProps) { + const t = useT(); + const savedBot = normalizeBotSettings(s.bot); + const [draft, setDraft] = useState(savedBot); + const [secrets, setSecrets] = useState>({ qq: "", feishu: "", weixin: "" }); + const refs = allRefs(s); + + useEffect(() => { + setDraft(normalizeBotSettings(s.bot)); + setSecrets({ qq: "", feishu: "", weixin: "" }); + }, [s.bot]); + + const dirty = JSON.stringify(sanitizeBotDraft(draft)) !== JSON.stringify(sanitizeBotDraft(savedBot)); + const setAllowlist = (next: Partial) => + setDraft((prev) => ({ ...prev, allowlist: { ...prev.allowlist, ...next } })); + const setQQ = (next: Partial) => + setDraft((prev) => ({ ...prev, qq: { ...prev.qq, ...next } })); + const setFeishu = (next: Partial) => + setDraft((prev) => ({ ...prev, feishu: { ...prev.feishu, ...next } })); + const setWeixin = (next: Partial) => + setDraft((prev) => ({ ...prev, weixin: { ...prev.weixin, ...next } })); + + const saveBot = () => app.SetBotSettings(sanitizeBotDraft(draft)); + const saveSecret = async (channel: BotChannelID, envName: string) => { + const env = envName.trim(); + const value = secrets[channel].trim(); + if (!env || !value) return; + await apply(async () => { + await saveBot(); + await app.SetBotSecret(env, value); + }); + setSecrets((prev) => ({ ...prev, [channel]: "" })); + }; + const clearSecret = async (envName: string) => { + const env = envName.trim(); + if (!env) return; + await apply(async () => { + await saveBot(); + await app.ClearBotSecret(env); + }); + }; + + return ( + <> + void apply(saveBot)} + > + {t("settings.saveBotSettings")} + + } + > + + setDraft((prev) => ({ ...prev, enabled }))} + /> + +
+ {t("settings.botAdvancedRuntime")} +
+ + setDraft((prev) => ({ ...prev, model }))} + /> + + +
+ + setDraft((prev) => ({ ...prev, maxSteps: parseNonNegativeInt(e.target.value) }))} + /> + + setDraft((prev) => ({ ...prev, debounceMs: parseNonNegativeInt(e.target.value) }))} + /> +
+
+
+
+
+ + +
+ setQQ({ enabled })} + advanced={ + + setQQ({ appSecretEnv: e.target.value })} + /> + + } + > + + setQQ({ appId: e.target.value })} + /> + + setSecrets((prev) => ({ ...prev, qq: value }))} + onSave={() => void saveSecret("qq", draft.qq.appSecretEnv)} + onClear={() => void clearSecret(draft.qq.appSecretEnv)} + /> + + + setFeishu({ enabled })} + advanced={ + <> + +
+ {BOT_FEISHU_MODES.map((mode) => ( + + ))} +
+
+ + setFeishu({ webhookPort: parseNonNegativeInt(e.target.value) })} + /> + + + setFeishu({ requireMention })} + /> + + + setFeishu({ appSecretEnv: e.target.value })} + /> + + + } + > + + setFeishu({ appId: e.target.value })} + /> + + setSecrets((prev) => ({ ...prev, feishu: value }))} + onSave={() => void saveSecret("feishu", draft.feishu.appSecretEnv)} + onClear={() => void clearSecret(draft.feishu.appSecretEnv)} + /> + + setFeishu({ verificationToken: e.target.value })} + /> + +
+ + setWeixin({ enabled })} + advanced={ + <> + + setWeixin({ apiBase: e.target.value })} + /> + + + setWeixin({ tokenEnv: e.target.value })} + /> + + + } + > + + setWeixin({ accountId: e.target.value })} + /> + + setSecrets((prev) => ({ ...prev, weixin: value }))} + onSave={() => void saveSecret("weixin", draft.weixin.tokenEnv)} + onClear={() => void clearSecret(draft.weixin.tokenEnv)} + /> + +
+
+ + + + setAllowlist({ enabled: true, allowAll })} + /> + +
+ {t("settings.botEditAllowlist")} +
+ +
+ setAllowlist({ qqUsers })} /> + setAllowlist({ qqGroups })} /> + setAllowlist({ feishuUsers })} /> + setAllowlist({ feishuGroups })} /> + setAllowlist({ weixinUsers })} /> + setAllowlist({ weixinGroups })} /> +
+
+
+
+
+ + ); +} + +function ToggleSegment({ + value, + disabled, + onLabel, + offLabel, + onChange, +}: { + value: boolean; + disabled: boolean; + onLabel?: string; + offLabel?: string; + onChange: (value: boolean) => void; +}) { + const t = useT(); + return ( +
+ + +
+ ); +} + +function BotChannelCard({ + title, + description, + enabled, + secretSet, + busy, + onEnabled, + advanced, + children, +}: { + title: ReactNode; + description: ReactNode; + enabled: boolean; + secretSet: boolean; + busy: boolean; + onEnabled: (enabled: boolean) => void; + advanced?: ReactNode; + children: ReactNode; +}) { + const t = useT(); + return ( +
+
+
+ {title} + {description} +
+ + {secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")} + +
+
+ +
+
{children}
+ {advanced && ( +
+ {t("settings.botAdvancedSettings")} +
+ {advanced} +
+
+ )} +
+ ); +} + +function BotSecretField({ + label, + envName, + secretSet, + value, + busy, + onValue, + onSave, + onClear, +}: { + label: ReactNode; + envName: string; + secretSet: boolean; + value: string; + busy: boolean; + onValue: (value: string) => void; + onSave: () => void; + onClear: () => void; +}) { + const t = useT(); + return ( + +
+ onValue(e.target.value)} + /> + + {secretSet && ( + + )} +
+
+ ); +} + +function BotCardField({ label, children }: { label: ReactNode; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function BotListInput({ + label, + value, + disabled, + onChange, +}: { + label: ReactNode; + value: string[]; + disabled: boolean; + onChange: (value: string[]) => void; +}) { + const t = useT(); + return ( +