import { useEffect, useMemo, useState } from 'react'; import { AlertCircle, Bot, Link2, RefreshCw } from 'lucide-react'; import type { ChannelAccountCatalogGroup, ChannelAccountsCatalogResponse } from '../../lib/channel-types'; import { onGatewayEvent } from '../../lib/gateway-client'; import { hostApiFetch } from '../../lib/host-api'; import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events'; import { agentsStore, useAgentsStore } from '../../stores'; function cn(...tokens: Array): string { return tokens.filter(Boolean).join(' '); } function formatChannelLabel(channelType: string, fallback?: string): string { if (fallback) return fallback; const parts = String(channelType ?? '') .split(/[-_]/) .map((part) => part.trim()) .filter(Boolean); if (parts.length === 0) return channelType; return parts .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function normalizeGroups(payload: unknown): ChannelAccountCatalogGroup[] { const groups = Array.isArray(payload) ? payload : isRecord(payload) && Array.isArray(payload.channels) ? payload.channels : []; return groups .filter((group): group is ChannelAccountCatalogGroup => isRecord(group)) .map((group) => ({ channelType: String(group.channelType ?? '').trim(), channelLabel: formatChannelLabel(String(group.channelType ?? '').trim(), String(group.channelLabel ?? '').trim() || undefined), defaultAccountId: String(group.defaultAccountId ?? '').trim(), status: group.status ?? 'connected', accounts: Array.isArray(group.accounts) ? group.accounts .filter((account) => isRecord(account)) .map((account) => ({ accountId: String(account.accountId ?? '').trim(), name: String(account.name ?? account.accountId ?? '').trim(), configured: account.configured !== false, status: account.status ?? 'connected', lastError: typeof account.lastError === 'string' ? account.lastError : undefined, isDefault: Boolean(account.isDefault), agentId: typeof account.agentId === 'string' ? account.agentId : undefined, bindingScope: account.bindingScope === 'account' || account.bindingScope === 'channel' ? account.bindingScope : undefined, channelUrl: typeof account.channelUrl === 'string' ? account.channelUrl : undefined, })) .filter((account) => account.accountId) : [], })) .filter((group) => group.channelType) .sort((left, right) => left.channelLabel.localeCompare(right.channelLabel, 'zh-CN')); } function resolveAgentName(agentId: string | null | undefined, agentNames: Map): string { const resolvedId = String(agentId ?? '').trim(); if (!resolvedId) return '未分配'; return agentNames.get(resolvedId) || resolvedId; } export default function ChannelsPage() { const agents = useAgentsStore((state) => state.agents); const agentsLoading = useAgentsStore((state) => state.loading); const agentsError = useAgentsStore((state) => state.error); const channelOwners = useAgentsStore((state) => state.channelOwners); const channelAccountOwners = useAgentsStore((state) => state.channelAccountOwners); const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [feedback, setFeedback] = useState(null); const [pendingKey, setPendingKey] = useState(null); const agentNames = useMemo( () => new Map(agents.map((agent) => [agent.id, agent.name])), [agents], ); async function loadChannels(): Promise { setLoading(true); setError(null); try { const response = await hostApiFetch('/api/channels/accounts'); setGroups(normalizeGroups(response)); } catch (requestError) { setError(requestError instanceof Error ? requestError.message : String(requestError)); setGroups([]); } finally { setLoading(false); } } useEffect(() => { void Promise.all([agentsStore.init(), loadChannels()]); }, []); useEffect(() => { return onGatewayEvent((event) => { if (!isRuntimeChangedGatewayEvent(event)) return; if (!runtimeEventHasTopic(event, 'channels', 'agents', 'providers', 'channel-targets')) return; void loadChannels(); }); }, []); async function handleRefresh(): Promise { setFeedback(null); await Promise.allSettled([agentsStore.refresh(), loadChannels()]); } async function handleChannelOwnerChange(channelType: string, nextOwnerId: string): Promise { const currentOwnerId = channelOwners[channelType] || ''; if (currentOwnerId === nextOwnerId) return; setPendingKey(`channel:${channelType}`); setFeedback(null); try { if (nextOwnerId) { await agentsStore.assignChannel(nextOwnerId, channelType); setFeedback(`${formatChannelLabel(channelType)} 的频道级归属已更新。`); } else if (currentOwnerId) { await agentsStore.removeChannel(currentOwnerId, channelType); setFeedback(`${formatChannelLabel(channelType)} 的频道级归属已清除。`); } } catch (requestError) { setFeedback(requestError instanceof Error ? requestError.message : String(requestError)); } finally { setPendingKey(null); } } async function handleAccountOwnerChange(channelType: string, accountId: string, nextOwnerId: string): Promise { const bindingKey = `${channelType}:${accountId}`; const currentOwnerId = channelAccountOwners[bindingKey] || ''; if (currentOwnerId === nextOwnerId) return; setPendingKey(bindingKey); setFeedback(null); try { if (nextOwnerId) { await agentsStore.assignChannelAccount(nextOwnerId, channelType, accountId); setFeedback(`${formatChannelLabel(channelType)} / ${accountId} 的账号归属已更新。`); } else { await agentsStore.clearChannelBinding(channelType, accountId); setFeedback(`${formatChannelLabel(channelType)} / ${accountId} 的账号归属已清除。`); } } catch (requestError) { setFeedback(requestError instanceof Error ? requestError.message : String(requestError)); } finally { setPendingKey(null); } } return (
Channels 在这里统一管理 channel/account 到 Agent 的归属,让 Agents 页面只负责模型与摘要展示,更接近 ClawX 的职责边界。
{feedback ? (
{feedback}
) : null} {error ? (
{error}
) : null} {agentsError ? (
{`Agents 数据加载失败:${agentsError}`}
) : null} {loading && groups.length === 0 ? (
正在加载渠道目录...
) : groups.length === 0 ? (
暂无已配置渠道
先在首页任务中心里配置可用渠道,这里就会出现对应的 channel/account 归属入口。
) : (
{groups.map((group) => { const channelOwnerId = channelOwners[group.channelType] || ''; const channelPending = pendingKey === `channel:${group.channelType}`; return (
{group.channelLabel}
{group.channelType}
频道级兜底归属会在账号没有单独指定 Agent 时生效。
频道级归属
{group.accounts.map((account) => { const bindingKey = `${group.channelType}:${account.accountId}`; const explicitOwnerId = channelAccountOwners[bindingKey] || ''; const effectiveOwnerId = explicitOwnerId || channelOwnerId; const bindingScope = explicitOwnerId ? '账号归属' : channelOwnerId ? '继承频道' : '未分配'; const accountPending = pendingKey === bindingKey; return (
{account.name || account.accountId}
{account.isDefault ? ( 默认账号 ) : null} {bindingScope}
{`当前归属:${resolveAgentName(effectiveOwnerId, agentNames)}`} {account.channelUrl ? {account.channelUrl} : null}
账号归属
); })}
); })}
)}
); }