feat: add AgentsConfirmDialog component and integrate it into AgentsPage for agent deletion confirmation

- Implemented AgentsConfirmDialog for confirming agent deletions.
- Updated AgentsPage to manage agent deletion state and feedback messages.
- Refactored provider account loading logic to include channel groups.
- Enhanced feedback mechanism for user actions such as agent creation and deletion.
This commit is contained in:
duanshuwen
2026-04-19 22:10:01 +08:00
parent 3a86539537
commit 3de3629d12
6 changed files with 1079 additions and 427 deletions

View File

@@ -2,10 +2,8 @@
1、任务列表 - 完成
2、走本地模型配置重构模型对话功能 - 完成
3、上传表单信息+读取信息,脚本执行录取表单
4、定时任务脚本关联多个脚本执行
5、一键打开渠道可以新增渠道 - 完成
6、把龙虾包装到对话
7、迁移频道功能
8、迁移agent功能
9、知识库调整成上传文件查看文件列表
3、一键打开渠道可以新增渠道 - 完成
4、把龙虾包装到对话
5、迁移频道功能 - 完成
6、迁移agent功能 - 完成
7、知识库调整成上传文件,查看文件列表 - 完成

View File

@@ -120,6 +120,23 @@ export const messages: I18nMessages = {
saveLabel: 'Save',
savingLabel: 'Saving...',
},
feedback: {
created: 'Agent created.',
createFailed: 'Failed to create Agent: {error}',
deleted: 'Agent deleted.',
deleteFailed: 'Failed to delete Agent: {error}',
agentUpdated: 'Agent name updated.',
agentUpdateFailedPrefix: 'Failed to update Agent name: ',
agentModelUpdated: 'Agent model updated.',
agentModelReset: 'Agent reverted to the default model.',
agentModelUpdateFailedPrefix: 'Failed to update Agent model: ',
},
deleteDialog: {
title: 'Delete Agent',
message: 'Delete Agent "{name}"? Existing chats stay on disk.',
confirm: 'Delete',
deleting: 'Deleting...',
},
fields: {
model: 'Model',
workspace: 'Workspace',
@@ -140,9 +157,11 @@ export const messages: I18nMessages = {
},
settings: {
title: 'Agent settings',
description: 'Review identity, model routing, and channel ownership for {name}.',
titleWithName: '{name} settings',
description: 'Update the Agent name and review which channel accounts belong to this Agent.',
identityTitle: 'Identity',
modelTitle: 'Model',
modelLabel: 'Model',
bindingTitle: 'Channel ownership',
bindingHelp: 'Read-only summary of which channels and accounts currently resolve to this Agent.',
bindingEmpty: 'No channels or account ownership are associated with this Agent yet.',
@@ -182,6 +201,27 @@ export const messages: I18nMessages = {
bindingReadonly: 'Ownership is read-only here. Change channel/account bindings in Channels.',
providerLoadError: 'Provider accounts could not be loaded: {error}',
channelLoadError: 'Channel accounts could not be loaded: {error}',
closeLabel: 'Close dialog',
notConfigured: 'Not configured',
channelsTitle: 'Channels',
channelsDescription: 'This list is read-only. Manage channel accounts and bindings from Channels.',
noChannels: 'This Agent has not been assigned any channels yet.',
channelsManagedInChannels: 'No explicit account bindings were found for this Agent. Review channel-level ownership in Channels.',
mainAccount: 'Main account',
unsavedChangesTitle: 'Discard unsaved changes?',
unsavedChangesMessage: 'You have unsaved edits. Closing now will discard them.',
closeWithoutSaving: 'Close without saving',
modelOverrideDescription: 'Update this Agent\'s model override. Current default model: {defaultModel}',
modelProviderLabel: 'Model provider',
modelProviderPlaceholder: 'Select a provider',
modelIdLabel: 'Model ID',
modelIdPlaceholder: 'gpt-5.4',
modelPreview: 'Model preview',
modelProviderEmpty: 'No providers are available for model configuration yet. Configure a provider in Models first.',
useDefaultModel: 'Use default model',
modelProviderRequired: 'Select a provider first.',
modelIdRequired: 'Enter a model ID.',
modelInvalid: 'The model configuration format is invalid.',
},
},
settings: {
@@ -355,6 +395,23 @@ export const messages: I18nMessages = {
saveLabel: '保存',
savingLabel: '保存中...',
},
feedback: {
created: 'Agent 已创建。',
createFailed: '创建 Agent 失败:{error}',
deleted: 'Agent 已删除。',
deleteFailed: '删除 Agent 失败:{error}',
agentUpdated: 'Agent 名称已更新。',
agentUpdateFailedPrefix: '更新 Agent 名称失败:',
agentModelUpdated: 'Agent 模型已更新。',
agentModelReset: 'Agent 已恢复默认模型。',
agentModelUpdateFailedPrefix: '更新 Agent 模型失败:',
},
deleteDialog: {
title: '删除 Agent',
message: '确定删除 Agent “{name}”吗?现有聊天记录仍会保留在磁盘中。',
confirm: '确认删除',
deleting: '删除中...',
},
fields: {
model: '模型',
workspace: '工作区',
@@ -375,9 +432,11 @@ export const messages: I18nMessages = {
},
settings: {
title: 'Agent 设置',
description: '查看并调整 {name} 的基础信息、模型路由和频道归属摘要。',
titleWithName: '{name} 设置',
description: '更新 Agent 名称,并管理哪些频道归属于这个 Agent。',
identityTitle: '基础信息',
modelTitle: '模型',
modelLabel: 'Model',
bindingTitle: '频道归属',
bindingHelp: '这里只展示这个 Agent 当前命中的频道和账号归属摘要。',
bindingEmpty: '这个 Agent 还没有任何频道或账号归属。',
@@ -417,6 +476,27 @@ export const messages: I18nMessages = {
bindingReadonly: '这里只读展示归属;修改 channel/account 绑定请前往 Channels。',
providerLoadError: '加载 Provider 账号失败:{error}',
channelLoadError: '加载频道账号失败:{error}',
closeLabel: '关闭弹窗',
notConfigured: '未配置',
channelsTitle: '频道',
channelsDescription: '该列表为只读。频道账号与绑定关系请在 Channels 页面管理。',
noChannels: '这个 Agent 还没有分配任何频道。',
channelsManagedInChannels: '该 Agent 当前没有显式账号归属。频道级归属请前往 Channels 页面查看。',
mainAccount: '主账号',
unsavedChangesTitle: '放弃未保存的更改?',
unsavedChangesMessage: '当前有尚未保存的修改。关闭后这些修改将丢失。',
closeWithoutSaving: '直接关闭',
modelOverrideDescription: '更新这个 Agent 的模型覆盖设置。当前默认模型:{defaultModel}',
modelProviderLabel: '模型 Provider',
modelProviderPlaceholder: '请选择 Provider',
modelIdLabel: '模型 ID',
modelIdPlaceholder: 'gpt-5.4',
modelPreview: '模型预览',
modelProviderEmpty: '当前没有可用于模型配置的 Provider。请先在 Models 页面完成账号配置。',
useDefaultModel: '使用默认模型',
modelProviderRequired: '请先选择一个 Provider。',
modelIdRequired: '请输入模型 ID。',
modelInvalid: '模型配置格式不正确。',
},
},
settings: {
@@ -590,6 +670,23 @@ export const messages: I18nMessages = {
saveLabel: '保存',
savingLabel: '保存中...',
},
feedback: {
created: 'Agent を作成しました。',
createFailed: 'Agent の作成に失敗しました: {error}',
deleted: 'Agent を削除しました。',
deleteFailed: 'Agent の削除に失敗しました: {error}',
agentUpdated: 'Agent 名を更新しました。',
agentUpdateFailedPrefix: 'Agent 名の更新に失敗しました: ',
agentModelUpdated: 'Agent モデルを更新しました。',
agentModelReset: 'Agent を既定モデルに戻しました。',
agentModelUpdateFailedPrefix: 'Agent モデルの更新に失敗しました: ',
},
deleteDialog: {
title: 'Agent を削除',
message: 'Agent「{name}」を削除しますか?既存のチャットはディスクに残ります。',
confirm: '削除する',
deleting: '削除中...',
},
fields: {
model: 'モデル',
workspace: 'ワークスペース',
@@ -610,9 +707,11 @@ export const messages: I18nMessages = {
},
settings: {
title: 'Agent 設定',
description: '{name} の基本情報、モデルルーティング、チャンネル担当の要約を確認します。',
titleWithName: '{name} 設定',
description: 'Agent 名を更新し、この Agent に属するチャンネルを確認します。',
identityTitle: '基本情報',
modelTitle: 'モデル',
modelLabel: 'Model',
bindingTitle: 'チャンネル担当',
bindingHelp: 'この Agent に現在解決されるチャンネル / アカウント担当を読み取り専用で表示します。',
bindingEmpty: 'この Agent にはまだチャンネルまたはアカウントの担当がありません。',
@@ -652,6 +751,27 @@ export const messages: I18nMessages = {
bindingReadonly: 'ここでは担当を読み取り専用で表示します。channel/account の変更は Channels ページで行ってください。',
providerLoadError: 'Provider アカウントの読み込みに失敗しました: {error}',
channelLoadError: 'チャンネルアカウントの読み込みに失敗しました: {error}',
closeLabel: 'ダイアログを閉じる',
notConfigured: '未設定',
channelsTitle: 'チャンネル',
channelsDescription: 'この一覧は読み取り専用です。チャンネルアカウントと紐付けは Channels ページで管理してください。',
noChannels: 'この Agent にはまだチャンネルが割り当てられていません。',
channelsManagedInChannels: 'この Agent に明示的なアカウント紐付けは見つかりませんでした。チャンネル単位の担当は Channels で確認してください。',
mainAccount: 'メインアカウント',
unsavedChangesTitle: '未保存の変更を破棄しますか?',
unsavedChangesMessage: '未保存の変更があります。今閉じると破棄されます。',
closeWithoutSaving: '保存せずに閉じる',
modelOverrideDescription: 'この Agent のモデル上書きを更新します。現在の既定モデル: {defaultModel}',
modelProviderLabel: 'モデル Provider',
modelProviderPlaceholder: 'Provider を選択',
modelIdLabel: 'モデル ID',
modelIdPlaceholder: 'gpt-5.4',
modelPreview: 'モデルプレビュー',
modelProviderEmpty: 'モデル設定に使える Provider がまだありません。先に Models で Provider を設定してください。',
useDefaultModel: '既定モデルを使う',
modelProviderRequired: '先に Provider を選択してください。',
modelIdRequired: 'モデル ID を入力してください。',
modelInvalid: 'モデル設定の形式が正しくありません。',
},
},
settings: {

View File

@@ -1,4 +1,4 @@
import { Bot, Check, SlidersHorizontal } from 'lucide-react';
import { Bot, Check, Settings2, Trash2 } from 'lucide-react';
import type { AgentSummary } from '@runtime/lib/agents';
type AgentCardProps = {
@@ -9,8 +9,10 @@ type AgentCardProps = {
channelsLabel: string;
channelsValue: string;
settingsLabel: string;
deleteLabel: string;
disabled?: boolean;
onOpenSettings: (agent: AgentSummary) => void;
onDelete?: (agent: AgentSummary) => void;
};
export default function AgentCard({
@@ -21,11 +23,13 @@ export default function AgentCard({
channelsLabel,
channelsValue,
settingsLabel,
deleteLabel,
disabled = false,
onOpenSettings,
onDelete,
}: AgentCardProps) {
return (
<article className="rounded-2xl bg-[#F5F7FA] px-7 py-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.5)] dark:bg-[#1f1f22]">
<article className="group rounded-2xl bg-[#F5F7FA] 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-20.5 w-20.5 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]" />
@@ -50,16 +54,30 @@ export default function AgentCard({
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{!agent.isDefault && onDelete ? (
<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]"
className="flex h-11 w-11 items-center justify-center rounded-full text-[#6B7CA5] opacity-0 transition-all hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-60 group-hover:opacity-100 dark:text-gray-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
aria-label={`${deleteLabel} ${agent.name}`}
onClick={() => onDelete(agent)}
>
<Trash2 className="h-4.5 w-4.5" />
</button>
) : null}
<button
type="button"
disabled={disabled}
className="flex h-12 w-12 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" />
<Settings2 className="h-5 w-5" />
</button>
</div>
</div>
</article>
);
}

View File

@@ -1,357 +1,711 @@
import { useEffect, useMemo, useState } from 'react';
import { RefreshCw, X } from 'lucide-react';
import type { AgentSummary } from '@runtime/lib/agents';
import type { ProviderAccount } from '@runtime/lib/providers';
import AgentsDialogSurface from './AgentsDialogSurface';
import type { ChannelAccountCatalogGroup } from '../../../lib/channel-types';
import type { ProviderVendorInfo, ProviderWithKeyInfo } from '../../../lib/providers';
import telegramIcon from '../../../assets/channels/telegram.svg';
import discordIcon from '../../../assets/channels/discord.svg';
import whatsappIcon from '../../../assets/channels/whatsapp.svg';
import wechatIcon from '../../../assets/channels/wechat.svg';
import dingtalkIcon from '../../../assets/channels/dingtalk.svg';
import feishuIcon from '../../../assets/channels/feishu.svg';
import wecomIcon from '../../../assets/channels/wecom.svg';
import qqIcon from '../../../assets/channels/qq.svg';
import AgentsConfirmDialog from './AgentsConfirmDialog';
const FIELD_CLASS_NAME = [
'w-full rounded-[20px] border border-black/10 bg-[#F8F4EC] px-5 py-4 text-[16px] text-[#171717]',
const inputClasses = [
'h-[44px] flex-1 rounded-xl border border-black/10 bg-[#F8F4EC] px-4 text-[14px] 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(' ');
const selectClasses = [
inputClasses,
'appearance-none pr-10',
].join(' ');
const labelClasses = 'text-[12px] text-[#525866] dark:text-gray-400';
type FeedbackTone = 'success' | 'error' | 'info';
type AgentSettingsDialogCopy = {
title: string;
subtitle: string;
identityTitle: string;
description: 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;
closeLabel: string;
agentIdLabel: string;
modelLabel: string;
notConfigured: string;
inherited: string;
channelsTitle: string;
channelsDescription: string;
noChannels: string;
channelsManagedInChannels: string;
mainAccount: string;
cancelLabel: string;
unsavedChangesTitle: string;
unsavedChangesMessage: string;
closeWithoutSaving: string;
modelOverrideDescription: string;
modelProviderLabel: string;
modelProviderPlaceholder: string;
modelIdLabel: string;
modelIdPlaceholder: string;
modelPreview: string;
modelProviderEmpty: string;
useDefaultModel: string;
modelProviderRequired: string;
modelIdRequired: string;
modelInvalid: string;
nameSaved: string;
nameSaveFailedPrefix: string;
modelSaved: string;
modelReset: string;
modelSaveFailedPrefix: string;
};
type AgentSettingsDialogProps = {
open: boolean;
agent: AgentSummary | null;
channelGroups: ChannelAccountCatalogGroup[];
providerAccounts: ProviderAccount[];
providerLoading: boolean;
providerError: string | null;
defaultProviderAccountId: string | null;
providerStatuses: ProviderWithKeyInfo[];
providerVendors: ProviderVendorInfo[];
providerDefaultAccountId: 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;
onUpdateName: (agentId: string, name: string) => Promise<void> | void;
onUpdateModel: (agentId: string, modelRef: string | null) => Promise<void> | void;
onFeedback?: (message: string, tone?: FeedbackTone) => void;
};
type RuntimeProviderOption = {
runtimeProviderKey: string;
accountId: string;
label: string;
modelIdPlaceholder?: string;
configuredModelId?: string;
};
function resolveRuntimeProviderKey(account: ProviderAccount): string {
if (account.authMode === 'oauth_browser') {
if (account.vendorId === 'google') return 'google-gemini-cli';
if (account.vendorId === 'openai') return 'openai-codex';
}
if (account.vendorId === 'custom' || account.vendorId === 'ollama') {
const suffix = account.id.replace(/-/g, '').slice(0, 8);
return `${account.vendorId}-${suffix}`;
}
if (account.vendorId === 'minimax-portal-cn') {
return 'minimax-portal';
}
return account.vendorId;
}
function splitModelRef(modelRef: string | null | undefined): { providerKey: string; modelId: string } | null {
const value = String(modelRef ?? '').trim();
if (!value) return null;
const separatorIndex = value.indexOf('/');
if (separatorIndex <= 0 || separatorIndex >= value.length - 1) return null;
return {
providerKey: value.slice(0, separatorIndex),
modelId: value.slice(separatorIndex + 1),
};
}
function hasConfiguredProviderCredentials(
account: ProviderAccount,
statusById: Map<string, ProviderWithKeyInfo>,
): boolean {
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
return true;
}
return statusById.get(account.id)?.hasKey ?? false;
}
function getChannelDisplayName(group: ChannelAccountCatalogGroup): string {
return group.channelLabel || group.channelType;
}
function ChannelLogo({ type }: { type: string }) {
switch (type) {
case 'telegram':
return <img src={telegramIcon} alt="Telegram" className="h-5.5 w-5.5 dark:invert" />;
case 'discord':
return <img src={discordIcon} alt="Discord" className="h-5.5 w-5.5 dark:invert" />;
case 'whatsapp':
return <img src={whatsappIcon} alt="WhatsApp" className="h-5.5 w-5.5 dark:invert" />;
case 'wechat':
return <img src={wechatIcon} alt="WeChat" className="h-5.5 w-5.5 dark:invert" />;
case 'dingtalk':
return <img src={dingtalkIcon} alt="DingTalk" className="h-5.5 w-5.5 dark:invert" />;
case 'feishu':
return <img src={feishuIcon} alt="Feishu" className="h-5.5 w-5.5 dark:invert" />;
case 'wecom':
return <img src={wecomIcon} alt="WeCom" className="h-5.5 w-5.5 dark:invert" />;
case 'qqbot':
return <img src={qqIcon} alt="QQ Bot" className="h-5.5 w-5.5 dark:invert" />;
default:
return <span className="text-[22px] font-semibold">{type.slice(0, 1).toUpperCase() || 'C'}</span>;
}
}
function AgentModelDialog({
open,
agent,
providerAccounts,
providerStatuses,
providerVendors,
providerDefaultAccountId,
defaultModelRef,
copy,
onClose,
onUpdateModel,
onFeedback,
}: {
open: boolean;
agent: AgentSummary;
providerAccounts: ProviderAccount[];
providerStatuses: ProviderWithKeyInfo[];
providerVendors: ProviderVendorInfo[];
providerDefaultAccountId: string | null;
defaultModelRef: string | null;
copy: AgentSettingsDialogCopy;
onClose: () => void;
onUpdateModel: (agentId: string, modelRef: string | null) => Promise<void> | void;
onFeedback?: (message: string, tone?: FeedbackTone) => void;
}) {
const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
const [modelIdInput, setModelIdInput] = useState('');
const [savingModel, setSavingModel] = useState(false);
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const runtimeProviderOptions = useMemo<RuntimeProviderOption[]>(() => {
const vendorMap = new Map<string, ProviderVendorInfo>(providerVendors.map((vendor) => [vendor.id, vendor]));
const statusById = new Map<string, ProviderWithKeyInfo>(providerStatuses.map((status) => [status.id, status]));
const entries = providerAccounts
.filter((account) => account.enabled && hasConfiguredProviderCredentials(account, statusById))
.sort((left, right) => {
if (left.id === providerDefaultAccountId) return -1;
if (right.id === providerDefaultAccountId) return 1;
return right.updatedAt.localeCompare(left.updatedAt);
});
const deduped = new Map<string, RuntimeProviderOption>();
for (const account of entries) {
const runtimeProviderKey = resolveRuntimeProviderKey(account);
if (!runtimeProviderKey || deduped.has(runtimeProviderKey)) continue;
const vendor = vendorMap.get(account.vendorId);
const configuredModelId = account.model
? (account.model.startsWith(`${runtimeProviderKey}/`)
? account.model.slice(runtimeProviderKey.length + 1)
: account.model)
: undefined;
deduped.set(runtimeProviderKey, {
runtimeProviderKey,
accountId: account.id,
label: `${account.label} (${vendor?.name || account.vendorId})`,
modelIdPlaceholder: vendor?.modelIdPlaceholder,
configuredModelId,
});
}
return [...deduped.values()];
}, [providerAccounts, providerDefaultAccountId, providerStatuses, providerVendors]);
useEffect(() => {
if (!open) return;
const override = splitModelRef(agent.overrideModelRef);
if (override) {
setSelectedRuntimeProviderKey(override.providerKey);
setModelIdInput(override.modelId);
setErrorMessage(null);
return;
}
const effective = splitModelRef(agent.modelRef || defaultModelRef);
if (effective) {
setSelectedRuntimeProviderKey(effective.providerKey);
setModelIdInput(effective.modelId);
setErrorMessage(null);
return;
}
setSelectedRuntimeProviderKey(runtimeProviderOptions[0]?.runtimeProviderKey || '');
setModelIdInput('');
setErrorMessage(null);
}, [agent.modelRef, agent.overrideModelRef, defaultModelRef, open, runtimeProviderOptions]);
if (!open) return null;
const selectedProvider = runtimeProviderOptions.find((option) => option.runtimeProviderKey === selectedRuntimeProviderKey) || null;
const trimmedModelId = modelIdInput.trim();
const nextModelRef = selectedRuntimeProviderKey && trimmedModelId
? `${selectedRuntimeProviderKey}/${trimmedModelId}`
: '';
const normalizedDefaultModelRef = String(defaultModelRef ?? '').trim();
const isUsingDefaultModelInForm = Boolean(normalizedDefaultModelRef) && nextModelRef === normalizedDefaultModelRef;
const currentOverrideModelRef = String(agent.overrideModelRef ?? '').trim();
const desiredOverrideModelRef = nextModelRef && nextModelRef !== normalizedDefaultModelRef
? nextModelRef
: null;
const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
function handleRequestClose() {
if (savingModel || modelChanged) {
setShowCloseConfirm(true);
return;
}
onClose();
}
async function handleSaveModel(): Promise<void> {
if (!selectedRuntimeProviderKey) {
setErrorMessage(copy.modelProviderRequired);
return;
}
if (!trimmedModelId) {
setErrorMessage(copy.modelIdRequired);
return;
}
if (!modelChanged) return;
if (!nextModelRef.includes('/')) {
setErrorMessage(copy.modelInvalid);
return;
}
setSavingModel(true);
setErrorMessage(null);
try {
await onUpdateModel(agent.id, desiredOverrideModelRef);
onFeedback?.(desiredOverrideModelRef ? copy.modelSaved : copy.modelReset, 'success');
onClose();
} catch (error) {
setErrorMessage(`${copy.modelSaveFailedPrefix}${error instanceof Error ? error.message : String(error)}`);
} finally {
setSavingModel(false);
}
}
function handleUseDefaultModel() {
const parsedDefault = splitModelRef(normalizedDefaultModelRef);
if (!parsedDefault) {
setSelectedRuntimeProviderKey('');
setModelIdInput('');
setErrorMessage(null);
return;
}
setSelectedRuntimeProviderKey(parsedDefault.providerKey);
setModelIdInput(parsedDefault.modelId);
setErrorMessage(null);
}
return (
<>
<div className="fixed inset-0 z-60 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-xl overflow-hidden rounded-3xl bg-[#f3f1e9] shadow-2xl dark:bg-[#1f1f22]">
<div className="flex items-start justify-between gap-4 px-6 pb-2 pt-6">
<div>
<h3
className="text-[26px] font-normal leading-none tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
{copy.modelLabel}
</h3>
<p className="mt-3 text-[15px] leading-6 text-[#525866] dark:text-gray-400">
{copy.modelOverrideDescription.replace('{defaultModel}', defaultModelRef || '-')}
</p>
</div>
<button
type="button"
onClick={handleRequestClose}
className="rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
aria-label={copy.closeLabel}
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-6 pb-6 pt-4">
{errorMessage ? (
<div className="rounded-[14px] border border-red-500/20 bg-red-500/10 px-4 py-3 text-[13px] text-red-600 dark:text-red-300">
{errorMessage}
</div>
) : null}
<div className="space-y-2">
<label htmlFor="agent-model-provider" className={labelClasses}>{copy.modelProviderLabel}</label>
<select
id="agent-model-provider"
value={selectedRuntimeProviderKey}
onChange={(event) => {
const nextProvider = event.target.value;
setSelectedRuntimeProviderKey(nextProvider);
setErrorMessage(null);
if (!modelIdInput.trim()) {
const option = runtimeProviderOptions.find((candidate) => candidate.runtimeProviderKey === nextProvider);
setModelIdInput(option?.configuredModelId || '');
}
}}
className={selectClasses}
>
<option value="">{copy.modelProviderPlaceholder}</option>
{runtimeProviderOptions.map((option) => (
<option key={option.runtimeProviderKey} value={option.runtimeProviderKey}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label htmlFor="agent-model-id" className={labelClasses}>{copy.modelIdLabel}</label>
<input
id="agent-model-id"
value={modelIdInput}
onChange={(event) => {
setModelIdInput(event.target.value);
setErrorMessage(null);
}}
placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || copy.modelIdPlaceholder}
className={inputClasses}
/>
</div>
{nextModelRef ? (
<p className="break-all text-[12px] font-mono text-[#525866] dark:text-gray-400">
{copy.modelPreview}: {nextModelRef}
</p>
) : null}
{runtimeProviderOptions.length === 0 ? (
<p className="text-[12px] text-amber-600 dark:text-amber-400">
{copy.modelProviderEmpty}
</p>
) : null}
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={handleUseDefaultModel}
disabled={savingModel || !normalizedDefaultModelRef || isUsingDefaultModelInForm}
className="inline-flex h-9 items-center rounded-full border border-black/10 bg-transparent px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
>
{copy.useDefaultModel}
</button>
<button
type="button"
onClick={handleRequestClose}
className="inline-flex h-9 items-center rounded-full border border-black/10 bg-transparent px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
disabled={savingModel}
>
{copy.cancelLabel}
</button>
<button
type="button"
onClick={() => {
void handleSaveModel();
}}
disabled={savingModel || !selectedRuntimeProviderKey || !trimmedModelId || !modelChanged}
className="inline-flex h-9 items-center rounded-full bg-[#7E9DF5] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#6E90F3] disabled:cursor-not-allowed disabled:opacity-60"
>
{savingModel ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
copy.saveLabel
)}
</button>
</div>
</div>
</div>
</div>
<AgentsConfirmDialog
open={showCloseConfirm}
title={copy.unsavedChangesTitle}
message={copy.unsavedChangesMessage}
cancelLabel={copy.cancelLabel}
confirmLabel={copy.closeWithoutSaving}
onClose={() => setShowCloseConfirm(false)}
onConfirm={() => {
setShowCloseConfirm(false);
onClose();
}}
/>
</>
);
}
export default function AgentSettingsDialog({
open,
agent,
channelGroups,
providerAccounts,
providerLoading,
providerError,
defaultProviderAccountId,
providerStatuses,
providerVendors,
providerDefaultAccountId,
defaultModelRef,
mainWorkspacePath,
channelSummary,
accountBindingCount,
saving,
deleting,
copy,
onClose,
onSave,
onDelete,
onOpenChannels,
onOpenModels,
onUpdateName,
onUpdateModel,
onFeedback,
}: AgentSettingsDialogProps) {
const [name, setName] = useState('');
const [providerAccountId, setProviderAccountId] = useState('');
const [modelRef, setModelRef] = useState('');
const [savingName, setSavingName] = useState(false);
const [showModelDialog, setShowModelDialog] = useState(false);
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!agent || !open) return;
setName(agent.name);
setProviderAccountId(agent.providerAccountId ?? '');
setModelRef(agent.overrideModelRef ?? '');
setErrorMessage(null);
}, [agent, open]);
const selectedProvider = useMemo(
() => providerAccounts.find((account) => account.id === providerAccountId) ?? null,
[providerAccountId, providerAccounts],
);
const assignedChannels = useMemo(() => {
if (!agent) return [];
const defaultProvider = useMemo(
() => providerAccounts.find((account) => account.id === defaultProviderAccountId) ?? null,
[defaultProviderAccountId, providerAccounts],
return channelGroups.flatMap((group) =>
group.accounts
.filter((account) => account.agentId === agent.id)
.map((account) => ({
channelType: group.channelType,
channelLabel: getChannelDisplayName(group),
accountId: account.accountId,
name: account.accountId === 'default' ? copy.mainAccount : account.name || account.accountId,
error: account.lastError,
})),
);
}, [agent, channelGroups, copy.mainAccount]);
if (!agent) return null;
useEffect(() => {
if (!open) {
setShowModelDialog(false);
setShowCloseConfirm(false);
}
}, [open]);
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 ?? '')
);
if (!open || !agent) return null;
const hasNameChanges = name.trim() !== agent.name;
const effectiveModelRef = String(agent.modelRef || defaultModelRef || '').trim();
const modelSummary = effectiveModelRef
? (String(agent.modelDisplay || effectiveModelRef).trim() || effectiveModelRef)
: copy.notConfigured;
function handleRequestClose() {
if (savingName || hasNameChanges) {
setShowCloseConfirm(true);
return;
}
onClose();
}
async function handleSaveName(): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName || trimmedName === agent.name) return;
setSavingName(true);
setErrorMessage(null);
try {
await onUpdateName(agent.id, trimmedName);
onFeedback?.(copy.nameSaved, 'success');
} catch (error) {
setErrorMessage(`${copy.nameSaveFailedPrefix}${error instanceof Error ? error.message : String(error)}`);
} finally {
setSavingName(false);
}
}
return (
<AgentsDialogSurface
open={open}
onClose={onClose}
title={copy.title}
subtitle={copy.subtitle}
widthClassName="max-w-[980px]"
<>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl bg-[#f3f1e9] shadow-2xl dark:bg-[#1f1f22]">
<div className="flex shrink-0 flex-row items-start justify-between px-6 pb-2 pt-6">
<div>
<h2
className="text-2xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
<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}
{copy.title}
</h2>
<p className="mt-3 text-[15px] text-[#525866] dark:text-gray-400">
{copy.description}
</p>
</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>
<button
type="button"
onClick={handleRequestClose}
className="rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
aria-label={copy.closeLabel}
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 pt-4">
{errorMessage ? (
<div className="mb-5 rounded-[14px] border border-red-500/20 bg-red-500/10 px-4 py-3 text-[13px] text-red-600 dark:text-red-300">
{errorMessage}
</div>
) : null}
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-2.5">
<label htmlFor="agent-settings-name" className={labelClasses}>{copy.nameLabel}</label>
<div className="flex gap-2">
<input
id="agent-settings-name"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={isDefault || saving || deleting}
className={FIELD_CLASS_NAME}
readOnly={agent.isDefault}
className={inputClasses}
/>
</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>
{!agent.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={onOpenChannels}
onClick={() => {
void handleSaveName();
}}
disabled={savingName || !name.trim() || name.trim() === agent.name}
className="inline-flex h-11 items-center rounded-xl border border-black/10 bg-[#eeece3] px-2 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
>
{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}
{savingName ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
copy.saveLabel
)}
</button>
) : null}
</div>
</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}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 rounded-2xl border border-transparent bg-black/5 p-4 dark:bg-white/5">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-[#7B8190] dark:text-gray-500">
{copy.agentIdLabel}
</p>
<p className="font-mono text-[13px] text-[#171717] dark:text-[#f3f4f6]">{agent.id}</p>
</div>
<button
type="button"
onClick={() => setShowModelDialog(true)}
className="space-y-1 rounded-2xl border border-transparent bg-black/5 p-4 text-left transition-colors hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<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>
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-[#7B8190] dark:text-gray-500">
{copy.modelLabel}
</p>
<p className="text-[13.5px] text-[#171717] dark:text-[#f3f4f6]">
{modelSummary}
{agent.inheritedModel ? ` (${copy.inherited})` : ''}
</p>
<p className="break-all font-mono text-[12px] text-[#525866] dark:text-gray-400">
{agent.modelRef || defaultModelRef || '-'}
</p>
</button>
</div>
</div>
<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 className="space-y-4">
<div>
<span className="font-semibold text-[#2E3445] dark:text-[#f3f4f6]">
{copy.effectiveProviderLabel}
</span>
<span className="ml-2">{effectiveProviderLabel}</span>
<h3
className="text-xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
{copy.channelsTitle}
</h3>
<p className="mt-1 text-[14px] text-[#525866] dark:text-gray-400">{copy.channelsDescription}</p>
</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>
{assignedChannels.length === 0 && agent.channelTypes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-black/10 bg-black/5 p-4 text-[13.5px] text-[#667085] dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
{copy.noChannels}
</div>
) : (
<div className="space-y-3">
{assignedChannels.map((channel) => (
<div key={`${channel.channelType}-${channel.accountId}`} className="flex items-center justify-between rounded-2xl border border-transparent bg-black/5 p-4 dark:bg-white/5">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-full border border-black/5 bg-black/5 text-[#171717] shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-[#f3f4f6]">
<ChannelLogo type={channel.channelType} />
</div>
<div className="min-w-0">
<p className="text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">{channel.name}</p>
<p className="text-[13.5px] text-[#667085] dark:text-gray-400">
{channel.channelLabel} · {channel.accountId === 'default' ? copy.mainAccount : channel.accountId}
</p>
{channel.error ? (
<p className="mt-1 text-xs text-red-600 dark:text-red-300">{channel.error}</p>
) : null}
</div>
</div>
<div className="shrink-0" />
</div>
))}
{assignedChannels.length === 0 && agent.channelTypes.length > 0 ? (
<div className="rounded-2xl border border-dashed border-black/10 bg-black/5 p-4 text-[13.5px] text-[#667085] dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
{copy.channelsManagedInChannels}
</div>
) : null}
</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>
</div>
</div>
</div>
<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);
<AgentModelDialog
open={showModelDialog}
agent={agent}
providerAccounts={providerAccounts}
providerStatuses={providerStatuses}
providerVendors={providerVendors}
providerDefaultAccountId={providerDefaultAccountId}
defaultModelRef={defaultModelRef}
copy={copy}
onClose={() => setShowModelDialog(false)}
onUpdateModel={onUpdateModel}
onFeedback={onFeedback}
/>
<AgentsConfirmDialog
open={showCloseConfirm}
title={copy.unsavedChangesTitle}
message={copy.unsavedChangesMessage}
cancelLabel={copy.cancelLabel}
confirmLabel={copy.closeWithoutSaving}
onClose={() => setShowCloseConfirm(false)}
onConfirm={() => {
setShowCloseConfirm(false);
setName(agent.name);
setErrorMessage(null);
onClose();
}}
>
{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>
/>
</>
);
}

View File

@@ -0,0 +1,81 @@
import * as Dialog from '@radix-ui/react-dialog';
type AgentsConfirmDialogProps = {
open: boolean;
busy?: boolean;
title: string;
message: string;
cancelLabel: string;
confirmLabel: string;
onClose: () => void;
onConfirm: () => void;
};
export default function AgentsConfirmDialog({
open,
busy = false,
title,
message,
cancelLabel,
confirmLabel,
onClose,
onConfirm,
}: AgentsConfirmDialogProps) {
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && !busy) {
onClose();
}
}
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-60 bg-black/45 backdrop-blur-[2px]" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-70 w-[calc(100vw-32px)] max-w-126 -translate-x-1/2 -translate-y-1/2 rounded-3xl bg-[#F4F3EB] p-0 shadow-[0_30px_80px_rgba(15,23,42,0.18)] outline-none dark:bg-[#1f1f22]"
onEscapeKeyDown={(event) => {
if (busy) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => {
if (busy) {
event.preventDefault();
}
}}
>
<div className="border-b border-black/6 px-6 py-5 dark:border-white/6">
<Dialog.Title
className="text-[26px] font-normal leading-none tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
{title}
</Dialog.Title>
<Dialog.Description className="mt-3 text-[14px] leading-6 text-[#525866] dark:text-gray-400">
{message}
</Dialog.Description>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-5">
<button
type="button"
className="inline-flex h-10 items-center rounded-full border border-black/10 px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
onClick={onClose}
disabled={busy}
>
{cancelLabel}
</button>
<button
type="button"
className="inline-flex h-10 items-center rounded-full bg-[#7E9DF5] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#6E90F3] disabled:cursor-not-allowed disabled:opacity-60"
onClick={onConfirm}
disabled={busy}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,15 +1,17 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, 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 type { ChannelAccountCatalogGroup, ChannelAccountsCatalogResponse } from '../../lib/channel-types';
import type { ProviderVendorInfo, ProviderWithKeyInfo } from '../../lib/providers';
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events';
import { agentsStore, useAgentsStore } from '../../stores';
import AddAgentDialog from './components/AddAgentDialog';
import AgentCard from './components/AgentCard';
import AgentsConfirmDialog from './components/AgentsConfirmDialog';
import AgentSettingsDialog from './components/AgentSettingsDialog';
function interpolateFallback(template: string, params?: Record<string, string | number>): string {
@@ -34,10 +36,6 @@ function formatChannelLabel(channelType: string): string {
.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,
@@ -67,8 +65,14 @@ function Spinner() {
);
}
type FeedbackTone = 'success' | 'error' | 'info';
type FeedbackState = {
id: number;
tone: FeedbackTone;
message: string;
} | null;
export default function AgentsPage() {
const navigate = useNavigate();
const { t, hasMessage } = useI18n();
const initialized = useAgentsStore((state) => state.initialized);
const loading = useAgentsStore((state) => state.loading);
@@ -77,14 +81,20 @@ export default function AgentsPage() {
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 feedbackTimerRef = useRef<number | null>(null);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [settingsAgentId, setSettingsAgentId] = useState<string | null>(null);
const [agentToDelete, setAgentToDelete] = useState<AgentSummary | null>(null);
const [providerAccounts, setProviderAccounts] = useState<ProviderAccount[]>([]);
const [providerStatuses, setProviderStatuses] = useState<ProviderWithKeyInfo[]>([]);
const [providerVendors, setProviderVendors] = useState<ProviderVendorInfo[]>([]);
const [providerLoading, setProviderLoading] = useState(false);
const [providerError, setProviderError] = useState<string | null>(null);
const [channelGroups, setChannelGroups] = useState<ChannelAccountCatalogGroup[]>([]);
const [channelLoading, setChannelLoading] = useState(false);
const message = (path: string, fallback: string, params?: Record<string, string | number>) => (
hasMessage(path)
@@ -92,19 +102,47 @@ export default function AgentsPage() {
: interpolateFallback(fallback, params)
);
function pushFeedback(nextMessage: string, tone: FeedbackTone = 'info') {
const nextFeedback = { id: Date.now(), tone, message: nextMessage };
setFeedback(nextFeedback);
if (feedbackTimerRef.current) {
window.clearTimeout(feedbackTimerRef.current);
}
feedbackTimerRef.current = window.setTimeout(() => {
setFeedback((current) => (current?.id === nextFeedback.id ? null : current));
}, 3600);
}
useEffect(() => {
return () => {
if (feedbackTimerRef.current) {
window.clearTimeout(feedbackTimerRef.current);
}
};
}, []);
useEffect(() => {
void agentsStore.init();
}, []);
useEffect(() => {
void loadProviderAccounts();
void Promise.allSettled([
loadProviderCatalog(),
loadChannelGroups(),
]);
}, []);
useEffect(() => (
onGatewayEvent((event) => {
if (!isRuntimeChangedGatewayEvent(event)) return;
if (!runtimeEventHasTopic(event, 'providers', 'models', 'agents')) return;
void loadProviderAccounts(false);
if (!runtimeEventHasTopic(event, 'providers', 'models', 'agents', 'channels')) return;
void Promise.allSettled([
loadProviderCatalog(false),
loadChannelGroups(false),
]);
})
), []);
@@ -122,25 +160,29 @@ export default function AgentsPage() {
[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> {
async function loadProviderCatalog(showLoading = true): Promise<void> {
if (showLoading) {
setProviderLoading(true);
}
setProviderError(null);
try {
const accounts = await hostApiFetch<ProviderAccount[]>('/api/provider-accounts');
const [accounts, statuses, vendors] = await Promise.all([
hostApiFetch<ProviderAccount[]>('/api/provider-accounts'),
hostApiFetch<ProviderWithKeyInfo[]>('/api/providers'),
hostApiFetch<ProviderVendorInfo[]>('/api/provider-vendors'),
]);
setProviderAccounts(Array.isArray(accounts) ? accounts.filter((account) => account?.enabled !== false) : []);
setProviderStatuses(Array.isArray(statuses) ? statuses : []);
setProviderVendors(Array.isArray(vendors) ? vendors : []);
} catch (requestError) {
setProviderAccounts([]);
setProviderStatuses([]);
setProviderVendors([]);
setProviderError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
if (showLoading) {
@@ -149,10 +191,30 @@ export default function AgentsPage() {
}
}
async function loadChannelGroups(showLoading = true): Promise<void> {
if (showLoading) {
setChannelLoading(true);
}
try {
const response = await hostApiFetch<ChannelAccountsCatalogResponse>('/api/channels/accounts');
setChannelGroups(Array.isArray(response.channels) ? response.channels : []);
} catch {
if (showLoading) {
setChannelGroups([]);
}
} finally {
if (showLoading) {
setChannelLoading(false);
}
}
}
async function handleRefresh(): Promise<void> {
await Promise.allSettled([
agentsStore.refresh(),
loadProviderAccounts(),
loadProviderCatalog(),
loadChannelGroups(),
]);
}
@@ -161,52 +223,36 @@ export default function AgentsPage() {
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 },
),
pushFeedback(message('agents.feedback.created', 'Agent 已创建。'), 'success');
} catch (createError) {
pushFeedback(
message('agents.feedback.createFailed', '创建 Agent 失败:{error}', {
error: createError instanceof Error ? createError.message : String(createError),
}),
'error',
);
if (!confirmed) return;
} finally {
setBusyAction(null);
}
}
setBusyAction(`delete:${agent.id}`);
async function handleConfirmDeleteAgent(): Promise<void> {
if (!agentToDelete) return;
const deletingAgent = agentToDelete;
setBusyAction(`delete:${deletingAgent.id}`);
try {
await agentsStore.deleteAgent(agent.id);
setSettingsAgentId((current) => (current === agent.id ? null : current));
await agentsStore.deleteAgent(deletingAgent.id);
setSettingsAgentId((current) => (current === deletingAgent.id ? null : current));
setAgentToDelete(null);
pushFeedback(message('agents.feedback.deleted', 'Agent 已删除。'), 'success');
} catch (deleteError) {
pushFeedback(
message('agents.feedback.deleteFailed', '删除 Agent 失败:{error}', {
error: deleteError instanceof Error ? deleteError.message : String(deleteError),
}),
'error',
);
} finally {
setBusyAction(null);
}
@@ -232,6 +278,7 @@ export default function AgentsPage() {
modelLabel: message('agents.card.modelLabel', 'Model'),
channelsLabel: message('agents.fields.channels', '频道'),
settingsLabel: message('agents.actions.settings', '设置'),
deleteLabel: message('agents.actions.delete', '删除'),
emptyTitle: message('agents.emptyTitle', '暂无 Agent'),
emptyDescription: message('agents.emptyDescription', '创建新的 Agent 后,这里会显示对应的卡片摘要。'),
notConfigured: message('agents.card.missingModel', 'Not configured'),
@@ -247,39 +294,50 @@ export default function AgentsPage() {
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', '基础信息'),
title: settingsAgent
? message('agents.settings.titleWithName', '{name} 设置', { name: settingsAgent.name })
: message('agents.settings.title', 'Agent 设置'),
description: message('agents.settings.description', '更新 Agent 名称,并管理哪些频道归属于这个 Agent。'),
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', '删除'),
closeLabel: message('agents.settings.closeLabel', '关闭弹窗'),
agentIdLabel: message('agents.settings.agentIdLabel', 'Agent ID'),
modelLabel: message('agents.settings.modelLabel', 'Model'),
notConfigured: message('agents.settings.notConfigured', 'Not configured'),
inherited: message('agents.inherited', 'Inherited'),
channelsTitle: message('agents.settings.channelsTitle', '频道'),
channelsDescription: message('agents.settings.channelsDescription', '该列表为只读。频道账号与绑定关系请在 Channels 页面管理。'),
noChannels: message('agents.settings.noChannels', '这个 Agent 还没有分配任何频道。'),
channelsManagedInChannels: message('agents.settings.channelsManagedInChannels', '该 Agent 当前没有显式账号归属。频道级归属请前往 Channels 页面查看。'),
mainAccount: message('agents.settings.mainAccount', '主账号'),
cancelLabel: message('dialog.cancel', '取消'),
unsavedChangesTitle: message('agents.settings.unsavedChangesTitle', '放弃未保存的更改?'),
unsavedChangesMessage: message('agents.settings.unsavedChangesMessage', '当前有尚未保存的修改。关闭后这些修改将丢失。'),
closeWithoutSaving: message('agents.settings.closeWithoutSaving', '直接关闭'),
modelOverrideDescription: message('agents.settings.modelOverrideDescription', '更新这个 Agent 的模型覆盖设置。当前默认模型:{defaultModel}'),
modelProviderLabel: message('agents.settings.modelProviderLabel', '模型 Provider'),
modelProviderPlaceholder: message('agents.settings.modelProviderPlaceholder', '请选择 Provider'),
modelIdLabel: message('agents.settings.modelIdLabel', '模型 ID'),
modelIdPlaceholder: message('agents.settings.modelIdPlaceholder', 'gpt-5.4'),
modelPreview: message('agents.settings.modelPreview', '模型预览'),
modelProviderEmpty: message('agents.settings.modelProviderEmpty', '当前没有可用于模型配置的 Provider。请先在 Models 页面完成账号配置。'),
useDefaultModel: message('agents.settings.useDefaultModel', '使用默认模型'),
modelProviderRequired: message('agents.settings.modelProviderRequired', '请选择一个 Provider。'),
modelIdRequired: message('agents.settings.modelIdRequired', '请输入模型 ID。'),
modelInvalid: message('agents.settings.modelInvalid', '模型配置格式不正确。'),
nameSaved: message('agents.feedback.agentUpdated', 'Agent 名称已更新。'),
nameSaveFailedPrefix: message('agents.feedback.agentUpdateFailedPrefix', '更新 Agent 名称失败:'),
modelSaved: message('agents.feedback.agentModelUpdated', 'Agent 模型已更新。'),
modelReset: message('agents.feedback.agentModelReset', 'Agent 已恢复默认模型。'),
modelSaveFailedPrefix: message('agents.feedback.agentModelUpdateFailedPrefix', '更新 Agent 模型失败:'),
},
deleteDialog: {
title: message('agents.deleteDialog.title', '删除 Agent'),
message: agentToDelete
? message('agents.deleteDialog.message', '确定删除 Agent “{name}”吗?现有聊天记录仍会保留在磁盘中。', { name: agentToDelete.name })
: '',
confirmLabel: busyAction && agentToDelete ? message('agents.deleteDialog.deleting', '删除中...') : message('agents.deleteDialog.confirm', '确认删除'),
},
};
@@ -303,19 +361,19 @@ export default function AgentsPage() {
<button
type="button"
disabled={isBusy}
className="inline-flex px-4 py-2 items-center gap-3 rounded-full border border-black/12 bg-white/50 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]"
className="inline-flex items-center gap-3 rounded-full border border-black/12 bg-white/50 px-4 py-2 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(' ')} />
<RefreshCw className={['h-5 w-5', (loading || providerLoading || channelLoading) ? 'animate-spin' : ''].join(' ')} />
{pageCopy.refresh}
</button>
<button
type="button"
disabled={isBusy}
className="inline-flex px-4 py-2 items-center gap-3 rounded-full bg-[#2E67F8] text-[18px] font-semibold text-white transition-colors hover:bg-[#2458E0] disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex items-center gap-3 rounded-full bg-[#2E67F8] px-4 py-2 text-[18px] font-semibold text-white transition-colors hover:bg-[#2458E0] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
setAddDialogOpen(true);
}}
@@ -327,6 +385,19 @@ export default function AgentsPage() {
</div>
<div className="mt-10 space-y-4">
{feedback ? (
<div
className={[
'rounded-3xl border px-5 py-4 text-[15px]',
feedback.tone === 'success' ? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : '',
feedback.tone === 'error' ? 'border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300' : '',
feedback.tone === 'info' ? 'border-black/10 bg-white text-[#525866] dark:border-gray-700 dark:bg-[#222225] dark:text-gray-300' : '',
].join(' ')}
>
{feedback.message}
</div>
) : null}
{warning ? (
<div className="rounded-3xl 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">
@@ -349,7 +420,7 @@ export default function AgentsPage() {
<div className="rounded-3xl 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>
<span>{`${message('agents.settings.providerLoadErrorPrefix', '加载 Provider 账号失败:')}${providerError}`}</span>
</div>
</div>
) : null}
@@ -376,8 +447,10 @@ export default function AgentsPage() {
channelsLabel={pageCopy.channelsLabel}
channelsValue={getAgentChannelsValue(agent, pageCopy.none)}
settingsLabel={pageCopy.settingsLabel}
deleteLabel={pageCopy.deleteLabel}
disabled={isBusy}
onOpenSettings={(targetAgent) => setSettingsAgentId(targetAgent.id)}
onDelete={agent.isDefault ? undefined : (targetAgent) => setAgentToDelete(targetAgent)}
/>
))
)}
@@ -399,26 +472,34 @@ export default function AgentsPage() {
<AgentSettingsDialog
open={Boolean(settingsAgent)}
agent={settingsAgent}
channelGroups={channelGroups}
providerAccounts={providerAccounts}
providerLoading={providerLoading}
providerError={providerError}
defaultProviderAccountId={defaultProviderAccountId}
providerStatuses={providerStatuses}
providerVendors={providerVendors}
providerDefaultAccountId={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={() => setSettingsAgentId(null)}
onUpdateName={(agentId, name) => agentsStore.updateAgent(agentId, name)}
onUpdateModel={(agentId, modelRef) => agentsStore.updateAgentModel(agentId, modelRef)}
onFeedback={pushFeedback}
/>
<AgentsConfirmDialog
open={!!agentToDelete}
busy={Boolean(agentToDelete && busyAction === `delete:${agentToDelete.id}`)}
title={pageCopy.deleteDialog.title}
message={pageCopy.deleteDialog.message}
cancelLabel={message('dialog.cancel', '取消')}
confirmLabel={pageCopy.deleteDialog.confirmLabel}
onClose={() => {
if (!isBusy) {
setSettingsAgentId(null);
if (!busyAction) {
setAgentToDelete(null);
}
}}
onSave={handleSaveSettings}
onDelete={handleDeleteAgent}
onOpenChannels={() => navigate('/channels')}
onOpenModels={() => navigate('/models')}
onConfirm={() => {
void handleConfirmDeleteAgent();
}}
/>
</section>
);