Files
zn-ai/src/pages/Channels/index.tsx
duanshuwen ee72cf7261 feat: refactor HomePage to integrate agents store and update related components
feat: add runtime event handling for providers in ProvidersSection

feat: update routing to include Channels and Agents pages

feat: extend route types and navigation items for Channels and Agents

feat: implement agents store for managing agent data and interactions

fix: update chat store to utilize agents store for agent-related functionality

chore: export agents store from index

fix: enhance runtime types for better event handling

fix: update Vite config to handle dev server URL correctly
2026-04-18 14:56:32 +08:00

349 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 | false | null | undefined>): 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<string, unknown> {
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, string>): 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<ChannelAccountCatalogGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState<string | null>(null);
const agentNames = useMemo(
() => new Map(agents.map((agent) => [agent.id, agent.name])),
[agents],
);
async function loadChannels(): Promise<void> {
setLoading(true);
setError(null);
try {
const response = await hostApiFetch<ChannelAccountsCatalogResponse>('/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<void> {
setFeedback(null);
await Promise.allSettled([agentsStore.refresh(), loadChannels()]);
}
async function handleChannelOwnerChange(channelType: string, nextOwnerId: string): Promise<void> {
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<void> {
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 (
<section className="h-full w-full min-h-0">
<div className="flex h-full w-full min-h-0 flex-col rounded-[16px] bg-white p-[20px] dark:bg-[#1b1b1d]">
<div className="mb-[20px] flex flex-col gap-4 border-b border-[#E5E8EE] pb-[20px] dark:border-[#2a2a2d] md:flex-row md:items-end md:justify-between">
<div className="min-w-0">
<span className="text-[24px] font-medium leading-[32px] text-[#171717] dark:text-gray-100">
Channels
</span>
<span className="block max-w-3xl pt-[3px] text-[12px] leading-[16px] text-[#99A0AE] dark:text-gray-500">
channel/account Agent Agents ClawX
</span>
</div>
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border border-[#E5E8EE] px-4 text-[12px] text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:text-gray-300 dark:hover:border-[#3b82f6] dark:hover:text-white"
disabled={loading || agentsLoading}
onClick={() => {
void handleRefresh();
}}
>
<RefreshCw className={cn('h-3.5 w-3.5', (loading || agentsLoading) && 'animate-spin')} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-10 pr-2">
{feedback ? (
<div className="mb-4 rounded-[14px] border border-[#DCE5F1] bg-[#F7FAFF] px-4 py-3 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#20243a] dark:text-gray-300">
{feedback}
</div>
) : null}
{error ? (
<div className="mb-4 rounded-[14px] border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
</div>
) : null}
{agentsError ? (
<div className="mb-4 rounded-[14px] border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{`Agents 数据加载失败:${agentsError}`}</span>
</div>
</div>
) : null}
{loading && groups.length === 0 ? (
<div className="flex h-full items-center justify-center rounded-[16px] bg-[#FAFBFC] text-sm text-[#99A0AE] dark:bg-[#202024] dark:text-gray-500">
...
</div>
) : groups.length === 0 ? (
<div className="rounded-[16px] border border-dashed border-[#DCE5F1] bg-[#FAFBFC] px-4 py-6 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-300">
<div className="text-[15px] font-medium text-[#171717] dark:text-gray-100"></div>
<div className="mt-1 text-[13px] leading-[20px] text-[#99A0AE] dark:text-gray-500">
channel/account
</div>
</div>
) : (
<div className="space-y-4">
{groups.map((group) => {
const channelOwnerId = channelOwners[group.channelType] || '';
const channelPending = pendingKey === `channel:${group.channelType}`;
return (
<article
key={group.channelType}
className="rounded-[18px] border border-[#E5E8EE] bg-[#FAFBFC] p-5 shadow-[0_14px_40px_rgba(15,23,42,0.04)] dark:border-[#2a2a2d] dark:bg-[#1f1f22]"
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#EFF6FF] text-[#2B7FFF] dark:bg-[#1d2633] dark:text-[#93c5fd]">
<Link2 className="h-4 w-4" />
</div>
<div>
<div className="text-[16px] font-semibold text-[#171717] dark:text-gray-100">
{group.channelLabel}
</div>
<div className="mt-1 text-[12px] text-[#99A0AE] dark:text-gray-500">
{group.channelType}
</div>
</div>
</div>
<div className="mt-3 rounded-[12px] border border-dashed border-[#DCE5F1] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
Agent
</div>
</div>
<div className="w-full lg:max-w-[280px]">
<div className="text-[12px] font-medium text-[#525866] dark:text-gray-300"></div>
<select
value={channelOwnerId}
disabled={channelPending || agents.length === 0}
onChange={(event) => {
void handleChannelOwnerChange(group.channelType, event.target.value);
}}
className="mt-2 w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[13px] text-[#171717] outline-none transition-colors focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
>
<option value=""></option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 space-y-3">
{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 (
<div
key={bindingKey}
className="rounded-[14px] border border-[#E5E8EE] bg-white px-4 py-4 dark:border-[#2a2a2d] dark:bg-[#17171a]"
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-[14px] font-semibold text-[#171717] dark:text-gray-100">
{account.name || account.accountId}
</div>
{account.isDefault ? (
<span className="rounded-full bg-[#EFF6FF] px-2.5 py-1 text-[11px] font-medium text-[#2B7FFF] dark:bg-[#1d2633] dark:text-[#93c5fd]">
</span>
) : null}
<span className="rounded-full border border-[#E5E8EE] px-2.5 py-1 text-[11px] text-[#525866] dark:border-[#2a2a2d] dark:text-gray-300">
{bindingScope}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-3 text-[12px] text-[#99A0AE] dark:text-gray-500">
<span>{`当前归属:${resolveAgentName(effectiveOwnerId, agentNames)}`}</span>
{account.channelUrl ? <span className="truncate">{account.channelUrl}</span> : null}
</div>
</div>
<div className="w-full lg:max-w-[280px]">
<div className="flex items-center gap-2 text-[12px] font-medium text-[#525866] dark:text-gray-300">
<Bot className="h-3.5 w-3.5" />
</div>
<select
value={explicitOwnerId}
disabled={accountPending || agents.length === 0}
onChange={(event) => {
void handleAccountOwnerChange(group.channelType, account.accountId, event.target.value);
}}
className="mt-2 w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[13px] text-[#171717] outline-none transition-colors focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
>
<option value=""></option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
</div>
</div>
);
})}
</div>
</article>
);
})}
</div>
)}
</div>
</div>
</section>
);
}