feat(agent-model): add per-agent model override with default-reset UX and runtime sync (#651)

This commit is contained in:
Felix
2026-03-25 10:13:11 +08:00
committed by GitHub
Unverified
parent 9d40e1fa05
commit ab8fe760ef
16 changed files with 871 additions and 26 deletions

View File

@@ -30,6 +30,17 @@
"nameLabel": "Agent Name",
"agentIdLabel": "Agent ID",
"modelLabel": "Model",
"modelOverrideLabel": "Model Override",
"modelProviderLabel": "Provider",
"modelProviderPlaceholder": "Select a provider",
"modelProviderEmpty": "No provider accounts configured. Add one in Settings → AI Providers.",
"modelIdLabel": "Model ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "Preview",
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)",
"modelOverrideDescription": "Select provider and model ID for this agent.",
"saveModelOverride": "Save model",
"useDefaultModel": "Use default model",
"channelsTitle": "Channels",
"channelsDescription": "This list is read-only. Manage channel accounts and bindings in the Channels page.",
"mainAccount": "Main account",
@@ -48,6 +59,13 @@
"agentDeleteFailed": "Failed to delete agent: {{error}}",
"agentUpdated": "Agent updated",
"agentUpdateFailed": "Failed to update agent: {{error}}",
"agentModelInvalid": "Model must be in provider/model format",
"agentModelProviderRequired": "Select a provider first",
"agentModelIdRequired": "Model ID is required",
"agentModelUpdated": "Agent model updated",
"agentModelUpdateFailed": "Failed to update agent model: {{error}}",
"agentModelReset": "Agent model reset to default",
"agentModelResetFailed": "Failed to reset agent model: {{error}}",
"channelAssigned": "{{channel}} assigned to agent",
"channelAssignFailed": "Failed to assign channel: {{error}}",
"channelRemoved": "{{channel}} removed",

View File

@@ -30,6 +30,17 @@
"nameLabel": "Agent 名",
"agentIdLabel": "Agent ID",
"modelLabel": "Model",
"modelOverrideLabel": "モデル上書き",
"modelProviderLabel": "Provider",
"modelProviderPlaceholder": "Provider を選択",
"modelProviderEmpty": "Provider アカウントが未設定です。設定 → AI Providers で追加してください。",
"modelIdLabel": "モデル ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "プレビュー",
"modelOverridePlaceholder": "provider/model例: openrouter/openai/gpt-5.4",
"modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。",
"saveModelOverride": "モデルを保存",
"useDefaultModel": "デフォルトモデルを使用",
"channelsTitle": "Channels",
"channelsDescription": "この一覧は読み取り専用です。チャンネルアカウントと紐付けは Channels ページで管理してください。",
"mainAccount": "メインアカウント",
@@ -48,6 +59,13 @@
"agentDeleteFailed": "Agent の削除に失敗しました: {{error}}",
"agentUpdated": "Agent を更新しました",
"agentUpdateFailed": "Agent の更新に失敗しました: {{error}}",
"agentModelInvalid": "モデル形式は provider/model で指定してください",
"agentModelProviderRequired": "先に Provider を選択してください",
"agentModelIdRequired": "モデル ID は必須です",
"agentModelUpdated": "Agent のモデルを更新しました",
"agentModelUpdateFailed": "Agent モデルの更新に失敗しました: {{error}}",
"agentModelReset": "Agent モデルをデフォルトに戻しました",
"agentModelResetFailed": "Agent モデルのリセットに失敗しました: {{error}}",
"channelAssigned": "{{channel}} を Agent に割り当てました",
"channelAssignFailed": "Channel の割り当てに失敗しました: {{error}}",
"channelRemoved": "{{channel}} を削除しました",

View File

@@ -30,6 +30,17 @@
"nameLabel": "Agent 名称",
"agentIdLabel": "Agent ID",
"modelLabel": "Model",
"modelOverrideLabel": "模型覆盖",
"modelProviderLabel": "Provider",
"modelProviderPlaceholder": "选择 Provider",
"modelProviderEmpty": "尚未配置 Provider 账号。请先前往 设置 → AI Providers 添加。",
"modelIdLabel": "模型 ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "预览",
"modelOverridePlaceholder": "provider/model例如openrouter/openai/gpt-5.4",
"modelOverrideDescription": "为该 Agent 选择 Provider 和模型 ID。",
"saveModelOverride": "保存模型",
"useDefaultModel": "使用默认模型",
"channelsTitle": "频道",
"channelsDescription": "该列表为只读。频道账号与绑定关系请在 Channels 页面管理。",
"mainAccount": "主账号",
@@ -48,6 +59,13 @@
"agentDeleteFailed": "删除 Agent 失败:{{error}}",
"agentUpdated": "Agent 已更新",
"agentUpdateFailed": "更新 Agent 失败:{{error}}",
"agentModelInvalid": "模型格式必须为 provider/model",
"agentModelProviderRequired": "请先选择 Provider",
"agentModelIdRequired": "模型 ID 不能为空",
"agentModelUpdated": "Agent 模型已更新",
"agentModelUpdateFailed": "更新 Agent 模型失败:{{error}}",
"agentModelReset": "Agent 模型已恢复为默认",
"agentModelResetFailed": "恢复 Agent 默认模型失败:{{error}}",
"channelAssigned": "{{channel}} 已分配给 Agent",
"channelAssignFailed": "分配频道失败:{{error}}",
"channelRemoved": "{{channel}} 已移除",

View File

@@ -10,10 +10,12 @@ import { Switch } from '@/components/ui/switch';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { useAgentsStore } from '@/stores/agents';
import { useGatewayStore } from '@/stores/gateway';
import { useProviderStore } from '@/stores/providers';
import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events';
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
import type { AgentSummary } from '@/types/agent';
import type { ProviderAccount, ProviderVendorInfo, ProviderWithKeyInfo } from '@/lib/providers';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -43,9 +45,57 @@ interface ChannelGroupItem {
accounts: ChannelAccountItem[];
}
interface 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 = (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;
}
export function Agents() {
const { t } = useTranslation('agents');
const gatewayStatus = useGatewayStore((state) => state.status);
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
const lastGatewayStateRef = useRef(gatewayStatus.state);
const {
agents,
@@ -72,8 +122,8 @@ export function Agents() {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
}, [fetchAgents, fetchChannelAccounts]);
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]);
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);
useEffect(() => {
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
@@ -316,6 +366,7 @@ function AgentCard({
}
const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const selectClasses = 'h-[44px] w-full rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground px-3';
const labelClasses = 'text-[14px] text-foreground/80 font-bold';
function ChannelLogo({ type }: { type: ChannelType }) {
@@ -438,9 +489,10 @@ function AgentSettingsModal({
onClose: () => void;
}) {
const { t } = useTranslation('agents');
const { updateAgent } = useAgentsStore();
const { updateAgent, defaultModelRef } = useAgentsStore();
const [name, setName] = useState(agent.name);
const [savingName, setSavingName] = useState(false);
const [showModelModal, setShowModelModal] = useState(false);
useEffect(() => {
setName(agent.name);
@@ -530,7 +582,11 @@ function AgentSettingsModal({
</p>
<p className="font-mono text-[13px] text-foreground">{agent.id}</p>
</div>
<div className="space-y-1 rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4">
<button
type="button"
onClick={() => setShowModelModal(true)}
className="space-y-1 rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4 text-left hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<p className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground/80 font-medium">
{t('settingsDialog.modelLabel')}
</p>
@@ -538,7 +594,10 @@ function AgentSettingsModal({
{agent.modelDisplay}
{agent.inheritedModel ? ` (${t('inherited')})` : ''}
</p>
</div>
<p className="font-mono text-[12px] text-foreground/70 break-all">
{agent.modelRef || defaultModelRef || '-'}
</p>
</button>
</div>
</div>
@@ -587,6 +646,233 @@ function AgentSettingsModal({
</div>
</CardContent>
</Card>
{showModelModal && (
<AgentModelModal
agent={agent}
onClose={() => setShowModelModal(false)}
/>
)}
</div>
);
}
function AgentModelModal({
agent,
onClose,
}: {
agent: AgentSummary;
onClose: () => void;
}) {
const { t } = useTranslation('agents');
const providerAccounts = useProviderStore((state) => state.accounts);
const providerStatuses = useProviderStore((state) => state.statuses);
const providerVendors = useProviderStore((state) => state.vendors);
const providerDefaultAccountId = useProviderStore((state) => state.defaultAccountId);
const { updateAgentModel, defaultModelRef } = useAgentsStore();
const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
const [modelIdInput, setModelIdInput] = useState('');
const [savingModel, setSavingModel] = useState(false);
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 label = `${account.label} (${vendor?.name || 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,
modelIdPlaceholder: vendor?.modelIdPlaceholder,
configuredModelId,
});
}
return [...deduped.values()];
}, [providerAccounts, providerDefaultAccountId, providerStatuses, providerVendors]);
useEffect(() => {
const override = splitModelRef(agent.overrideModelRef);
if (override) {
setSelectedRuntimeProviderKey(override.providerKey);
setModelIdInput(override.modelId);
return;
}
const effective = splitModelRef(agent.modelRef || defaultModelRef);
if (effective) {
setSelectedRuntimeProviderKey(effective.providerKey);
setModelIdInput(effective.modelId);
return;
}
setSelectedRuntimeProviderKey(runtimeProviderOptions[0]?.runtimeProviderKey || '');
setModelIdInput('');
}, [agent.modelRef, agent.overrideModelRef, defaultModelRef, runtimeProviderOptions]);
const selectedProvider = runtimeProviderOptions.find((option) => option.runtimeProviderKey === selectedRuntimeProviderKey) || null;
const trimmedModelId = modelIdInput.trim();
const nextModelRef = selectedRuntimeProviderKey && trimmedModelId
? `${selectedRuntimeProviderKey}/${trimmedModelId}`
: '';
const normalizedDefaultModelRef = (defaultModelRef || '').trim();
const isUsingDefaultModelInForm = Boolean(normalizedDefaultModelRef) && nextModelRef === normalizedDefaultModelRef;
const currentOverrideModelRef = (agent.overrideModelRef || '').trim();
const desiredOverrideModelRef = nextModelRef && nextModelRef !== normalizedDefaultModelRef
? nextModelRef
: null;
const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
const handleSaveModel = async () => {
if (!selectedRuntimeProviderKey) {
toast.error(t('toast.agentModelProviderRequired'));
return;
}
if (!trimmedModelId) {
toast.error(t('toast.agentModelIdRequired'));
return;
}
if (!modelChanged) return;
if (!nextModelRef.includes('/')) {
toast.error(t('toast.agentModelInvalid'));
return;
}
setSavingModel(true);
try {
await updateAgentModel(agent.id, desiredOverrideModelRef);
toast.success(desiredOverrideModelRef ? t('toast.agentModelUpdated') : t('toast.agentModelReset'));
onClose();
} catch (error) {
toast.error(t('toast.agentModelUpdateFailed', { error: String(error) }));
} finally {
setSavingModel(false);
}
};
const handleUseDefaultModel = () => {
const parsedDefault = splitModelRef(normalizedDefaultModelRef);
if (!parsedDefault) {
setSelectedRuntimeProviderKey('');
setModelIdInput('');
return;
}
setSelectedRuntimeProviderKey(parsedDefault.providerKey);
setModelIdInput(parsedDefault.modelId);
};
return (
<div className="fixed inset-0 z-[60] bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-xl rounded-3xl border-0 shadow-2xl bg-[#f3f1e9] dark:bg-card overflow-hidden">
<CardHeader className="flex flex-row items-start justify-between pb-2">
<div>
<CardTitle className="text-2xl font-serif font-normal tracking-tight">
{t('settingsDialog.modelLabel')}
</CardTitle>
<CardDescription className="text-[15px] mt-1 text-foreground/70">
{t('settingsDialog.modelOverrideDescription', { defaultModel: defaultModelRef || '-' })}
</CardDescription>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4 p-6 pt-4">
<div className="space-y-2">
<Label htmlFor="agent-model-provider" className="text-[12px] text-foreground/70">{t('settingsDialog.modelProviderLabel')}</Label>
<select
id="agent-model-provider"
value={selectedRuntimeProviderKey}
onChange={(event) => {
const nextProvider = event.target.value;
setSelectedRuntimeProviderKey(nextProvider);
if (!modelIdInput.trim()) {
const option = runtimeProviderOptions.find((candidate) => candidate.runtimeProviderKey === nextProvider);
setModelIdInput(option?.configuredModelId || '');
}
}}
className={selectClasses}
>
<option value="">{t('settingsDialog.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="text-[12px] text-foreground/70">{t('settingsDialog.modelIdLabel')}</Label>
<Input
id="agent-model-id"
value={modelIdInput}
onChange={(event) => setModelIdInput(event.target.value)}
placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || t('settingsDialog.modelIdPlaceholder')}
className={inputClasses}
/>
</div>
{!!nextModelRef && (
<p className="text-[12px] font-mono text-foreground/70 break-all">
{t('settingsDialog.modelPreview')}: {nextModelRef}
</p>
)}
{runtimeProviderOptions.length === 0 && (
<p className="text-[12px] text-amber-600 dark:text-amber-400">
{t('settingsDialog.modelProviderEmpty')}
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={handleUseDefaultModel}
disabled={savingModel || !normalizedDefaultModelRef || isUsingDefaultModelInForm}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
>
{t('settingsDialog.useDefaultModel')}
</Button>
<Button
variant="outline"
onClick={onClose}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
>
{t('common:actions.cancel')}
</Button>
<Button
onClick={() => void handleSaveModel()}
disabled={savingModel || !selectedRuntimeProviderKey || !trimmedModelId || !modelChanged}
className="h-9 text-[13px] font-medium rounded-full px-4 shadow-none"
>
{savingModel ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
t('common:actions.save')
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import type { AgentSummary, AgentsSnapshot } from '@/types/agent';
interface AgentsState {
agents: AgentSummary[];
defaultAgentId: string;
defaultModelRef: string | null;
configuredChannelTypes: string[];
channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;
@@ -14,6 +15,7 @@ interface AgentsState {
fetchAgents: () => Promise<void>;
createAgent: (name: string, options?: { inheritWorkspace?: boolean }) => Promise<void>;
updateAgent: (agentId: string, name: string) => Promise<void>;
updateAgentModel: (agentId: string, modelRef: string | null) => Promise<void>;
deleteAgent: (agentId: string) => Promise<void>;
assignChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
removeChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
@@ -24,6 +26,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
return snapshot ? {
agents: snapshot.agents ?? [],
defaultAgentId: snapshot.defaultAgentId ?? 'main',
defaultModelRef: snapshot.defaultModelRef ?? null,
configuredChannelTypes: snapshot.configuredChannelTypes ?? [],
channelOwners: snapshot.channelOwners ?? {},
channelAccountOwners: snapshot.channelAccountOwners ?? {},
@@ -33,6 +36,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
export const useAgentsStore = create<AgentsState>((set) => ({
agents: [],
defaultAgentId: 'main',
defaultModelRef: null,
configuredChannelTypes: [],
channelOwners: {},
channelAccountOwners: {},
@@ -83,6 +87,23 @@ export const useAgentsStore = create<AgentsState>((set) => ({
}
},
updateAgentModel: async (agentId: string, modelRef: string | null) => {
set({ error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>(
`/api/agents/${encodeURIComponent(agentId)}/model`,
{
method: 'PUT',
body: JSON.stringify({ modelRef }),
}
);
set(applySnapshot(snapshot));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
deleteAgent: async (agentId: string) => {
set({ error: null });
try {

View File

@@ -3,6 +3,8 @@ export interface AgentSummary {
name: string;
isDefault: boolean;
modelDisplay: string;
modelRef?: string | null;
overrideModelRef?: string | null;
inheritedModel: boolean;
workspace: string;
agentDir: string;
@@ -13,6 +15,7 @@ export interface AgentSummary {
export interface AgentsSnapshot {
agents: AgentSummary[];
defaultAgentId: string;
defaultModelRef?: string | null;
configuredChannelTypes: string[];
channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;