feat(agent-model): add per-agent model override with default-reset UX and runtime sync (#651)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user