import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Switch } from '@/components/ui/switch'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { useAgentsStore } from '@/stores/agents'; import { useGatewayStore } from '@/stores/gateway'; import { useProviderStore } from '@/stores/providers'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel'; import type { AgentSummary } from '@/types/agent'; import type { ProviderAccount, ProviderVendorInfo, ProviderWithKeyInfo } from '@/lib/providers'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import telegramIcon from '@/assets/channels/telegram.svg'; import discordIcon from '@/assets/channels/discord.svg'; import whatsappIcon from '@/assets/channels/whatsapp.svg'; import wechatIcon from '@/assets/channels/wechat.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 RuntimeProviderOption { runtimeProviderKey: string; accountId: string; label: string; modelIdPlaceholder?: string; configuredModelId?: string; } function resolveRuntimeProviderKey(account: ProviderAccount): string { if (account.authMode === 'oauth_browser') { if (account.vendorId === 'google') return 'google-gemini-cli'; if (account.vendorId === 'openai') return 'openai-codex'; } if (account.vendorId === 'custom' || account.vendorId === 'ollama') { const suffix = account.id.replace(/-/g, '').slice(0, 8); return `${account.vendorId}-${suffix}`; } if (account.vendorId === 'minimax-portal-cn') { return 'minimax-portal'; } return account.vendorId; } function splitModelRef(modelRef: string | null | undefined): { providerKey: string; modelId: string } | null { const value = (modelRef || '').trim(); if (!value) return null; const separatorIndex = value.indexOf('/'); if (separatorIndex <= 0 || separatorIndex >= value.length - 1) return null; return { providerKey: value.slice(0, separatorIndex), modelId: value.slice(separatorIndex + 1), }; } function hasConfiguredProviderCredentials( account: ProviderAccount, statusById: Map, ): boolean { if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') { return true; } return statusById.get(account.id)?.hasKey ?? false; } export function Agents() { const { t } = useTranslation('agents'); const gatewayStatus = useGatewayStore((state) => state.status); const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot); const lastGatewayStateRef = useRef(gatewayStatus.state); const { agents, loading, error, fetchAgents, createAgent, deleteAgent, } = useAgentsStore(); const [channelGroups, setChannelGroups] = useState([]); const [hasCompletedInitialLoad, setHasCompletedInitialLoad] = useState(() => agents.length > 0); const [showAddDialog, setShowAddDialog] = useState(false); const [activeAgentId, setActiveAgentId] = useState(null); const [agentToDelete, setAgentToDelete] = useState(null); const fetchChannelAccounts = useCallback(async () => { try { const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts'); setChannelGroups(response.channels || []); } catch { // Keep the last rendered snapshot when channel account refresh fails. } }, []); useEffect(() => { let mounted = true; // eslint-disable-next-line react-hooks/set-state-in-effect void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]).finally(() => { if (mounted) { setHasCompletedInitialLoad(true); } }); return () => { mounted = false; }; }, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]); useEffect(() => { const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { void fetchChannelAccounts(); }); return () => { if (typeof unsubscribe === 'function') { unsubscribe(); } }; }, [fetchChannelAccounts]); useEffect(() => { const previousGatewayState = lastGatewayStateRef.current; lastGatewayStateRef.current = gatewayStatus.state; if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') { // eslint-disable-next-line react-hooks/set-state-in-effect void fetchChannelAccounts(); } }, [fetchChannelAccounts, gatewayStatus.state]); const activeAgent = useMemo( () => agents.find((agent) => agent.id === activeAgentId) ?? null, [activeAgentId, agents], ); const visibleAgents = agents; const visibleChannelGroups = channelGroups; const isUsingStableValue = loading && hasCompletedInitialLoad; const handleRefresh = () => { void Promise.all([fetchAgents(), fetchChannelAccounts()]); }; if (loading && !hasCompletedInitialLoad) { return (
); } return (

{t('title')}

{t('subtitle')}

{gatewayStatus.state !== 'running' && (
{t('gatewayWarning')}
)} {error && (
{error}
)}
{visibleAgents.map((agent) => ( setActiveAgentId(agent.id)} onDelete={() => setAgentToDelete(agent)} /> ))}
{showAddDialog && ( setShowAddDialog(false)} onCreate={async (name, options) => { await createAgent(name, options); setShowAddDialog(false); toast.success(t('toast.agentCreated')); }} /> )} {activeAgent && ( setActiveAgentId(null)} /> )} { if (!agentToDelete) return; try { await deleteAgent(agentToDelete.id); const deletedId = agentToDelete.id; setAgentToDelete(null); if (activeAgentId === deletedId) { setActiveAgentId(null); } toast.success(t('toast.agentDeleted')); } catch (error) { toast.error(t('toast.agentDeleteFailed', { error: String(error) })); } }} onCancel={() => setAgentToDelete(null)} />
); } function AgentCard({ agent, channelGroups, onOpenSettings, onDelete, }: { agent: AgentSummary; channelGroups: ChannelGroupItem[]; onOpenSettings: () => void; onDelete: () => void; }) { const { t } = useTranslation('agents'); const boundChannelAccounts = channelGroups.flatMap((group) => group.accounts .filter((account) => account.agentId === agent.id) .map((account) => { const channelName = CHANNEL_NAMES[group.channelType as ChannelType] || group.channelType; const accountLabel = account.accountId === 'default' ? t('settingsDialog.mainAccount') : account.name || account.accountId; return `${channelName} · ${accountLabel}`; }), ); const channelsText = boundChannelAccounts.length > 0 ? boundChannelAccounts.join(', ') : t('none'); return (

{agent.name}

{agent.isDefault && ( {t('defaultBadge')} )}
{!agent.isDefault && ( )}

{t('modelLine', { model: agent.modelDisplay, suffix: agent.inheritedModel ? ` (${t('inherited')})` : '', })}

{t('channelsLine', { channels: channelsText })}

); } const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40'; const selectClasses = 'h-[44px] w-full rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground px-3'; const labelClasses = 'text-[14px] text-foreground/80 font-bold'; function ChannelLogo({ type }: { type: ChannelType }) { switch (type) { case 'telegram': return Telegram; case 'discord': return Discord; case 'whatsapp': return WhatsApp; case 'wechat': return WeChat; case 'dingtalk': return DingTalk; case 'feishu': return Feishu; case 'wecom': return WeCom; case 'qqbot': return QQ; default: return {CHANNEL_ICONS[type] || '💬'}; } } function AddAgentDialog({ onClose, onCreate, }: { onClose: () => void; onCreate: (name: string, options: { inheritWorkspace: boolean }) => Promise; }) { const { t } = useTranslation('agents'); const [name, setName] = useState(''); const [inheritWorkspace, setInheritWorkspace] = useState(false); const [saving, setSaving] = useState(false); const handleSubmit = async () => { if (!name.trim()) return; setSaving(true); try { await onCreate(name.trim(), { inheritWorkspace }); } catch (error) { toast.error(t('toast.agentCreateFailed', { error: String(error) })); setSaving(false); return; } setSaving(false); }; return (
{t('createDialog.title')} {t('createDialog.description')}
setName(event.target.value)} placeholder={t('createDialog.namePlaceholder')} className={inputClasses} />

{t('createDialog.inheritWorkspaceDescription')}

); } function AgentSettingsModal({ agent, channelGroups, onClose, }: { agent: AgentSummary; channelGroups: ChannelGroupItem[]; onClose: () => void; }) { const { t } = useTranslation('agents'); const { updateAgent, defaultModelRef } = useAgentsStore(); const [name, setName] = useState(agent.name); const [savingName, setSavingName] = useState(false); const [showModelModal, setShowModelModal] = useState(false); const [showCloseConfirm, setShowCloseConfirm] = useState(false); useEffect(() => { setName(agent.name); }, [agent.name]); const hasNameChanges = name.trim() !== agent.name; const handleRequestClose = () => { if (savingName || hasNameChanges) { setShowCloseConfirm(true); return; } onClose(); }; const handleSaveName = async () => { if (!name.trim() || name.trim() === agent.name) return; setSavingName(true); try { await updateAgent(agent.id, name.trim()); toast.success(t('toast.agentUpdated')); } catch (error) { toast.error(t('toast.agentUpdateFailed', { error: String(error) })); } finally { setSavingName(false); } }; const assignedChannels = channelGroups.flatMap((group) => group.accounts .filter((account) => account.agentId === agent.id) .map((account) => ({ channelType: group.channelType as ChannelType, accountId: account.accountId, name: account.accountId === 'default' ? t('settingsDialog.mainAccount') : account.name || account.accountId, error: account.lastError, })), ); return (
{t('settingsDialog.title', { name: agent.name })} {t('settingsDialog.description')}
setName(event.target.value)} readOnly={agent.isDefault} className={inputClasses} /> {!agent.isDefault && ( )}

{t('settingsDialog.agentIdLabel')}

{agent.id}

{t('settingsDialog.channelsTitle')}

{t('settingsDialog.channelsDescription')}

{assignedChannels.length === 0 && agent.channelTypes.length === 0 ? (
{t('settingsDialog.noChannels')}
) : (
{assignedChannels.map((channel) => (

{channel.name}

{CHANNEL_NAMES[channel.channelType]} · {channel.accountId === 'default' ? t('settingsDialog.mainAccount') : channel.accountId}

{channel.error && (

{channel.error}

)}
))} {assignedChannels.length === 0 && agent.channelTypes.length > 0 && (
{t('settingsDialog.channelsManagedInChannels')}
)}
)}
{showModelModal && ( setShowModelModal(false)} /> )} { setShowCloseConfirm(false); setName(agent.name); onClose(); }} onCancel={() => setShowCloseConfirm(false)} />
); } function AgentModelModal({ agent, onClose, }: { agent: AgentSummary; onClose: () => void; }) { const { t } = useTranslation('agents'); const providerAccounts = useProviderStore((state) => state.accounts); const providerStatuses = useProviderStore((state) => state.statuses); const providerVendors = useProviderStore((state) => state.vendors); const providerDefaultAccountId = useProviderStore((state) => state.defaultAccountId); const { updateAgentModel, defaultModelRef } = useAgentsStore(); const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState(''); const [modelIdInput, setModelIdInput] = useState(''); const [savingModel, setSavingModel] = useState(false); const [showCloseConfirm, setShowCloseConfirm] = useState(false); const runtimeProviderOptions = useMemo(() => { const vendorMap = new Map(providerVendors.map((vendor) => [vendor.id, vendor])); const statusById = new Map(providerStatuses.map((status) => [status.id, status])); const entries = providerAccounts .filter((account) => account.enabled && hasConfiguredProviderCredentials(account, statusById)) .sort((left, right) => { if (left.id === providerDefaultAccountId) return -1; if (right.id === providerDefaultAccountId) return 1; return right.updatedAt.localeCompare(left.updatedAt); }); const deduped = new Map(); for (const account of entries) { const runtimeProviderKey = resolveRuntimeProviderKey(account); if (!runtimeProviderKey || deduped.has(runtimeProviderKey)) continue; const vendor = vendorMap.get(account.vendorId); const label = `${account.label} (${vendor?.name || account.vendorId})`; const configuredModelId = account.model ? (account.model.startsWith(`${runtimeProviderKey}/`) ? account.model.slice(runtimeProviderKey.length + 1) : account.model) : undefined; deduped.set(runtimeProviderKey, { runtimeProviderKey, accountId: account.id, label, modelIdPlaceholder: vendor?.modelIdPlaceholder, configuredModelId, }); } return [...deduped.values()]; }, [providerAccounts, providerDefaultAccountId, providerStatuses, providerVendors]); useEffect(() => { const override = splitModelRef(agent.overrideModelRef); if (override) { setSelectedRuntimeProviderKey(override.providerKey); setModelIdInput(override.modelId); return; } const effective = splitModelRef(agent.modelRef || defaultModelRef); if (effective) { setSelectedRuntimeProviderKey(effective.providerKey); setModelIdInput(effective.modelId); return; } setSelectedRuntimeProviderKey(runtimeProviderOptions[0]?.runtimeProviderKey || ''); setModelIdInput(''); }, [agent.modelRef, agent.overrideModelRef, defaultModelRef, runtimeProviderOptions]); const selectedProvider = runtimeProviderOptions.find((option) => option.runtimeProviderKey === selectedRuntimeProviderKey) || null; const trimmedModelId = modelIdInput.trim(); const nextModelRef = selectedRuntimeProviderKey && trimmedModelId ? `${selectedRuntimeProviderKey}/${trimmedModelId}` : ''; const normalizedDefaultModelRef = (defaultModelRef || '').trim(); const isUsingDefaultModelInForm = Boolean(normalizedDefaultModelRef) && nextModelRef === normalizedDefaultModelRef; const currentOverrideModelRef = (agent.overrideModelRef || '').trim(); const desiredOverrideModelRef = nextModelRef && nextModelRef !== normalizedDefaultModelRef ? nextModelRef : null; const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef; const handleRequestClose = () => { if (savingModel || modelChanged) { setShowCloseConfirm(true); return; } onClose(); }; const handleSaveModel = async () => { if (!selectedRuntimeProviderKey) { toast.error(t('toast.agentModelProviderRequired')); return; } if (!trimmedModelId) { toast.error(t('toast.agentModelIdRequired')); return; } if (!modelChanged) return; if (!nextModelRef.includes('/')) { toast.error(t('toast.agentModelInvalid')); return; } setSavingModel(true); try { await updateAgentModel(agent.id, desiredOverrideModelRef); toast.success(desiredOverrideModelRef ? t('toast.agentModelUpdated') : t('toast.agentModelReset')); onClose(); } catch (error) { toast.error(t('toast.agentModelUpdateFailed', { error: String(error) })); } finally { setSavingModel(false); } }; const handleUseDefaultModel = () => { const parsedDefault = splitModelRef(normalizedDefaultModelRef); if (!parsedDefault) { setSelectedRuntimeProviderKey(''); setModelIdInput(''); return; } setSelectedRuntimeProviderKey(parsedDefault.providerKey); setModelIdInput(parsedDefault.modelId); }; return (
{t('settingsDialog.modelLabel')} {t('settingsDialog.modelOverrideDescription', { defaultModel: defaultModelRef || '-' })}
setModelIdInput(event.target.value)} placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || t('settingsDialog.modelIdPlaceholder')} className={inputClasses} />
{!!nextModelRef && (

{t('settingsDialog.modelPreview')}: {nextModelRef}

)} {runtimeProviderOptions.length === 0 && (

{t('settingsDialog.modelProviderEmpty')}

)}
{ setShowCloseConfirm(false); onClose(); }} onCancel={() => setShowCloseConfirm(false)} />
); } export default Agents;