diff --git a/docs/todo-list.md b/docs/todo-list.md index 5af00f1..a8e198e 100644 --- a/docs/todo-list.md +++ b/docs/todo-list.md @@ -2,10 +2,8 @@ 1、任务列表 - 完成 2、走本地模型配置,重构模型对话功能 - 完成 -3、上传表单信息+读取信息,脚本执行录取表单 -4、定时任务脚本关联多个脚本执行 -5、一键打开渠道可以新增渠道 - 完成 -6、把龙虾包装到对话 -7、迁移频道功能 -8、迁移agent功能 -9、知识库调整成上传文件,查看文件列表 \ No newline at end of file +3、一键打开渠道可以新增渠道 - 完成 +4、把龙虾包装到对话 +5、迁移频道功能 - 完成 +6、迁移agent功能 - 完成 +7、知识库调整成上传文件,查看文件列表 - 完成 \ No newline at end of file diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index af67080..f57230a 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -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: { diff --git a/src/pages/Agents/components/AgentCard.tsx b/src/pages/Agents/components/AgentCard.tsx index ea21132..3b9919a 100644 --- a/src/pages/Agents/components/AgentCard.tsx +++ b/src/pages/Agents/components/AgentCard.tsx @@ -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 ( -
+
@@ -50,15 +54,29 @@ export default function AgentCard({
- +
+ {!agent.isDefault && onDelete ? ( + + ) : null} + + +
); diff --git a/src/pages/Agents/components/AgentSettingsDialog.tsx b/src/pages/Agents/components/AgentSettingsDialog.tsx index 812b849..94c81a4 100644 --- a/src/pages/Agents/components/AgentSettingsDialog.tsx +++ b/src/pages/Agents/components/AgentSettingsDialog.tsx @@ -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; - onDelete: (agent: AgentSummary) => Promise | void; - onOpenChannels: () => void; - onOpenModels: () => void; + onUpdateName: (agentId: string, name: string) => Promise | void; + onUpdateModel: (agentId: string, modelRef: string | null) => Promise | 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, +): 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 Telegram; + case 'discord': + return Discord; + case 'whatsapp': + return WhatsApp; + case 'wechat': + return WeChat; + case 'dingtalk': + return DingTalk; + case 'feishu': + return Feishu; + case 'wecom': + return WeCom; + case 'qqbot': + return QQ Bot; + default: + return {type.slice(0, 1).toUpperCase() || 'C'}; + } +} + +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; + 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(null); + + const runtimeProviderOptions = useMemo(() => { + const vendorMap = new Map(providerVendors.map((vendor) => [vendor.id, vendor])); + const statusById = new Map(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(); + 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 { + 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 ( + <> +
+
+
+
+

+ {copy.modelLabel} +

+

+ {copy.modelOverrideDescription.replace('{defaultModel}', defaultModelRef || '-')} +

+
+ + +
+ +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + +
+ +
+ + { + setModelIdInput(event.target.value); + setErrorMessage(null); + }} + placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || copy.modelIdPlaceholder} + className={inputClasses} + /> +
+ + {nextModelRef ? ( +

+ {copy.modelPreview}: {nextModelRef} +

+ ) : null} + + {runtimeProviderOptions.length === 0 ? ( +

+ {copy.modelProviderEmpty} +

+ ) : null} + +
+ + + +
+
+
+
+ + 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(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 { + 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 ( - -
-
-
-
- {copy.identityTitle} -
-
- - -
-
- {copy.agentIdLabel} -
-
- {agent.id} -
-
- -
-
- {copy.workspaceTitle} -
-
- {copy.workspaceDescription} -
-
-
-
- {copy.workspaceLabel} -
-
- {agent.workspace || '--'} -
-
-
-
- {copy.inheritedWorkspaceLabel} -
-
- {inheritedWorkspace ? copy.inheritedWorkspaceYes : copy.inheritedWorkspaceNo} -
-
-
-
-
-
- -
-
-
-
- {copy.channelsTitle} -
-
- {copy.channelSummaryLabel} -
-
- -
- -
-
-
- {copy.channelSummaryLabel} -
-
- {channelSummary || copy.noChannels} -
-
-
-
- {copy.accountBindingsLabel} -
-
- {accountBindingCount} -
-
-
-
-
- -
-
+ <> +
+
+
-
- {copy.modelTitle} -
-
- {isDefault ? copy.managedFromModels : copy.modelHelp} -
-
- {isDefault ? ( - - ) : null} + {copy.title} + +

+ {copy.description} +

+
+ +
- {isDefault ? null : ( -
- +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} - - -
-
- - {copy.effectiveProviderLabel} - - {effectiveProviderLabel} +
+
+
+ +
+ setName(event.target.value)} + readOnly={agent.isDefault} + className={inputClasses} + /> + {!agent.isDefault ? ( + + ) : null} +
-
- - {copy.effectiveModelLabel} - - - {effectiveModelLabel} - + +
+
+

+ {copy.agentIdLabel} +

+

{agent.id}

+
+ +
+ +
+
+

+ {copy.channelsTitle} +

+

{copy.channelsDescription}

+
+ + {assignedChannels.length === 0 && agent.channelTypes.length === 0 ? ( +
+ {copy.noChannels} +
+ ) : ( +
+ {assignedChannels.map((channel) => ( +
+
+
+ +
+ +
+

{channel.name}

+

+ {channel.channelLabel} · {channel.accountId === 'default' ? copy.mainAccount : channel.accountId} +

+ {channel.error ? ( +

{channel.error}

+ ) : null} +
+
+ +
+
+ ))} + + {assignedChannels.length === 0 && agent.channelTypes.length > 0 ? ( +
+ {copy.channelsManagedInChannels} +
+ ) : null} +
+ )} +
- )} - - {providerError && !isDefault ? ( -
- {`${copy.providerLoadErrorPrefix}${providerError}`} -
- ) : null} -
- -
- {!isDefault ? ( - - ) : null} - - - - {!isDefault ? ( - - ) : null} +
-
+ + setShowModelDialog(false)} + onUpdateModel={onUpdateModel} + onFeedback={onFeedback} + /> + + setShowCloseConfirm(false)} + onConfirm={() => { + setShowCloseConfirm(false); + setName(agent.name); + setErrorMessage(null); + onClose(); + }} + /> + ); } diff --git a/src/pages/Agents/components/AgentsConfirmDialog.tsx b/src/pages/Agents/components/AgentsConfirmDialog.tsx new file mode 100644 index 0000000..04ec2c0 --- /dev/null +++ b/src/pages/Agents/components/AgentsConfirmDialog.tsx @@ -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 ( + + + + { + if (busy) { + event.preventDefault(); + } + }} + onPointerDownOutside={(event) => { + if (busy) { + event.preventDefault(); + } + }} + > +
+ + {title} + + + {message} + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index ad89cb5..383f3ce 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -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 { @@ -34,10 +36,6 @@ function formatChannelLabel(channelType: string): string { .join(' '); } -function countAccountBindings(agentId: string, channelAccountOwners: Record): 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(null); + const [feedback, setFeedback] = useState(null); const [busyAction, setBusyAction] = useState(null); const [addDialogOpen, setAddDialogOpen] = useState(false); const [settingsAgentId, setSettingsAgentId] = useState(null); + const [agentToDelete, setAgentToDelete] = useState(null); const [providerAccounts, setProviderAccounts] = useState([]); + const [providerStatuses, setProviderStatuses] = useState([]); + const [providerVendors, setProviderVendors] = useState([]); const [providerLoading, setProviderLoading] = useState(false); const [providerError, setProviderError] = useState(null); + const [channelGroups, setChannelGroups] = useState([]); + const [channelLoading, setChannelLoading] = useState(false); const message = (path: string, fallback: string, params?: Record) => ( 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 { + async function loadProviderCatalog(showLoading = true): Promise { if (showLoading) { setProviderLoading(true); } setProviderError(null); try { - const accounts = await hostApiFetch('/api/provider-accounts'); + const [accounts, statuses, vendors] = await Promise.all([ + hostApiFetch('/api/provider-accounts'), + hostApiFetch('/api/providers'), + hostApiFetch('/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 { + if (showLoading) { + setChannelLoading(true); + } + + try { + const response = await hostApiFetch('/api/channels/accounts'); + setChannelGroups(Array.isArray(response.channels) ? response.channels : []); + } catch { + if (showLoading) { + setChannelGroups([]); + } + } finally { + if (showLoading) { + setChannelLoading(false); + } + } + } + async function handleRefresh(): Promise { 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); + 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', + ); } finally { setBusyAction(null); } } - async function handleSaveSettings(input: { name: string; providerAccountId: string | null; modelRef: string | null }): Promise { - if (!settingsAgent || settingsAgent.isDefault) { - setSettingsAgentId(null); - return; - } + async function handleConfirmDeleteAgent(): Promise { + if (!agentToDelete) return; - setBusyAction(`save:${settingsAgent.id}`); + const deletingAgent = agentToDelete; + setBusyAction(`delete:${deletingAgent.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 { - 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)); + 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() {