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
This commit is contained in:
duanshuwen
2026-04-18 14:56:32 +08:00
parent dfa4388087
commit ee72cf7261
52 changed files with 6626 additions and 189 deletions

View File

@@ -0,0 +1,348 @@
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>
);
}