| null>(null)
+ const pollGenerationRef = useRef(0)
+ const isBound = isEdit && asString(config.account_id) !== ""
+ const existingAccountID = asString(config.account_id)
+
+ const stopPolling = useCallback(() => {
+ pollGenerationRef.current += 1
+ if (pollTimerRef.current !== null) {
+ clearInterval(pollTimerRef.current)
+ pollTimerRef.current = null
+ }
+ }, [])
+
+ useEffect(() => () => stopPolling(), [stopPolling])
+
+ useEffect(() => {
+ if (!existingAccountID) return
+ stopPolling()
+ setAccountID(existingAccountID)
+ setBindState("confirmed")
+ setErrorMsg("")
+ }, [existingAccountID, 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 pollWeixinFlow(id)
+ if (generation !== pollGenerationRef.current) {
+ return
+ }
+ if (resp.status === "scaned") {
+ setBindState("scaned")
+ } else if (resp.status === "confirmed") {
+ stopPolling()
+ setAccountID(resp.account_id ?? existingAccountID ?? 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.weixin.errorGeneric"))
+ }
+ } catch {
+ // transient network error — keep polling
+ } finally {
+ inFlight = false
+ }
+ }, 2000)
+ },
+ [existingAccountID, stopPolling, onBindSuccess, t],
+ )
+
+ const handleBind = async () => {
+ setBindState("loading")
+ setErrorMsg("")
+ setQrDataURI(null)
+ stopPolling()
+ try {
+ const resp = await startWeixinFlow()
+ setQrDataURI(resp.qr_data_uri ?? null)
+ setBindState("waiting")
+ startPolling(resp.flow_id)
+ } catch (e) {
+ setBindState("error")
+ setErrorMsg(
+ e instanceof Error ? e.message : t("channels.weixin.errorGeneric"),
+ )
+ }
+ }
+
+ const handleRebind = () => {
+ stopPolling()
+ setBindState("idle")
+ setQrDataURI(null)
+ setAccountID(null)
+ setErrorMsg("")
+ void handleBind()
+ }
+
+ const renderBindSection = () => {
+ if (bindState === "idle") {
+ if (isBound) {
+ return (
+
+
+
+ {t("channels.weixin.bound")}
+
+ {existingAccountID && (
+
+ {existingAccountID}
+
+ )}
+
+
+ )
+ }
+ return (
+
+
+ {t("channels.weixin.notBound")}
+
+
+
+ )
+ }
+
+ if (bindState === "loading") {
+ return (
+
+
+
+ {t("channels.weixin.generating")}
+
+
+ )
+ }
+
+ if (bindState === "waiting" || bindState === "scaned") {
+ return (
+
+ {qrDataURI ? (
+

+ ) : (
+
+
+
+ )}
+ {bindState === "scaned" ? (
+
+
+ {t("channels.weixin.scanned")}
+
+ ) : (
+
+ {t("channels.weixin.scanHint")}
+
+ )}
+
+
+ )
+ }
+
+ if (bindState === "confirmed") {
+ return (
+
+
+
+
+
+ {t("channels.weixin.bound")}
+
+ {accountID && (
+
+ {accountID}
+
+ )}
+
+
+ )
+ }
+
+ if (bindState === "expired") {
+ return (
+
+
+
+
+
+ {t("channels.weixin.expired")}
+
+
+
+ )
+ }
+
+ if (bindState === "error") {
+ return (
+
+
+
+
+
+ {errorMsg || t("channels.weixin.errorGeneric")}
+
+
+
+ )
+ }
+
+ return null
+ }
+
+ return (
+
+ {/* QR Bind Section */}
+
+
+
+ {t("channels.weixin.bindTitle")}
+
+
+ {t("channels.weixin.bindDesc")}
+
+
+ {renderBindSection()}
+
+
+ {/* allow_from */}
+
+
+ onChange(
+ "allow_from",
+ e.target.value
+ .split(",")
+ .map((s: string) => s.trim())
+ .filter(Boolean),
+ )
+ }
+ placeholder={t("channels.field.allowFromPlaceholder")}
+ />
+
+
+ {/* proxy */}
+
+ onChange("proxy", e.target.value)}
+ placeholder="http://localhost:7890"
+ />
+
+
+ )
+}
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx
index 84978e907..0035f14fa 100644
--- a/web/frontend/src/components/chat/user-message.tsx
+++ b/web/frontend/src/components/chat/user-message.tsx
@@ -5,7 +5,7 @@ interface UserMessageProps {
export function UserMessage({ content }: UserMessageProps) {
return (
-
diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx
index 5482b0a35..1f7426d22 100644
--- a/web/frontend/src/components/config/config-sections.tsx
+++ b/web/frontend/src/components/config/config-sections.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
@@ -7,6 +8,7 @@ import {
type LauncherForm,
} from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form"
+import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
@@ -201,6 +203,56 @@ interface ExecSectionProps {
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
const { t } = useTranslation()
+ const [testCommand, setTestCommand] = useState("")
+ const [testResult, setTestResult] = useState<{
+ allowed: boolean
+ blocked: boolean
+ matchedWhitelist: string | null
+ matchedBlacklist: string | null
+ } | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const testPatterns = async () => {
+ if (!testCommand.trim()) {
+ setTestResult(null)
+ return
+ }
+
+ const allowPatterns = form.customAllowPatternsText
+ .split("\n")
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0)
+ const denyPatterns = form.enableDenyPatterns
+ ? form.customDenyPatternsText
+ .split("\n")
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0)
+ : []
+
+ setIsLoading(true)
+ try {
+ const res = await fetch("/api/config/test-command-patterns", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ allow_patterns: allowPatterns,
+ deny_patterns: denyPatterns,
+ command: testCommand,
+ }),
+ })
+ const data = await res.json()
+ setTestResult({
+ allowed: data.allowed,
+ blocked: data.blocked,
+ matchedWhitelist: data.matched_whitelist ?? null,
+ matchedBlacklist: data.matched_blacklist ?? null,
+ })
+ } catch {
+ setTestResult(null)
+ } finally {
+ setIsLoading(false)
+ }
+ }
return (
@@ -266,6 +318,50 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
/>
+
+
+
+ setTestCommand(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ testPatterns()
+ }
+ }}
+ />
+
+
+ {testResult && (
+
+ {testResult.allowed
+ ? `${t("pages.config.pattern_detector_result_allowed")}${testResult.matchedWhitelist ? ` (${testResult.matchedWhitelist})` : ""}`
+ : testResult.blocked
+ ? `${t("pages.config.pattern_detector_result_blocked")}${testResult.matchedBlacklist ? ` (${testResult.matchedBlacklist})` : ""}`
+ : t("pages.config.pattern_detector_result_no_match")}
+
+ )}
+
+
+
)}
+ {model.is_virtual && (
+
+ {t("models.badge.virtual")}
+
+ )}
diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts
index 5579a955b..22fc24e57 100644
--- a/web/frontend/src/hooks/use-sidebar-channels.ts
+++ b/web/frontend/src/hooks/use-sidebar-channels.ts
@@ -28,15 +28,10 @@ import { getChannelDisplayName } from "@/components/channels/channel-display-nam
import { gatewayAtom } from "@/store/gateway"
const DEFAULT_VISIBLE_CHANNELS = 4
-const CHANNEL_IMPORTANCE_ORDER = [
- "discord",
- "feishu",
- "telegram",
+const CHANNEL_IMPORTANCE_TAIL = [
"slack",
"line",
"wecom",
- "wecom_app",
- "wecom_aibot",
"dingtalk",
"qq",
"onebot",
@@ -47,9 +42,13 @@ const CHANNEL_IMPORTANCE_ORDER = [
"whatsapp",
"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 }) {
return React.createElement("span", {
@@ -75,9 +74,8 @@ const CHANNEL_ICON_MAP: Record<
dingtalk: IconBrandDingtalk,
line: IconBrandLine,
qq: IconBrandQq,
+ weixin: IconBrandWechat,
wecom: IconBrandWechat,
- wecom_app: IconBrandWechat,
- wecom_aibot: IconBrandWechat,
whatsapp: IconBrandWhatsapp,
whatsapp_native: IconBrandWhatsapp,
matrix: IconBrandMatrix,
@@ -134,10 +132,11 @@ export interface SidebarChannelNavItem {
}
interface UseSidebarChannelsOptions {
+ language: string
t: TFunction
}
-export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
+export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) {
const gateway = useAtomValue(gatewayAtom)
const [channels, setChannels] = React.useState([])
const [enabledMap, setEnabledMap] = React.useState>(
@@ -183,6 +182,12 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
previousGatewayStatusRef.current = gateway.status
}, [gateway.status, reloadChannels])
+ const channelImportanceIndex = React.useMemo(() => {
+ return new Map(
+ getChannelImportanceOrder(language).map((name, index) => [name, index]),
+ )
+ }, [language])
+
const sortedChannels = React.useMemo(() => {
const list = [...channels]
list.sort((a, b) => {
@@ -193,9 +198,9 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
}
const aImportance =
- CHANNEL_IMPORTANCE_INDEX.get(a.name) ?? Number.MAX_SAFE_INTEGER
+ channelImportanceIndex.get(a.name) ?? Number.MAX_SAFE_INTEGER
const bImportance =
- CHANNEL_IMPORTANCE_INDEX.get(b.name) ?? Number.MAX_SAFE_INTEGER
+ channelImportanceIndex.get(b.name) ?? Number.MAX_SAFE_INTEGER
if (aImportance !== bImportance) {
return aImportance - bImportance
}
@@ -205,7 +210,7 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
)
})
return list
- }, [channels, enabledMap, t])
+ }, [channelImportanceIndex, channels, enabledMap, t])
const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS
const visibleChannels = showAllChannels
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 66e39ad0e..eebf6e9fc 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -154,7 +154,8 @@
"unconfigured": "Not configured"
},
"badge": {
- "default": "Default"
+ "default": "Default",
+ "virtual": "Virtual"
},
"action": {
"edit": "Edit API key",
@@ -233,14 +234,31 @@
"qq": "QQ",
"onebot": "OneBot",
"wecom": "WeCom",
- "wecom_app": "WeCom App",
- "wecom_aibot": "WeCom AI Bot",
"whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native",
"pico": "Web",
"maixcam": "MaixCam",
"matrix": "Matrix",
- "irc": "IRC"
+ "irc": "IRC",
+ "weixin": "WeChat"
+ },
+ "weixin": {
+ "warningTitle": "Testing phase, use with caution",
+ "warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.",
+ "bindEnableSuccess": "WeChat connected and the channel has been enabled automatically.",
+ "bindTitle": "WeChat Account Binding",
+ "bindDesc": "Scan the QR code with WeChat to bind your personal account.",
+ "bind": "Bind WeChat",
+ "rebind": "Re-bind",
+ "bound": "WeChat Bound",
+ "notBound": "WeChat account not bound yet.",
+ "generating": "Generating QR code...",
+ "scanHint": "Open WeChat and scan the QR code",
+ "scanned": "Scanned — please confirm in WeChat",
+ "expired": "QR code expired",
+ "retry": "Try Again",
+ "refresh": "Refresh QR",
+ "errorGeneric": "An error occurred. Please try again."
},
"field": {
"token": "Bot Token",
@@ -273,7 +291,9 @@
"saveError": "Failed to save channel configuration",
"enabled": "enabled",
"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": {
"desc": {
@@ -413,6 +433,13 @@
"custom_allow_patterns": "Command Whitelist",
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
+ "pattern_detector_title": "Pattern Detection Tool",
+ "pattern_detector_hint": "Enter a command to test if it matches any blacklist or whitelist patterns.",
+ "pattern_detector_input_placeholder": "Enter a command to test, e.g., rm -rf /tmp",
+ "pattern_detector_test_button": "Test",
+ "pattern_detector_result_allowed": "Allowed (matches whitelist)",
+ "pattern_detector_result_blocked": "Blocked (matches blacklist)",
+ "pattern_detector_result_no_match": "No match (will use default rules)",
"allow_shell_execution": "Allow Scheduled Commands",
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
"cron_exec_timeout": "Scheduled Command Timeout (minutes)",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index 65f2a5548..848bea15f 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -154,7 +154,8 @@
"unconfigured": "未配置"
},
"badge": {
- "default": "默认"
+ "default": "默认",
+ "virtual": "虚拟"
},
"action": {
"edit": "编辑 API Key",
@@ -233,14 +234,31 @@
"qq": "QQ",
"onebot": "OneBot",
"wecom": "企业微信",
- "wecom_app": "企业微信应用",
- "wecom_aibot": "企业微信 AI 机器人",
"whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native",
"pico": "Web",
"maixcam": "MaixCam",
"matrix": "Matrix",
- "irc": "IRC"
+ "irc": "IRC",
+ "weixin": "微信"
+ },
+ "weixin": {
+ "warningTitle": "测试阶段,请谨慎使用",
+ "warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
+ "bindEnableSuccess": "微信已连接,频道已自动启用。",
+ "bindTitle": "微信账号绑定",
+ "bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
+ "bind": "绑定微信",
+ "rebind": "重新绑定",
+ "bound": "微信已绑定",
+ "notBound": "尚未绑定微信账号。",
+ "generating": "正在生成二维码...",
+ "scanHint": "打开微信,扫描二维码",
+ "scanned": "已扫码 — 请在微信中确认",
+ "expired": "二维码已过期",
+ "retry": "重试",
+ "refresh": "刷新二维码",
+ "errorGeneric": "发生错误,请重试。"
},
"field": {
"token": "Bot Token",
@@ -273,7 +291,9 @@
"saveError": "保存频道配置失败",
"enabled": "已启用",
"docLink": "配置文档",
- "enableLabel": "启用频道"
+ "enableLabel": "启用频道",
+ "restartRequiredTitle": "需要重启服务",
+ "restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
},
"form": {
"desc": {
@@ -413,6 +433,13 @@
"custom_allow_patterns": "命令白名单",
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
+ "pattern_detector_title": "规则检测工具",
+ "pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则。",
+ "pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
+ "pattern_detector_test_button": "检测",
+ "pattern_detector_result_allowed": "允许(匹配白名单)",
+ "pattern_detector_result_blocked": "阻止(匹配黑名单)",
+ "pattern_detector_result_no_match": "无匹配(将使用默认规则)",
"allow_shell_execution": "允许定时任务运行命令",
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
"cron_exec_timeout": "定时命令超时(分钟)",