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:
348
src/pages/Channels/index.tsx
Normal file
348
src/pages/Channels/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user