import { useState, useEffect, useCallback, useMemo } from 'react'; import { RefreshCw, Trash2, AlertCircle, Plus } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; import { ChannelConfigModal } from '@/components/channels/ChannelConfigModal'; import { cn } from '@/lib/utils'; import { CHANNEL_ICONS, CHANNEL_NAMES, CHANNEL_META, getPrimaryChannels, type ChannelType, } from '@/types/channel'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import telegramIcon from '@/assets/channels/telegram.svg'; import discordIcon from '@/assets/channels/discord.svg'; import whatsappIcon from '@/assets/channels/whatsapp.svg'; import dingtalkIcon from '@/assets/channels/dingtalk.svg'; import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; import qqIcon from '@/assets/channels/qq.svg'; interface ChannelAccountItem { accountId: string; name: string; configured: boolean; status: 'connected' | 'connecting' | 'disconnected' | 'error'; lastError?: string; isDefault: boolean; agentId?: string; } interface ChannelGroupItem { channelType: string; defaultAccountId: string; status: 'connected' | 'connecting' | 'disconnected' | 'error'; accounts: ChannelAccountItem[]; } interface AgentItem { id: string; name: string; } interface DeleteTarget { channelType: string; accountId?: string; } function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget): ChannelGroupItem[] { if (target.accountId) { return groups .map((group) => { if (group.channelType !== target.channelType) return group; return { ...group, accounts: group.accounts.filter((account) => account.accountId !== target.accountId), }; }) .filter((group) => group.accounts.length > 0); } return groups.filter((group) => group.channelType !== target.channelType); } export function Channels() { const { t } = useTranslation('channels'); const gatewayStatus = useGatewayStore((state) => state.status); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [channelGroups, setChannelGroups] = useState([]); const [agents, setAgents] = useState([]); const [showConfigModal, setShowConfigModal] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); const [selectedAccountId, setSelectedAccountId] = useState(undefined); const [allowExistingConfigInModal, setAllowExistingConfigInModal] = useState(true); const [allowEditAccountIdInModal, setAllowEditAccountIdInModal] = useState(false); const [existingAccountIdsForModal, setExistingAccountIdsForModal] = useState([]); const [initialConfigValuesForModal, setInitialConfigValuesForModal] = useState | undefined>(undefined); const [deleteTarget, setDeleteTarget] = useState(null); const displayedChannelTypes = getPrimaryChannels(); const fetchPageData = useCallback(async () => { setLoading(true); setError(null); try { const [channelsRes, agentsRes] = await Promise.all([ hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>('/api/channels/accounts'), hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'), ]); if (!channelsRes.success) { throw new Error(channelsRes.error || 'Failed to load channels'); } if (!agentsRes.success) { throw new Error(agentsRes.error || 'Failed to load agents'); } setChannelGroups(channelsRes.channels || []); setAgents(agentsRes.agents || []); } catch (fetchError) { setError(String(fetchError)); } finally { setLoading(false); } }, []); useEffect(() => { void fetchPageData(); }, [fetchPageData]); useEffect(() => { const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { void fetchPageData(); }); return () => { if (typeof unsubscribe === 'function') { unsubscribe(); } }; }, [fetchPageData]); const configuredTypes = useMemo( () => channelGroups.map((group) => group.channelType), [channelGroups], ); const groupedByType = useMemo(() => { return Object.fromEntries(channelGroups.map((group) => [group.channelType, group])); }, [channelGroups]); const configuredGroups = useMemo(() => { const known = displayedChannelTypes .map((type) => groupedByType[type]) .filter((group): group is ChannelGroupItem => Boolean(group)); const unknown = channelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType)); return [...known, ...unknown]; }, [channelGroups, displayedChannelTypes, groupedByType]); const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type)); const handleRefresh = () => { void fetchPageData(); }; const handleBindAgent = async (channelType: string, accountId: string, agentId: string) => { try { if (!agentId) { await hostApiFetch<{ success: boolean; error?: string }>('/api/channels/binding', { method: 'DELETE', body: JSON.stringify({ channelType, accountId }), }); } else { await hostApiFetch<{ success: boolean; error?: string }>('/api/channels/binding', { method: 'PUT', body: JSON.stringify({ channelType, accountId, agentId }), }); } await fetchPageData(); toast.success(t('toast.bindingUpdated')); } catch (bindError) { toast.error(t('toast.configFailed', { error: String(bindError) })); } }; const handleDelete = async () => { if (!deleteTarget) return; try { const suffix = deleteTarget.accountId ? `?accountId=${encodeURIComponent(deleteTarget.accountId)}` : ''; await hostApiFetch(`/api/channels/config/${encodeURIComponent(deleteTarget.channelType)}${suffix}`, { method: 'DELETE', }); setChannelGroups((prev) => removeDeletedTarget(prev, deleteTarget)); toast.success(deleteTarget.accountId ? t('toast.accountDeleted') : t('toast.channelDeleted')); // Channel reload is debounced in main process; pull again shortly to // converge with runtime state without flashing deleted rows back in. window.setTimeout(() => { void fetchPageData(); }, 1200); } catch (deleteError) { toast.error(t('toast.configFailed', { error: String(deleteError) })); } finally { setDeleteTarget(null); } }; const createNewAccountId = (channelType: string, existingAccounts: string[]): string => { // Generate a collision-safe default account id for user editing. let nextAccountId = `${channelType}-${crypto.randomUUID().slice(0, 8)}`; while (existingAccounts.includes(nextAccountId)) { nextAccountId = `${channelType}-${crypto.randomUUID().slice(0, 8)}`; } return nextAccountId; }; if (loading) { return (
); } return (

{t('title')}

{t('subtitle')}

{gatewayStatus.state !== 'running' && (
{t('gatewayWarning')}
)} {error && (
{error}
)} {configuredGroups.length > 0 && (

{t('configured')}

{configuredGroups.map((group) => (

{CHANNEL_NAMES[group.channelType as ChannelType] || group.channelType}

{group.channelType}

{group.accounts.map((account) => { const displayName = account.accountId === 'default' && account.name === account.accountId ? t('account.mainAccount') : account.name; return (

{displayName}

{account.lastError && (
{account.lastError}
)}
); })}
))}
)}

{t('supportedChannels')}

{unsupportedGroups.map((type) => { const meta = CHANNEL_META[type]; return ( ); })}
{showConfigModal && ( { setShowConfigModal(false); setSelectedChannelType(null); setSelectedAccountId(undefined); setAllowExistingConfigInModal(true); setAllowEditAccountIdInModal(false); setExistingAccountIdsForModal([]); setInitialConfigValuesForModal(undefined); }} onChannelSaved={async () => { await fetchPageData(); setShowConfigModal(false); setSelectedChannelType(null); setSelectedAccountId(undefined); setAllowExistingConfigInModal(true); setAllowEditAccountIdInModal(false); setExistingAccountIdsForModal([]); setInitialConfigValuesForModal(undefined); }} /> )} { void handleDelete(); }} onCancel={() => setDeleteTarget(null)} />
); } function ChannelLogo({ type }: { type: ChannelType }) { switch (type) { case 'telegram': return Telegram; case 'discord': return Discord; case 'whatsapp': return WhatsApp; case 'dingtalk': return DingTalk; case 'feishu': return Feishu; case 'wecom': return WeCom; case 'qqbot': return QQ; default: return {CHANNEL_ICONS[type] || '💬'}; } } export default Channels;