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:
145
src/pages/Agents/components/AddAgentDialog.tsx
Normal file
145
src/pages/Agents/components/AddAgentDialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import AgentsDialogSurface from './AgentsDialogSurface';
|
||||
|
||||
const INPUT_CLASS_NAME = [
|
||||
'h-[88px] w-full rounded-[26px] border border-black/10 bg-[#F8F4EC] px-7',
|
||||
'text-[22px] text-[#171717] outline-none transition-colors placeholder:text-[#9A958C]',
|
||||
'focus:border-black/20 dark:border-white/10 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500',
|
||||
].join(' ');
|
||||
|
||||
type AddAgentDialogCopy = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
nameLabel: string;
|
||||
namePlaceholder: string;
|
||||
inheritWorkspaceLabel: string;
|
||||
inheritWorkspaceDescription: string;
|
||||
cancelLabel: string;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
};
|
||||
|
||||
type AddAgentDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
copy: AddAgentDialogCopy;
|
||||
onClose: () => void;
|
||||
onSave: (input: { name: string; inheritWorkspace: boolean }) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={[
|
||||
'relative inline-flex h-12 w-24 shrink-0 items-center rounded-full transition-colors',
|
||||
checked ? 'bg-[#DDE8FF]' : 'bg-[#E8E1D3]',
|
||||
disabled ? 'cursor-not-allowed opacity-60' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute left-1.5 top-1.5 h-9 w-9 rounded-full bg-white shadow-[0_4px_12px_rgba(15,23,42,0.12)] transition-transform',
|
||||
checked ? 'translate-x-11' : '',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddAgentDialog({
|
||||
open,
|
||||
saving,
|
||||
copy,
|
||||
onClose,
|
||||
onSave,
|
||||
}: AddAgentDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [inheritWorkspace, setInheritWorkspace] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName('');
|
||||
setInheritWorkspace(false);
|
||||
}, [open]);
|
||||
|
||||
const canSave = name.trim().length > 0 && !saving;
|
||||
|
||||
return (
|
||||
<AgentsDialogSurface
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={copy.title}
|
||||
subtitle={copy.subtitle}
|
||||
widthClassName="max-w-[894px]"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
<label className="mb-4 block text-[22px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={copy.namePlaceholder}
|
||||
className={INPUT_CLASS_NAME}
|
||||
autoFocus
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex items-center justify-between gap-8">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[22px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.inheritWorkspaceLabel}
|
||||
</div>
|
||||
<p className="mt-2 max-w-[560px] text-[16px] leading-[1.6] text-[#646C7A] dark:text-gray-400">
|
||||
{copy.inheritWorkspaceDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
checked={inheritWorkspace}
|
||||
disabled={saving}
|
||||
onToggle={() => setInheritWorkspace((current) => !current)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-black/12 bg-white/70 px-10 py-3 text-[18px] font-medium text-[#3B4252] transition-colors hover:bg-white dark:border-white/10 dark:bg-[#222225] dark:text-gray-200 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
{copy.cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-[#7E9DF5] px-10 py-3 text-[18px] font-medium text-white transition-colors hover:bg-[#6E90F3] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
void onSave({
|
||||
name: name.trim(),
|
||||
inheritWorkspace,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saving ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AgentsDialogSurface>
|
||||
);
|
||||
}
|
||||
65
src/pages/Agents/components/AgentCard.tsx
Normal file
65
src/pages/Agents/components/AgentCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Bot, Check, SlidersHorizontal } from 'lucide-react';
|
||||
import type { AgentSummary } from '@runtime/lib/agents';
|
||||
|
||||
type AgentCardProps = {
|
||||
agent: AgentSummary;
|
||||
defaultBadge: string;
|
||||
modelLabel: string;
|
||||
modelValue: string;
|
||||
channelsLabel: string;
|
||||
channelsValue: string;
|
||||
settingsLabel: string;
|
||||
disabled?: boolean;
|
||||
onOpenSettings: (agent: AgentSummary) => void;
|
||||
};
|
||||
|
||||
export default function AgentCard({
|
||||
agent,
|
||||
defaultBadge,
|
||||
modelLabel,
|
||||
modelValue,
|
||||
channelsLabel,
|
||||
channelsValue,
|
||||
settingsLabel,
|
||||
disabled = false,
|
||||
onOpenSettings,
|
||||
}: AgentCardProps) {
|
||||
return (
|
||||
<article className="rounded-[32px] bg-[#EEE9DD] px-7 py-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.5)] dark:bg-[#1f1f22]">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex h-[82px] w-[82px] shrink-0 items-center justify-center rounded-full bg-[#E3E0D9] text-[#2E67F8] shadow-[0_6px_18px_rgba(15,23,42,0.08)] dark:bg-[#26262a]">
|
||||
<Bot className="h-9 w-9 stroke-[1.8]" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h3 className="truncate text-[26px] font-semibold leading-none text-[#0D1730] dark:text-[#f3f4f6]">
|
||||
{agent.name}
|
||||
</h3>
|
||||
{agent.isDefault ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[#E8E1D6] px-3 py-1.5 text-[13px] font-medium text-[#525866] dark:bg-[#2a2a2d] dark:text-gray-300">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{defaultBadge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-[19px] leading-[1.4] text-[#707791] dark:text-gray-400">
|
||||
<div>{`${modelLabel}: ${modelValue}`}</div>
|
||||
<div>{`${channelsLabel}: ${channelsValue}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-[#6B7CA5] transition-colors hover:bg-white/65 hover:text-[#2E67F8] disabled:cursor-not-allowed disabled:opacity-60 dark:text-gray-400 dark:hover:bg-[#2a2a2d] dark:hover:text-[#8ab4ff]"
|
||||
aria-label={`${settingsLabel} ${agent.name}`}
|
||||
onClick={() => onOpenSettings(agent)}
|
||||
>
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
357
src/pages/Agents/components/AgentSettingsDialog.tsx
Normal file
357
src/pages/Agents/components/AgentSettingsDialog.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { AgentSummary } from '@runtime/lib/agents';
|
||||
import type { ProviderAccount } from '@runtime/lib/providers';
|
||||
import AgentsDialogSurface from './AgentsDialogSurface';
|
||||
|
||||
const FIELD_CLASS_NAME = [
|
||||
'w-full rounded-[20px] border border-black/10 bg-[#F8F4EC] px-5 py-4 text-[16px] text-[#171717]',
|
||||
'outline-none transition-colors placeholder:text-[#99A0AE] focus:border-black/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-65 dark:border-white/10 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500 dark:focus:border-white/20',
|
||||
].join(' ');
|
||||
|
||||
type AgentSettingsDialogCopy = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
identityTitle: string;
|
||||
nameLabel: string;
|
||||
agentIdLabel: string;
|
||||
workspaceTitle: string;
|
||||
workspaceDescription: string;
|
||||
workspaceLabel: string;
|
||||
inheritedWorkspaceLabel: string;
|
||||
inheritedWorkspaceYes: string;
|
||||
inheritedWorkspaceNo: string;
|
||||
modelTitle: string;
|
||||
providerAccountLabel: string;
|
||||
useDefaultProvider: string;
|
||||
modelRefLabel: string;
|
||||
modelRefPlaceholder: string;
|
||||
effectiveProviderLabel: string;
|
||||
effectiveModelLabel: string;
|
||||
modelHelp: string;
|
||||
managedFromModels: string;
|
||||
openModelsLabel: string;
|
||||
channelsTitle: string;
|
||||
channelSummaryLabel: string;
|
||||
accountBindingsLabel: string;
|
||||
noChannels: string;
|
||||
openChannelsLabel: string;
|
||||
providerLoadErrorPrefix: string;
|
||||
cancelLabel: string;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
deleteLabel: string;
|
||||
};
|
||||
|
||||
type AgentSettingsDialogProps = {
|
||||
open: boolean;
|
||||
agent: AgentSummary | null;
|
||||
providerAccounts: ProviderAccount[];
|
||||
providerLoading: boolean;
|
||||
providerError: string | null;
|
||||
defaultProviderAccountId: string | null;
|
||||
defaultModelRef: string | null;
|
||||
mainWorkspacePath: string | null;
|
||||
channelSummary: string;
|
||||
accountBindingCount: number;
|
||||
saving: boolean;
|
||||
deleting: boolean;
|
||||
copy: AgentSettingsDialogCopy;
|
||||
onClose: () => void;
|
||||
onSave: (input: { name: string; providerAccountId: string | null; modelRef: string | null }) => Promise<void> | void;
|
||||
onDelete: (agent: AgentSummary) => Promise<void> | void;
|
||||
onOpenChannels: () => void;
|
||||
onOpenModels: () => void;
|
||||
};
|
||||
|
||||
export default function AgentSettingsDialog({
|
||||
open,
|
||||
agent,
|
||||
providerAccounts,
|
||||
providerLoading,
|
||||
providerError,
|
||||
defaultProviderAccountId,
|
||||
defaultModelRef,
|
||||
mainWorkspacePath,
|
||||
channelSummary,
|
||||
accountBindingCount,
|
||||
saving,
|
||||
deleting,
|
||||
copy,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
onOpenChannels,
|
||||
onOpenModels,
|
||||
}: AgentSettingsDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [providerAccountId, setProviderAccountId] = useState('');
|
||||
const [modelRef, setModelRef] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent || !open) return;
|
||||
setName(agent.name);
|
||||
setProviderAccountId(agent.providerAccountId ?? '');
|
||||
setModelRef(agent.overrideModelRef ?? '');
|
||||
}, [agent, open]);
|
||||
|
||||
const selectedProvider = useMemo(
|
||||
() => providerAccounts.find((account) => account.id === providerAccountId) ?? null,
|
||||
[providerAccountId, providerAccounts],
|
||||
);
|
||||
|
||||
const defaultProvider = useMemo(
|
||||
() => providerAccounts.find((account) => account.id === defaultProviderAccountId) ?? null,
|
||||
[defaultProviderAccountId, providerAccounts],
|
||||
);
|
||||
|
||||
if (!agent) return null;
|
||||
|
||||
const isDefault = agent.isDefault;
|
||||
const inheritedWorkspace = Boolean(
|
||||
!isDefault
|
||||
&& mainWorkspacePath
|
||||
&& agent.workspace
|
||||
&& agent.workspace === mainWorkspacePath,
|
||||
);
|
||||
const effectiveProviderLabel = selectedProvider?.label || defaultProvider?.label || copy.useDefaultProvider;
|
||||
const effectiveModelLabel = modelRef.trim()
|
||||
|| selectedProvider?.model
|
||||
|| defaultModelRef
|
||||
|| copy.modelRefPlaceholder;
|
||||
const hasChanges = !isDefault && (
|
||||
name.trim() !== agent.name
|
||||
|| providerAccountId !== (agent.providerAccountId ?? '')
|
||||
|| modelRef !== (agent.overrideModelRef ?? '')
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentsDialogSurface
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={copy.title}
|
||||
subtitle={copy.subtitle}
|
||||
widthClassName="max-w-[980px]"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.02fr_0.98fr]">
|
||||
<section className="rounded-[28px] bg-[#F7F2E9] p-6 dark:bg-[#17171a]">
|
||||
<div className="text-[20px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.identityTitle}
|
||||
</div>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-3 block text-[16px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.nameLabel}
|
||||
</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={isDefault || saving || deleting}
|
||||
className={FIELD_CLASS_NAME}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-[22px] border border-dashed border-black/10 bg-white/70 px-5 py-4 text-[14px] text-[#525866] dark:border-white/10 dark:bg-[#222225] dark:text-gray-300">
|
||||
<div className="font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.agentIdLabel}
|
||||
</div>
|
||||
<div className="mt-2 break-all font-mono text-[13px]">
|
||||
{agent.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[22px] border border-dashed border-black/10 bg-white/70 px-5 py-4 dark:border-white/10 dark:bg-[#222225]">
|
||||
<div className="font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.workspaceTitle}
|
||||
</div>
|
||||
<div className="mt-2 text-[14px] leading-[1.6] text-[#5B6475] dark:text-gray-400">
|
||||
{copy.workspaceDescription}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-[1fr_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#99A0AE] dark:text-gray-500">
|
||||
{copy.workspaceLabel}
|
||||
</div>
|
||||
<div className="mt-2 break-all font-mono text-[13px] text-[#5B6475] dark:text-gray-300">
|
||||
{agent.workspace || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[148px]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#99A0AE] dark:text-gray-500">
|
||||
{copy.inheritedWorkspaceLabel}
|
||||
</div>
|
||||
<div className="mt-2 text-[14px] text-[#5B6475] dark:text-gray-300">
|
||||
{inheritedWorkspace ? copy.inheritedWorkspaceYes : copy.inheritedWorkspaceNo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] bg-[#F7F2E9] p-6 dark:bg-[#17171a]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[20px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.channelsTitle}
|
||||
</div>
|
||||
<div className="mt-2 text-[14px] leading-[1.6] text-[#646C7A] dark:text-gray-400">
|
||||
{copy.channelSummaryLabel}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-black/12 bg-white/80 px-5 py-2.5 text-[14px] font-medium text-[#2E3445] transition-colors hover:bg-white dark:border-white/10 dark:bg-[#222225] dark:text-gray-200 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onOpenChannels}
|
||||
>
|
||||
{copy.openChannelsLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4 text-[14px] leading-[1.6] text-[#5B6475] dark:text-gray-300">
|
||||
<div className="rounded-[22px] border border-dashed border-black/10 bg-white/70 px-5 py-4 dark:border-white/10 dark:bg-[#222225]">
|
||||
<div className="font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.channelSummaryLabel}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{channelSummary || copy.noChannels}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-dashed border-black/10 bg-white/70 px-5 py-4 dark:border-white/10 dark:bg-[#222225]">
|
||||
<div className="font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.accountBindingsLabel}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{accountBindingCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[28px] bg-[#F7F2E9] p-6 dark:bg-[#17171a]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[20px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.modelTitle}
|
||||
</div>
|
||||
<div className="mt-2 text-[14px] leading-[1.6] text-[#646C7A] dark:text-gray-400">
|
||||
{isDefault ? copy.managedFromModels : copy.modelHelp}
|
||||
</div>
|
||||
</div>
|
||||
{isDefault ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-black/12 bg-white/80 px-5 py-2.5 text-[14px] font-medium text-[#2E3445] transition-colors hover:bg-white dark:border-white/10 dark:bg-[#222225] dark:text-gray-200 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onOpenModels}
|
||||
>
|
||||
{copy.openModelsLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isDefault ? null : (
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-[1fr_1fr_0.95fr]">
|
||||
<label className="block">
|
||||
<span className="mb-3 block text-[16px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.providerAccountLabel}
|
||||
</span>
|
||||
<select
|
||||
value={providerAccountId}
|
||||
onChange={(event) => setProviderAccountId(event.target.value)}
|
||||
disabled={providerLoading || saving || deleting}
|
||||
className={FIELD_CLASS_NAME}
|
||||
>
|
||||
<option value="">{copy.useDefaultProvider}</option>
|
||||
{providerAccounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{`${account.label}${account.isDefault ? ' · Default' : ''}${account.model ? ` · ${account.model}` : ''}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-3 block text-[16px] font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.modelRefLabel}
|
||||
</span>
|
||||
<input
|
||||
value={modelRef}
|
||||
onChange={(event) => setModelRef(event.target.value)}
|
||||
disabled={saving || deleting}
|
||||
placeholder={copy.modelRefPlaceholder}
|
||||
className={FIELD_CLASS_NAME}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-[22px] border border-dashed border-black/10 bg-white/70 px-5 py-4 text-[14px] text-[#5B6475] dark:border-white/10 dark:bg-[#222225] dark:text-gray-300">
|
||||
<div>
|
||||
<span className="font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.effectiveProviderLabel}
|
||||
</span>
|
||||
<span className="ml-2">{effectiveProviderLabel}</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
|
||||
{copy.effectiveModelLabel}
|
||||
</span>
|
||||
<span className="ml-2 break-all font-mono text-[13px]">
|
||||
{effectiveModelLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providerError && !isDefault ? (
|
||||
<div className="mt-4 rounded-[20px] border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-[13px] text-amber-700 dark:text-amber-300">
|
||||
{`${copy.providerLoadErrorPrefix}${providerError}`}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-4 pt-2">
|
||||
{!isDefault ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-red-500/20 bg-red-500/10 px-8 py-3 text-[16px] font-medium text-red-600 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-60 dark:text-red-300"
|
||||
disabled={saving || deleting}
|
||||
onClick={() => {
|
||||
void onDelete(agent);
|
||||
}}
|
||||
>
|
||||
{deleting ? copy.savingLabel : copy.deleteLabel}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-black/12 bg-white/80 px-8 py-3 text-[16px] font-medium text-[#2E3445] transition-colors hover:bg-white dark:border-white/10 dark:bg-[#222225] dark:text-gray-200 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onClose}
|
||||
disabled={saving || deleting}
|
||||
>
|
||||
{copy.cancelLabel}
|
||||
</button>
|
||||
|
||||
{!isDefault ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-[#7E9DF5] px-8 py-3 text-[16px] font-medium text-white transition-colors hover:bg-[#6E90F3] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!hasChanges || saving || deleting || name.trim().length === 0}
|
||||
onClick={() => {
|
||||
void onSave({
|
||||
name: name.trim(),
|
||||
providerAccountId: providerAccountId || null,
|
||||
modelRef: modelRef.trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saving ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AgentsDialogSurface>
|
||||
);
|
||||
}
|
||||
65
src/pages/Agents/components/AgentsDialogSurface.tsx
Normal file
65
src/pages/Agents/components/AgentsDialogSurface.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type AgentsDialogSurfaceProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
widthClassName?: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function AgentsDialogSurface({
|
||||
open,
|
||||
title,
|
||||
subtitle,
|
||||
widthClassName = 'max-w-[860px]',
|
||||
onClose,
|
||||
children,
|
||||
}: AgentsDialogSurfaceProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 px-4 py-6 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'w-full overflow-hidden rounded-[36px] bg-[#F4F0E8] shadow-[0_30px_80px_rgba(15,23,42,0.18)] dark:bg-[#1f1f22]',
|
||||
widthClassName,
|
||||
].join(' ')}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 px-10 pb-0 pt-10">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className="text-[32px] font-normal leading-none tracking-tight text-[#0D1730] dark:text-[#f3f4f6] md:text-[44px]"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? (
|
||||
<p className="mt-4 max-w-[680px] text-[18px] font-medium leading-[1.55] text-[#4E5668] dark:text-gray-400">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 rounded-full p-1.5 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8">
|
||||
<path d="M6 6L18 18M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-10 pb-10 pt-8">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
425
src/pages/Agents/index.tsx
Normal file
425
src/pages/Agents/index.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, Plus, RefreshCw } from 'lucide-react';
|
||||
import type { AgentSummary } from '@runtime/lib/agents';
|
||||
import type { ProviderAccount } from '@runtime/lib/providers';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { onGatewayEvent } from '../../lib/gateway-client';
|
||||
import { hostApiFetch } from '../../lib/host-api';
|
||||
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events';
|
||||
import { agentsStore, useAgentsStore } from '../../stores';
|
||||
import AddAgentDialog from './components/AddAgentDialog';
|
||||
import AgentCard from './components/AgentCard';
|
||||
import AgentSettingsDialog from './components/AgentSettingsDialog';
|
||||
|
||||
function interpolateFallback(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (_match, token) => {
|
||||
const value = params[token];
|
||||
return typeof value === 'undefined' ? `{${token}}` : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function formatChannelLabel(channelType: string): string {
|
||||
const normalized = String(channelType ?? '')
|
||||
.split(/[-_]/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) return channelType;
|
||||
|
||||
return normalized
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function countAccountBindings(agentId: string, channelAccountOwners: Record<string, string>): number {
|
||||
return Object.values(channelAccountOwners).filter((ownerId) => ownerId === agentId).length;
|
||||
}
|
||||
|
||||
function getAgentModelValue(
|
||||
agent: AgentSummary,
|
||||
defaultModelRef: string | null,
|
||||
notConfiguredLabel: string,
|
||||
): string {
|
||||
const candidate = (
|
||||
agent.overrideModelRef
|
||||
|| agent.modelRef
|
||||
|| (agent.isDefault ? defaultModelRef : null)
|
||||
|| ''
|
||||
).trim();
|
||||
|
||||
return candidate || notConfiguredLabel;
|
||||
}
|
||||
|
||||
function getAgentChannelsValue(agent: AgentSummary, noneLabel: string): string {
|
||||
const channels = Array.isArray(agent.channelTypes)
|
||||
? agent.channelTypes.map((channelType) => formatChannelLabel(channelType)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return channels.length > 0 ? channels.join(', ') : noneLabel;
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div className="h-9 w-9 animate-spin rounded-full border-2 border-[#DAD3C6] border-t-[#2E67F8] dark:border-[#303038] dark:border-t-[#8ab4ff]" />
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, hasMessage } = useI18n();
|
||||
const initialized = useAgentsStore((state) => state.initialized);
|
||||
const loading = useAgentsStore((state) => state.loading);
|
||||
const error = useAgentsStore((state) => state.error);
|
||||
const warning = useAgentsStore((state) => state.warning);
|
||||
const agents = useAgentsStore((state) => state.agents);
|
||||
const defaultProviderAccountId = useAgentsStore((state) => state.defaultProviderAccountId);
|
||||
const defaultModelRef = useAgentsStore((state) => state.defaultModelRef);
|
||||
const channelAccountOwners = useAgentsStore((state) => state.channelAccountOwners);
|
||||
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [settingsAgentId, setSettingsAgentId] = useState<string | null>(null);
|
||||
const [providerAccounts, setProviderAccounts] = useState<ProviderAccount[]>([]);
|
||||
const [providerLoading, setProviderLoading] = useState(false);
|
||||
const [providerError, setProviderError] = useState<string | null>(null);
|
||||
|
||||
const message = (path: string, fallback: string, params?: Record<string, string | number>) => (
|
||||
hasMessage(path)
|
||||
? t(path, params)
|
||||
: interpolateFallback(fallback, params)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void agentsStore.init();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProviderAccounts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => (
|
||||
onGatewayEvent((event) => {
|
||||
if (!isRuntimeChangedGatewayEvent(event)) return;
|
||||
if (!runtimeEventHasTopic(event, 'providers', 'models', 'agents')) return;
|
||||
void loadProviderAccounts(false);
|
||||
})
|
||||
), []);
|
||||
|
||||
const sortedAgents = useMemo(
|
||||
() => [...agents].sort((left, right) => {
|
||||
if (left.isDefault && !right.isDefault) return -1;
|
||||
if (!left.isDefault && right.isDefault) return 1;
|
||||
return left.name.localeCompare(right.name, 'zh-CN');
|
||||
}),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const settingsAgent = useMemo(
|
||||
() => sortedAgents.find((agent) => agent.id === settingsAgentId) ?? null,
|
||||
[settingsAgentId, sortedAgents],
|
||||
);
|
||||
|
||||
const mainAgent = useMemo(
|
||||
() => sortedAgents.find((agent) => agent.isDefault) ?? null,
|
||||
[sortedAgents],
|
||||
);
|
||||
|
||||
const isBusy = busyAction !== null;
|
||||
const isInitialLoading = loading && !initialized;
|
||||
|
||||
async function loadProviderAccounts(showLoading = true): Promise<void> {
|
||||
if (showLoading) {
|
||||
setProviderLoading(true);
|
||||
}
|
||||
setProviderError(null);
|
||||
|
||||
try {
|
||||
const accounts = await hostApiFetch<ProviderAccount[]>('/api/provider-accounts');
|
||||
setProviderAccounts(Array.isArray(accounts) ? accounts.filter((account) => account?.enabled !== false) : []);
|
||||
} catch (requestError) {
|
||||
setProviderAccounts([]);
|
||||
setProviderError(requestError instanceof Error ? requestError.message : String(requestError));
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setProviderLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
agentsStore.refresh(),
|
||||
loadProviderAccounts(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleCreateAgent(input: { name: string; inheritWorkspace: boolean }): Promise<void> {
|
||||
setBusyAction('create');
|
||||
try {
|
||||
await agentsStore.createAgent(input.name, { inheritWorkspace: input.inheritWorkspace });
|
||||
setAddDialogOpen(false);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSettings(input: { name: string; providerAccountId: string | null; modelRef: string | null }): Promise<void> {
|
||||
if (!settingsAgent || settingsAgent.isDefault) {
|
||||
setSettingsAgentId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyAction(`save:${settingsAgent.id}`);
|
||||
try {
|
||||
if (input.name.trim() && input.name.trim() !== settingsAgent.name) {
|
||||
await agentsStore.updateAgent(settingsAgent.id, input.name.trim());
|
||||
}
|
||||
|
||||
if (
|
||||
input.providerAccountId !== (settingsAgent.providerAccountId ?? null)
|
||||
|| input.modelRef !== (settingsAgent.overrideModelRef ?? null)
|
||||
) {
|
||||
await agentsStore.updateAgentModel(settingsAgent.id, input.modelRef, {
|
||||
providerAccountId: input.providerAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
setSettingsAgentId(null);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAgent(agent: AgentSummary): Promise<void> {
|
||||
const confirmed = window.confirm(
|
||||
message(
|
||||
'agents.prompts.deleteConfirm',
|
||||
'确定删除 Agent “{name}”吗?已有会话记录会保留在磁盘上,但它会从 Agents 控制台中移除。',
|
||||
{ name: agent.name },
|
||||
),
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyAction(`delete:${agent.id}`);
|
||||
try {
|
||||
await agentsStore.deleteAgent(agent.id);
|
||||
setSettingsAgentId((current) => (current === agent.id ? null : current));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<section className="h-full w-full min-h-0">
|
||||
<div className="flex h-full w-full min-h-0 items-center justify-center rounded-[32px] bg-[#F5F0E4] dark:bg-[#141416]">
|
||||
<Spinner />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const pageCopy = {
|
||||
title: message('agents.title', 'Agents'),
|
||||
subtitle: message('agents.subtitle', '创建新的 Agent,可以将特定频道路由到不同的人格配置或工作区。'),
|
||||
refresh: message('agents.refresh', '刷新'),
|
||||
addAgent: message('agents.addAgent', '添加 Agent'),
|
||||
defaultBadge: message('agents.defaultBadge', '默认'),
|
||||
none: message('agents.none', '无'),
|
||||
modelLabel: message('agents.card.modelLabel', 'Model'),
|
||||
channelsLabel: message('agents.fields.channels', '频道'),
|
||||
settingsLabel: message('agents.actions.settings', '设置'),
|
||||
emptyTitle: message('agents.emptyTitle', '暂无 Agent'),
|
||||
emptyDescription: message('agents.emptyDescription', '创建新的 Agent 后,这里会显示对应的卡片摘要。'),
|
||||
notConfigured: message('agents.card.missingModel', 'Not configured'),
|
||||
addDialog: {
|
||||
title: message('agents.createDialog.title', '添加 Agent'),
|
||||
subtitle: message('agents.createDialog.description', '输入名称即可创建新 Agent,可选择是否继承主 Agent 的工作区引导文件。'),
|
||||
nameLabel: message('agents.createDialog.nameLabel', 'Agent 名称'),
|
||||
namePlaceholder: message('agents.createDialog.namePlaceholder', 'Coding Helper'),
|
||||
inheritWorkspaceLabel: message('agents.createDialog.inheritWorkspaceLabel', '继承主 Agent 工作区'),
|
||||
inheritWorkspaceDescription: message('agents.createDialog.inheritWorkspaceDescription', '从主 Agent 复制 SOUL.md、AGENTS.md 等引导文件。'),
|
||||
cancelLabel: message('dialog.cancel', '取消'),
|
||||
saveLabel: message('agents.createDialog.saveLabel', '保存'),
|
||||
savingLabel: message('agents.createDialog.savingLabel', '保存中...'),
|
||||
},
|
||||
settingsDialog: {
|
||||
title: message('agents.settings.title', 'Agent 设置'),
|
||||
subtitle: settingsAgent
|
||||
? message('agents.settings.description', '查看并调整 {name} 的基础信息、模型路由和频道归属摘要。', { name: settingsAgent.name })
|
||||
: '',
|
||||
identityTitle: message('agents.settings.identityTitle', '基础信息'),
|
||||
nameLabel: message('agents.settings.nameLabel', 'Agent 名称'),
|
||||
agentIdLabel: message('agents.settings.agentIdLabel', 'Agent ID'),
|
||||
workspaceTitle: message('agents.settings.workspaceTitle', '工作区'),
|
||||
workspaceDescription: message('agents.settings.workspaceDescription', '查看当前 Agent 的工作区路径,以及是否继承主 Agent 工作区。'),
|
||||
workspaceLabel: message('agents.fields.workspace', '工作区'),
|
||||
inheritedWorkspaceLabel: message('agents.settings.inheritedWorkspaceLabel', '继承主 Agent 工作区'),
|
||||
inheritedWorkspaceYes: message('agents.settings.inheritedWorkspaceYes', '是'),
|
||||
inheritedWorkspaceNo: message('agents.settings.inheritedWorkspaceNo', '否'),
|
||||
modelTitle: message('agents.settings.modelTitle', '模型'),
|
||||
providerAccountLabel: message('agents.settings.providerAccountLabel', 'Provider 账号'),
|
||||
useDefaultProvider: message('agents.settings.useDefaultProvider', '使用工作区默认 Provider'),
|
||||
modelRefLabel: message('agents.settings.modelRefLabel', '模型覆盖'),
|
||||
modelRefPlaceholder: message('agents.settings.modelRefPlaceholder', 'provider/model-id'),
|
||||
effectiveProviderLabel: message('agents.settings.effectiveProviderLabel', '生效中的 Provider'),
|
||||
effectiveModelLabel: message('agents.settings.effectiveModelLabel', '生效中的模型'),
|
||||
modelHelp: message('agents.settings.modelHelp', '留空后会跟随所选 Provider 的模型;如果没有固定 Provider,则继续继承工作区默认模型。'),
|
||||
managedFromModels: message('agents.settings.managedFromModels', 'Main Agent 使用 Models 页面里配置的 Provider 和默认模型。'),
|
||||
openModelsLabel: message('agents.settings.openModels', '前往 Models'),
|
||||
channelsTitle: message('agents.settings.bindingTitle', '频道归属'),
|
||||
channelSummaryLabel: message('agents.settings.channelSummaryLabel', '频道路由摘要'),
|
||||
accountBindingsLabel: message('agents.settings.accountBindingsLabel', '账号绑定数'),
|
||||
noChannels: message('agents.card.noChannels', '无'),
|
||||
openChannelsLabel: message('agents.settings.manageBindings', '前往 Channels'),
|
||||
providerLoadErrorPrefix: message('agents.settings.providerLoadErrorPrefix', '加载 Provider 账号失败:'),
|
||||
cancelLabel: message('dialog.cancel', '取消'),
|
||||
saveLabel: message('agents.settings.save', '保存'),
|
||||
savingLabel: message('agents.settings.saving', '保存中...'),
|
||||
deleteLabel: message('agents.actions.delete', '删除'),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="h-full w-full min-h-0">
|
||||
<div className="flex h-full w-full min-h-0 flex-col overflow-y-auto rounded-[32px] bg-[#F5F0E4] px-12 py-12 dark:bg-[#141416]">
|
||||
<div className="flex flex-col gap-8 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className="text-[72px] font-normal leading-none tracking-tight text-[#0D1730] dark:text-[#f3f4f6] md:text-[92px]"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{pageCopy.title}
|
||||
</h1>
|
||||
<p className="mt-8 max-w-[960px] text-[24px] font-semibold leading-[1.55] text-[#555C69] dark:text-gray-400">
|
||||
{pageCopy.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-4 xl:pt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-[66px] items-center gap-3 rounded-full border border-black/12 bg-white/50 px-9 text-[18px] font-medium text-[#2E3445] transition-colors hover:bg-white/80 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#222225] dark:text-gray-200 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={['h-5 w-5', (loading || providerLoading) ? 'animate-spin' : ''].join(' ')} />
|
||||
{pageCopy.refresh}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-[66px] items-center gap-3 rounded-full bg-[#2E67F8] px-10 text-[18px] font-semibold text-white transition-colors hover:bg-[#2458E0] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
setAddDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
{pageCopy.addAgent}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 space-y-4">
|
||||
{warning ? (
|
||||
<div className="rounded-[24px] border border-amber-500/25 bg-amber-500/10 px-5 py-4 text-[15px] text-amber-700 dark:text-amber-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[24px] border border-red-500/25 bg-red-500/10 px-5 py-4 text-[15px] text-red-700 dark:text-red-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<span>{message('agents.errorPrefix', '加载 Agents 失败:{error}', { error })}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{providerError ? (
|
||||
<div className="rounded-[24px] border border-amber-500/25 bg-amber-500/10 px-5 py-4 text-[15px] text-amber-700 dark:text-amber-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<span>{`${pageCopy.settingsDialog.providerLoadErrorPrefix}${providerError}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 space-y-5 pb-6">
|
||||
{sortedAgents.length === 0 ? (
|
||||
<div className="rounded-[32px] border border-dashed border-black/10 bg-[#EEE9DD] px-8 py-12 text-[#555C69] dark:border-white/10 dark:bg-[#1f1f22] dark:text-gray-400">
|
||||
<div className="text-[24px] font-semibold text-[#0D1730] dark:text-[#f3f4f6]">
|
||||
{pageCopy.emptyTitle}
|
||||
</div>
|
||||
<div className="mt-3 text-[17px] leading-[1.6]">
|
||||
{pageCopy.emptyDescription}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
sortedAgents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
defaultBadge={pageCopy.defaultBadge}
|
||||
modelLabel={pageCopy.modelLabel}
|
||||
modelValue={getAgentModelValue(agent, defaultModelRef, pageCopy.notConfigured)}
|
||||
channelsLabel={pageCopy.channelsLabel}
|
||||
channelsValue={getAgentChannelsValue(agent, pageCopy.none)}
|
||||
settingsLabel={pageCopy.settingsLabel}
|
||||
disabled={isBusy}
|
||||
onOpenSettings={(targetAgent) => setSettingsAgentId(targetAgent.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddAgentDialog
|
||||
open={addDialogOpen}
|
||||
saving={busyAction === 'create'}
|
||||
copy={pageCopy.addDialog}
|
||||
onClose={() => {
|
||||
if (!isBusy) {
|
||||
setAddDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
onSave={handleCreateAgent}
|
||||
/>
|
||||
|
||||
<AgentSettingsDialog
|
||||
open={Boolean(settingsAgent)}
|
||||
agent={settingsAgent}
|
||||
providerAccounts={providerAccounts}
|
||||
providerLoading={providerLoading}
|
||||
providerError={providerError}
|
||||
defaultProviderAccountId={defaultProviderAccountId}
|
||||
defaultModelRef={defaultModelRef}
|
||||
mainWorkspacePath={mainAgent?.workspace ?? null}
|
||||
channelSummary={settingsAgent ? getAgentChannelsValue(settingsAgent, pageCopy.none) : pageCopy.none}
|
||||
accountBindingCount={settingsAgent ? countAccountBindings(settingsAgent.id, channelAccountOwners) : 0}
|
||||
saving={Boolean(settingsAgent && busyAction === `save:${settingsAgent.id}`)}
|
||||
deleting={Boolean(settingsAgent && busyAction === `delete:${settingsAgent.id}`)}
|
||||
copy={pageCopy.settingsDialog}
|
||||
onClose={() => {
|
||||
if (!isBusy) {
|
||||
setSettingsAgentId(null);
|
||||
}
|
||||
}}
|
||||
onSave={handleSaveSettings}
|
||||
onDelete={handleDeleteAgent}
|
||||
onOpenChannels={() => navigate('/channels')}
|
||||
onOpenModels={() => navigate('/models')}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,14 +19,13 @@ import {
|
||||
chatStore,
|
||||
getCompletedTasks,
|
||||
getPendingTasks,
|
||||
modelsStore,
|
||||
taskStore,
|
||||
useChannelStore,
|
||||
useChatStore,
|
||||
useModelsStore,
|
||||
useTaskStore,
|
||||
type StagedAttachment,
|
||||
} from '../../stores';
|
||||
import { agentsStore, useAgentsStore } from '../../stores/agents';
|
||||
import { AddChannelDialog, TaskOperationDialog } from './components';
|
||||
|
||||
type SessionBucketKey = 'today' | 'yesterday' | 'withinWeek' | 'withinTwoWeeks' | 'withinMonth' | 'older';
|
||||
@@ -154,7 +153,7 @@ function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null
|
||||
|
||||
function handleNewConversation(): void {
|
||||
const currentAgentId = chatStore.getState().currentAgentId;
|
||||
const defaultAgentId = modelsStore.getState().defaultAgentId;
|
||||
const defaultAgentId = agentsStore.getState().defaultAgentId;
|
||||
const targetAgentId = currentAgentId && currentAgentId !== 'local' ? currentAgentId : defaultAgentId;
|
||||
void chatStore.newSession(targetAgentId || undefined);
|
||||
}
|
||||
@@ -182,7 +181,7 @@ function handleDeleteConversation(conversationId: string): void {
|
||||
}
|
||||
|
||||
function handleRefreshConversationData(): void {
|
||||
void modelsStore.load();
|
||||
void agentsStore.load();
|
||||
void chatStore.loadSessions();
|
||||
void chatStore.loadHistory();
|
||||
}
|
||||
@@ -229,9 +228,9 @@ function HomeChatComposerSection() {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const models = useModelsStore((state) => state.models);
|
||||
const modelsLoading = useModelsStore((state) => state.loading);
|
||||
const defaultAgentId = useModelsStore((state) => state.defaultAgentId);
|
||||
const agents = useAgentsStore((state) => state.agents);
|
||||
const agentsLoading = useAgentsStore((state) => state.loading);
|
||||
const defaultAgentId = useAgentsStore((state) => state.defaultAgentId);
|
||||
const chatInitialized = useChatStore((state) => state.initialized);
|
||||
const chatMessages = useChatStore((state) => state.messages);
|
||||
const chatLoading = useChatStore((state) => state.loading);
|
||||
@@ -252,7 +251,7 @@ export default function HomePage() {
|
||||
const [addChannelDialogOpen, setAddChannelDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void modelsStore.init();
|
||||
void agentsStore.init();
|
||||
void chatStore.init();
|
||||
void taskStore.init();
|
||||
void channelStore.init();
|
||||
@@ -318,15 +317,15 @@ export default function HomePage() {
|
||||
const currentTaskSource = activeTaskTab === 'pending' ? pendingTasks : completedTasks;
|
||||
const latestTask = currentTaskSource[0];
|
||||
|
||||
const selectedModelId = useMemo(() => (
|
||||
models.some((model) => model.id === chatCurrentAgentId)
|
||||
const selectedAgentId = useMemo(() => (
|
||||
agents.some((agent) => agent.id === chatCurrentAgentId)
|
||||
? chatCurrentAgentId
|
||||
: defaultAgentId
|
||||
), [chatCurrentAgentId, defaultAgentId, models]);
|
||||
), [agents, chatCurrentAgentId, defaultAgentId]);
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => models.find((model) => model.id === selectedModelId) || null,
|
||||
[models, selectedModelId],
|
||||
const currentAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedAgentId) || null,
|
||||
[agents, selectedAgentId],
|
||||
);
|
||||
|
||||
async function handleTaskCenterItem(item: TaskCenterItem): Promise<void> {
|
||||
@@ -398,23 +397,24 @@ export default function HomePage() {
|
||||
<h2 className="text-base font-semibold text-[#171717] dark:text-gray-100">智能对话</h2>
|
||||
<div className="mt-1 text-xs text-[#99A0AE] dark:text-gray-500">
|
||||
网关状态:{chatGatewayStatus === 'connected' ? '已连接' : chatGatewayStatus === 'reconnecting' ? '重连中' : '未连接'}
|
||||
{currentModel ? ` · 当前模型:${currentModel.name}` : ''}
|
||||
{currentAgent ? ` · 当前 Agent:${currentAgent.name}` : ''}
|
||||
{currentAgent?.modelDisplay ? ` · 模型:${currentAgent.modelDisplay}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-[#525866] dark:text-gray-300">
|
||||
<span>模型</span>
|
||||
<span>Agent</span>
|
||||
<select
|
||||
className="rounded-full border border-[#E5E8EE] bg-white px-3 py-1.5 text-xs text-[#525866] outline-none transition-colors hover:border-[#2B7FFF] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300"
|
||||
disabled={modelsLoading || models.length === 0}
|
||||
value={selectedModelId}
|
||||
disabled={agentsLoading || agents.length === 0}
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => {
|
||||
chatStore.selectAgent(event.target.value);
|
||||
}}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
type ProviderVendorInfo,
|
||||
type ProviderWithKeyInfo,
|
||||
} from '../../../lib/providers';
|
||||
import { onGatewayEvent } from '../../../lib/gateway-client';
|
||||
import { hostApiFetch } from '../../../lib/host-api';
|
||||
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../../lib/runtime-events';
|
||||
import ProviderEditorDialog from './ProviderEditorDialog';
|
||||
import ProviderPickerDialog from './ProviderPickerDialog';
|
||||
import type { DisplayVendor, ProviderEditorValues, ProviderListItem } from './provider-types';
|
||||
@@ -164,6 +166,14 @@ export default function ProvidersSection() {
|
||||
void loadProviders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return onGatewayEvent((event) => {
|
||||
if (!isRuntimeChangedGatewayEvent(event)) return;
|
||||
if (!runtimeEventHasTopic(event, 'providers', 'models')) return;
|
||||
void loadProviders(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleSetDefault(accountId: string): Promise<void> {
|
||||
try {
|
||||
await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts/default', {
|
||||
|
||||
Reference in New Issue
Block a user