diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 9a107511..e60fedb5 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -79,6 +79,9 @@ import zaiImage from '@/assets/model/zai.svg'; const LOCAL_PROVIDER_NAMES = ['ollama', 'vllm', 'sglang', 'lmstudio']; const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434/v1'; +const OLLAMA_ENDPOINT_AUTO_FIX_TITLE = 'Ollama endpoint updated'; +const OLLAMA_ENDPOINT_AUTO_FIX_DESC = + 'Added /v1 once. You can remove it if not needed.'; // Sidebar tab types type SidebarTab = @@ -193,6 +196,8 @@ export default function SettingModels() { const [ollamaModelsError, setOllamaModelsError] = useState( null ); + const [ollamaEndpointAutoFixedOnce, setOllamaEndpointAutoFixedOnce] = + useState(false); // Fetch available models from Ollama API const fetchOllamaModels = async (endpoint?: string) => { @@ -627,13 +632,65 @@ export default function SettingModels() { }; // Local Model verification + const isOllamaEndpointMissingV1 = (endpoint: string): boolean => { + const trimmed = endpoint.trim(); + if (!trimmed) return false; + try { + const normalizedPath = new URL(trimmed).pathname.replace(/\/+$/, ''); + return !normalizedPath.endsWith('/v1'); + } catch { + return !trimmed.replace(/\/+$/, '').endsWith('/v1'); + } + }; + const canAutoFixOllamaEndpoint = (endpoint: string): boolean => { + const trimmed = endpoint.trim(); + if (!trimmed || !isOllamaEndpointMissingV1(trimmed)) return false; + try { + // Auto-fix only when endpoint has no extra path, e.g. http://localhost:11434 + const normalizedPath = new URL(trimmed).pathname.replace(/\/+$/, ''); + return normalizedPath === ''; + } catch { + const withoutQueryOrHash = trimmed.split(/[?#]/)[0] || ''; + const normalized = withoutQueryOrHash.replace(/\/+$/, ''); + return !normalized.includes('/'); + } + }; + const appendV1ToEndpoint = (endpoint: string): string => { + const trimmed = endpoint.trim(); + if (!trimmed) return trimmed; + try { + const parsed = new URL(trimmed); + const normalizedPath = parsed.pathname.replace(/\/+$/, ''); + parsed.pathname = `${normalizedPath}/v1`.replace(/\/{2,}/g, '/'); + return parsed.toString(); + } catch { + return `${trimmed.replace(/\/+$/, '')}/v1`; + } + }; + const handleLocalVerify = async () => { setLocalVerifying(true); setLocalError(null); setLocalInputError(false); - const currentEndpoint = localEndpoints[localPlatform] || ''; + let currentEndpoint = localEndpoints[localPlatform] || ''; const currentType = localTypes[localPlatform] || ''; + // Fallback guard for fast save interactions: ensure one-time auto-fix + // still applies even if blur state hasn't committed yet. + if ( + localPlatform === 'ollama' && + !ollamaEndpointAutoFixedOnce && + canAutoFixOllamaEndpoint(currentEndpoint) + ) { + const fixedEndpoint = appendV1ToEndpoint(currentEndpoint); + currentEndpoint = fixedEndpoint; + setLocalEndpoints((prev) => ({ + ...prev, + [localPlatform]: fixedEndpoint, + })); + setOllamaEndpointAutoFixedOnce(true); + } + if (!currentEndpoint) { setLocalError(t('setting.endpoint-url-can-not-be-empty')); setLocalInputError(true); @@ -899,6 +956,7 @@ export default function SettingModels() { setActiveModelIdx(null); // Re-fetch Ollama models after reset if (localPlatform === 'ollama') { + setOllamaEndpointAutoFixedOnce(false); setOllamaModelsError(null); fetchOllamaModels(DEFAULT_OLLAMA_ENDPOINT); } @@ -1535,6 +1593,25 @@ export default function SettingModels() { setOllamaModelsError(null); } }} + onBlur={(e) => { + if ( + platform !== 'ollama' || + ollamaEndpointAutoFixedOnce || + !canAutoFixOllamaEndpoint(e.target.value) + ) { + return; + } + const fixedEndpoint = appendV1ToEndpoint(e.target.value); + setLocalEndpoints((prev) => ({ + ...prev, + [platform]: fixedEndpoint, + })); + setOllamaEndpointAutoFixedOnce(true); + toast(OLLAMA_ENDPOINT_AUTO_FIX_TITLE, { + description: OLLAMA_ENDPOINT_AUTO_FIX_DESC, + closeButton: true, + }); + }} disabled={!localEnabled} placeholder={ platform === 'ollama'