hide unavailable channels in webui

This commit is contained in:
lpf
2026-03-10 14:46:34 +08:00
parent 4a1b5f27e4
commit 594897c9bb
20 changed files with 110 additions and 30 deletions

View File

@@ -83,10 +83,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-artifacts name: release-artifacts
path: | path: build
build/*.tar.gz
build/*.zip
build/checksums.txt
if-no-files-found: error if-no-files-found: error
publish-release: publish-release:
@@ -100,6 +97,9 @@ jobs:
name: release-artifacts name: release-artifacts
path: build path: build
- name: List downloaded artifacts
run: find build -maxdepth 4 -type f | sort
- name: Resolve tag - name: Resolve tag
id: tag id: tag
run: | run: |
@@ -116,6 +116,6 @@ jobs:
name: ${{ steps.tag.outputs.name }} name: ${{ steps.tag.outputs.name }}
generate_release_notes: true generate_release_notes: true
files: | files: |
build/*.tar.gz build/**/*.tar.gz
build/*.zip build/**/*.zip
build/checksums.txt build/**/checksums.txt

View File

@@ -1188,9 +1188,10 @@ func (s *Server) handleWebUIVersion(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = json.NewEncoder(w).Encode(map[string]interface{}{ _ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true, "ok": true,
"gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()), "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()),
"webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))), "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))),
"compiled_channels": channels.CompiledChannelKeys(),
}) })
} }
@@ -1523,8 +1524,9 @@ func (s *Server) webUISubagentsRuntimePayload(ctx context.Context) map[string]in
func (s *Server) webUIVersionPayload() map[string]interface{} { func (s *Server) webUIVersionPayload() map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()), "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()),
"webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))), "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))),
"compiled_channels": channels.CompiledChannelKeys(),
} }
} }

View File

@@ -0,0 +1,30 @@
package channels
import "sort"
func CompiledChannelKeys() []string {
out := make([]string, 0, 7)
if telegramCompiled {
out = append(out, "telegram")
}
if whatsappCompiled {
out = append(out, "whatsapp")
}
if discordCompiled {
out = append(out, "discord")
}
if feishuCompiled {
out = append(out, "feishu")
}
if qqCompiled {
out = append(out, "qq")
}
if dingtalkCompiled {
out = append(out, "dingtalk")
}
if maixcamCompiled {
out = append(out, "maixcam")
}
sort.Strings(out)
return out
}

View File

@@ -30,6 +30,8 @@ type DingTalkChannel struct {
sessionWebhooks sync.Map // chatID -> sessionWebhook sessionWebhooks sync.Map // chatID -> sessionWebhook
} }
const dingtalkCompiled = true
// NewDingTalkChannel creates a new DingTalk channel instance // NewDingTalkChannel creates a new DingTalk channel instance
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
if cfg.ClientID == "" || cfg.ClientSecret == "" { if cfg.ClientID == "" || cfg.ClientSecret == "" {

View File

@@ -9,6 +9,8 @@ import (
type DingTalkChannel struct{ disabledChannel } type DingTalkChannel struct{ disabledChannel }
const dingtalkCompiled = false
func NewDingTalkChannel(cfg config.DingTalkConfig, bus *bus.MessageBus) (*DingTalkChannel, error) { func NewDingTalkChannel(cfg config.DingTalkConfig, bus *bus.MessageBus) (*DingTalkChannel, error) {
return nil, errChannelDisabled("dingtalk") return nil, errChannelDisabled("dingtalk")
} }

View File

@@ -23,6 +23,8 @@ type DiscordChannel struct {
config config.DiscordConfig config config.DiscordConfig
} }
const discordCompiled = true
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
session, err := discordgo.New("Bot " + cfg.Token) session, err := discordgo.New("Bot " + cfg.Token)
if err != nil { if err != nil {

View File

@@ -9,6 +9,8 @@ import (
type DiscordChannel struct{ disabledChannel } type DiscordChannel struct{ disabledChannel }
const discordCompiled = false
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
return nil, errChannelDisabled("discord") return nil, errChannelDisabled("discord")
} }

View File

@@ -45,6 +45,8 @@ type FeishuChannel struct {
tenantTokenErr error tenantTokenErr error
} }
const feishuCompiled = true
func (c *FeishuChannel) SupportsAction(action string) bool { func (c *FeishuChannel) SupportsAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) { switch strings.ToLower(strings.TrimSpace(action)) {
case "", "send": case "", "send":

View File

@@ -9,6 +9,8 @@ import (
type FeishuChannel struct{ disabledChannel } type FeishuChannel struct{ disabledChannel }
const feishuCompiled = false
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
return nil, errChannelDisabled("feishu") return nil, errChannelDisabled("feishu")
} }

View File

@@ -22,6 +22,8 @@ type MaixCamChannel struct {
clientsMux sync.RWMutex clientsMux sync.RWMutex
} }
const maixcamCompiled = true
type MaixCamMessage struct { type MaixCamMessage struct {
Type string `json:"type"` Type string `json:"type"`
Tips string `json:"tips"` Tips string `json:"tips"`

View File

@@ -9,6 +9,8 @@ import (
type MaixCamChannel struct{ disabledChannel } type MaixCamChannel struct{ disabledChannel }
const maixcamCompiled = false
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
return nil, errChannelDisabled("maixcam") return nil, errChannelDisabled("maixcam")
} }

View File

@@ -31,6 +31,8 @@ type QQChannel struct {
mu sync.RWMutex mu sync.RWMutex
} }
const qqCompiled = true
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom) base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)

View File

@@ -9,6 +9,8 @@ import (
type QQChannel struct{ disabledChannel } type QQChannel struct{ disabledChannel }
const qqCompiled = false
func NewQQChannel(cfg config.QQConfig, bus *bus.MessageBus) (*QQChannel, error) { func NewQQChannel(cfg config.QQConfig, bus *bus.MessageBus) (*QQChannel, error) {
return nil, errChannelDisabled("qq") return nil, errChannelDisabled("qq")
} }

View File

@@ -38,6 +38,8 @@ const (
telegramStreamMaxRetries = 4 telegramStreamMaxRetries = 4
) )
const telegramCompiled = true
type TelegramChannel struct { type TelegramChannel struct {
*BaseChannel *BaseChannel
bot *telego.Bot bot *telego.Bot

View File

@@ -9,6 +9,8 @@ import (
type TelegramChannel struct{ disabledChannel } type TelegramChannel struct{ disabledChannel }
const telegramCompiled = false
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) { func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
return nil, errChannelDisabled("telegram") return nil, errChannelDisabled("telegram")
} }

View File

@@ -30,6 +30,8 @@ type WhatsAppChannel struct {
connected bool connected bool
} }
const whatsappCompiled = true
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom)

View File

@@ -9,6 +9,8 @@ import (
type WhatsAppChannel struct{ disabledChannel } type WhatsAppChannel struct{ disabledChannel }
const whatsappCompiled = false
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
return nil, errChannelDisabled("whatsapp") return nil, errChannelDisabled("whatsapp")
} }

View File

@@ -7,7 +7,7 @@ import NavItem from './NavItem';
const Sidebar: React.FC = () => { const Sidebar: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { token, setToken, sidebarOpen, sidebarCollapsed, setSidebarCollapsed } = useAppContext(); const { token, setToken, sidebarOpen, sidebarCollapsed, setSidebarCollapsed, compiledChannels } = useAppContext();
const location = useLocation(); const location = useLocation();
const [expandedSections, setExpandedSections] = React.useState<Record<string, boolean>>({ const [expandedSections, setExpandedSections] = React.useState<Record<string, boolean>>({
main: true, main: true,
@@ -19,6 +19,16 @@ const Sidebar: React.FC = () => {
channels: false, channels: false,
}); });
const channelChildren = [
{ id: 'whatsapp', icon: <Smartphone className="w-4 h-4" />, label: t('whatsappBridge'), to: '/channels/whatsapp' },
{ id: 'telegram', icon: <Radio className="w-4 h-4" />, label: t('telegram'), to: '/channels/telegram' },
{ id: 'discord', icon: <MonitorSmartphone className="w-4 h-4" />, label: t('discord'), to: '/channels/discord' },
{ id: 'feishu', icon: <MonitorSmartphone className="w-4 h-4" />, label: t('feishu'), to: '/channels/feishu' },
{ id: 'qq', icon: <MonitorSmartphone className="w-4 h-4" />, label: t('qq'), to: '/channels/qq' },
{ id: 'dingtalk', icon: <MonitorSmartphone className="w-4 h-4" />, label: t('dingtalk'), to: '/channels/dingtalk' },
{ id: 'maixcam', icon: <MonitorSmartphone className="w-4 h-4" />, label: t('maixcam'), to: '/channels/maixcam' },
].filter((item) => compiledChannels.includes(item.id));
const sections = [ const sections = [
{ {
id: 'main', id: 'main',
@@ -54,15 +64,7 @@ const Sidebar: React.FC = () => {
icon: <Smartphone className="w-5 h-5" />, icon: <Smartphone className="w-5 h-5" />,
label: t('channelsGroup'), label: t('channelsGroup'),
childrenId: 'channels', childrenId: 'channels',
children: [ children: channelChildren,
{ icon: <Smartphone className="w-4 h-4" />, label: t('whatsappBridge'), to: '/channels/whatsapp' },
{ icon: <Radio className="w-4 h-4" />, label: t('telegram'), to: '/channels/telegram' },
{ icon: <MonitorSmartphone className="w-4 h-4" />, label: t('discord'), to: '/channels/discord' },
{ icon: <MonitorSmartphone className="w-4 h-4" />, label: t('feishu'), to: '/channels/feishu' },
{ icon: <MonitorSmartphone className="w-4 h-4" />, label: t('qq'), to: '/channels/qq' },
{ icon: <MonitorSmartphone className="w-4 h-4" />, label: t('dingtalk'), to: '/channels/dingtalk' },
{ icon: <MonitorSmartphone className="w-4 h-4" />, label: t('maixcam'), to: '/channels/maixcam' },
],
}, },
{ icon: <Plug className="w-5 h-5" />, label: t('mcpServices'), to: '/mcp' }, { icon: <Plug className="w-5 h-5" />, label: t('mcpServices'), to: '/mcp' },
{ icon: <Clock className="w-5 h-5" />, label: t('cronJobs'), to: '/cron' }, { icon: <Clock className="w-5 h-5" />, label: t('cronJobs'), to: '/cron' },
@@ -86,6 +88,10 @@ const Sidebar: React.FC = () => {
], ],
}, },
]; ];
const normalizedSections = sections.map((sec) => ({
...sec,
items: sec.items.filter((item: any) => !item.children || item.children.length > 0),
}));
const toggle = (id: string) => setExpandedSections((prev) => ({ ...prev, [id]: !prev[id] })); const toggle = (id: string) => setExpandedSections((prev) => ({ ...prev, [id]: !prev[id] }));
const isSubmenuActive = (items: Array<{ to: string }>) => items.some((item) => location.pathname === item.to); const isSubmenuActive = (items: Array<{ to: string }>) => items.some((item) => location.pathname === item.to);
@@ -93,7 +99,7 @@ const Sidebar: React.FC = () => {
return ( return (
<aside className={`sidebar-shell fixed md:static inset-y-14 md:inset-y-16 left-0 z-40 ${sidebarCollapsed ? 'md:w-20' : 'md:w-64'} w-[86vw] max-w-72 border-r border-zinc-800 backdrop-blur-xl flex flex-col shrink-0 transform transition-all duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}> <aside className={`sidebar-shell fixed md:static inset-y-14 md:inset-y-16 left-0 z-40 ${sidebarCollapsed ? 'md:w-20' : 'md:w-64'} w-[86vw] max-w-72 border-r border-zinc-800 backdrop-blur-xl flex flex-col shrink-0 transform transition-all duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}>
<nav className={`flex-1 ${sidebarCollapsed ? 'px-2' : 'px-3'} py-3 space-y-2 overflow-y-auto`}> <nav className={`flex-1 ${sidebarCollapsed ? 'px-2' : 'px-3'} py-3 space-y-2 overflow-y-auto`}>
{sections.map((sec) => ( {normalizedSections.map((sec) => (
<div key={sec.title} className={`sidebar-section rounded-2xl border border-zinc-800/60 ${sidebarCollapsed ? 'p-2' : 'p-2'}`}> <div key={sec.title} className={`sidebar-section rounded-2xl border border-zinc-800/60 ${sidebarCollapsed ? 'p-2' : 'p-2'}`}>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<button <button

View File

@@ -5,6 +5,7 @@ type RuntimeSnapshot = {
version?: { version?: {
gateway_version?: string; gateway_version?: string;
webui_version?: string; webui_version?: string;
compiled_channels?: string[];
}; };
nodes?: { nodes?: {
nodes?: any[]; nodes?: any[];
@@ -84,6 +85,7 @@ interface AppContextType {
loadConfig: (force?: boolean) => Promise<void>; loadConfig: (force?: boolean) => Promise<void>;
gatewayVersion: string; gatewayVersion: string;
webuiVersion: string; webuiVersion: string;
compiledChannels: string[];
hotReloadFields: string[]; hotReloadFields: string[];
hotReloadFieldDetails: Array<{ path: string; name?: string; description?: string }>; hotReloadFieldDetails: Array<{ path: string; name?: string; description?: string }>;
q: string; q: string;
@@ -131,6 +133,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [subagentStreamItems, setSubagentStreamItems] = useState<any[]>([]); const [subagentStreamItems, setSubagentStreamItems] = useState<any[]>([]);
const [gatewayVersion, setGatewayVersion] = useState('unknown'); const [gatewayVersion, setGatewayVersion] = useState('unknown');
const [webuiVersion, setWebuiVersion] = useState('unknown'); const [webuiVersion, setWebuiVersion] = useState('unknown');
const [compiledChannels, setCompiledChannels] = useState<string[]>(['telegram', 'whatsapp', 'discord', 'feishu', 'qq', 'dingtalk', 'maixcam']);
const [hotReloadFields, setHotReloadFields] = useState<string[]>([]); const [hotReloadFields, setHotReloadFields] = useState<string[]>([]);
const [hotReloadFieldDetails, setHotReloadFieldDetails] = useState<Array<{ path: string; name?: string; description?: string }>>([]); const [hotReloadFieldDetails, setHotReloadFieldDetails] = useState<Array<{ path: string; name?: string; description?: string }>>([]);
@@ -237,6 +240,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const j = await r.json(); const j = await r.json();
setGatewayVersion(j.gateway_version || 'unknown'); setGatewayVersion(j.gateway_version || 'unknown');
setWebuiVersion(j.webui_version || 'unknown'); setWebuiVersion(j.webui_version || 'unknown');
setCompiledChannels(Array.isArray(j.compiled_channels) ? j.compiled_channels : []);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@@ -281,6 +285,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (snapshot.version) { if (snapshot.version) {
setGatewayVersion(snapshot.version.gateway_version || 'unknown'); setGatewayVersion(snapshot.version.gateway_version || 'unknown');
setWebuiVersion(snapshot.version.webui_version || 'unknown'); setWebuiVersion(snapshot.version.webui_version || 'unknown');
setCompiledChannels(Array.isArray(snapshot.version.compiled_channels) ? snapshot.version.compiled_channels : []);
} }
if (snapshot.nodes) { if (snapshot.nodes) {
setNodes(JSON.stringify(Array.isArray(snapshot.nodes.nodes) ? snapshot.nodes.nodes : [], null, 2)); setNodes(JSON.stringify(Array.isArray(snapshot.nodes.nodes) ? snapshot.nodes.nodes : [], null, 2));
@@ -373,7 +378,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
taskQueueItems, setTaskQueueItems, ekgSummary, setEkgSummary, taskQueueItems, setTaskQueueItems, ekgSummary, setEkgSummary,
subagentRuntimeItems, setSubagentRuntimeItems, subagentRegistryItems, setSubagentRegistryItems, subagentStreamItems, setSubagentStreamItems, subagentRuntimeItems, setSubagentRuntimeItems, subagentRegistryItems, setSubagentRegistryItems, subagentStreamItems, setSubagentStreamItems,
refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshTaskQueue, refreshEKGSummary, refreshVersion, loadConfig, refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshTaskQueue, refreshEKGSummary, refreshVersion, loadConfig,
gatewayVersion, webuiVersion, hotReloadFields, hotReloadFieldDetails, q gatewayVersion, webuiVersion, compiledChannels, hotReloadFields, hotReloadFieldDetails, q
}}> }}>
{children} {children}
</AppContext.Provider> </AppContext.Provider>

View File

@@ -400,8 +400,13 @@ const ChannelSettings: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const ui = useUI(); const ui = useUI();
const { cfg, setCfg, q, loadConfig } = useAppContext(); const { cfg, setCfg, q, loadConfig, compiledChannels } = useAppContext();
const key = (channelId || 'whatsapp') as ChannelKey; const availableChannelKeys = useMemo(
() => (Object.keys(channelDefinitions) as ChannelKey[]).filter((item) => compiledChannels.includes(item)),
[compiledChannels],
);
const fallbackChannel = availableChannelKeys[0];
const key = (channelId || fallbackChannel || 'whatsapp') as ChannelKey;
const definition = channelDefinitions[key]; const definition = channelDefinitions[key];
const [draft, setDraft] = useState<Record<string, any>>({}); const [draft, setDraft] = useState<Record<string, any>>({});
@@ -409,13 +414,17 @@ const ChannelSettings: React.FC = () => {
const [waStatus, setWaStatus] = useState<WhatsAppStatusPayload | null>(null); const [waStatus, setWaStatus] = useState<WhatsAppStatusPayload | null>(null);
useEffect(() => { useEffect(() => {
if (!definition) { if (!fallbackChannel) {
navigate('/channels/whatsapp', { replace: true }); navigate('/config', { replace: true });
return;
}
if (!definition || !availableChannelKeys.includes(key)) {
navigate(`/channels/${fallbackChannel}`, { replace: true });
return; return;
} }
const next = clone(((cfg as any)?.channels?.[definition.id] || {}) as Record<string, any>); const next = clone(((cfg as any)?.channels?.[definition.id] || {}) as Record<string, any>);
setDraft(next); setDraft(next);
}, [cfg, definition, navigate]); }, [availableChannelKeys, cfg, definition, fallbackChannel, key, navigate]);
useEffect(() => { useEffect(() => {
if (key !== 'whatsapp') return; if (key !== 'whatsapp') return;
@@ -443,7 +452,7 @@ const ChannelSettings: React.FC = () => {
return `/webui/api/whatsapp/qr.svg${q}${sep}ts=${encodeURIComponent(String(updatedAt))}`; return `/webui/api/whatsapp/qr.svg${q}${sep}ts=${encodeURIComponent(String(updatedAt))}`;
}, [q, waStatus?.status?.updated_at]); }, [q, waStatus?.status?.updated_at]);
if (!definition) return null; if (!definition || !availableChannelKeys.includes(key)) return null;
const saveChannel = async () => { const saveChannel = async () => {
setSaving(true); setSaving(true);