From a94ee371a731e44bc402a786ddc1d69ebb3b37b1 Mon Sep 17 00:00:00 2001 From: Korivi Date: Wed, 25 Mar 2026 10:37:00 +0900 Subject: [PATCH 1/5] Ollama Local LLM Integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 states the setup screen handles: 1. Checking (automatic) When the user arrives at this step, the app immediately and silently checks if Ollama is running on the machine in the background. The user sees a loading spinner — no action required. 2. Already running If Ollama is already running (even if it was started for a completely different project), the app detects it automatically. The user sees a URL field pre-filled with http://localhost:11434 (any port it auto finds)and a Test Connection button. They click Test, the app verifies it works, and the Next button unlocks. No API key, no manual setup. 3. Installed but not running If Ollama is installed on the machine but the server is not currently active, the user sees a Start Ollama button. Clicking it starts the Ollama server in the background automatically. The UI then moves to the "running" state above. 4. Not installed at all If Ollama is not installed on the machine, the user sees an Install Ollama button. Clicking it triggers an automatic installation process — the app tries to use the system package manager (winget on Windows, or a script on Mac/Linux). A live scrolling log shows the installation progress in real time. After installation completes, the app automatically starts Ollama and connects. 5. Custom port support If the user is running Ollama on a non-default port (e.g. 11435 instead of 11434), they can simply edit the URL in the text field and click Test. Whatever URL they test successfully is what gets saved and used going forward. --- app/onboarding/interfaces/steps.py | 30 +- app/tui/settings.py | 35 ++ app/ui_layer/adapters/browser_adapter.py | 108 ++++++- .../src/contexts/WebSocketContext.tsx | 131 +++++++- .../Onboarding/OnboardingPage.module.css | 131 ++++++++ .../src/pages/Onboarding/OnboardingPage.tsx | 306 +++++++++++++----- .../browser/frontend/src/types/index.ts | 57 ++++ app/ui_layer/local_llm_setup.py | 181 +++++++++++ app/ui_layer/onboarding/controller.py | 8 +- 9 files changed, 906 insertions(+), 81 deletions(-) create mode 100644 app/ui_layer/local_llm_setup.py diff --git a/app/onboarding/interfaces/steps.py b/app/onboarding/interfaces/steps.py index d69ed828..99a8922a 100644 --- a/app/onboarding/interfaces/steps.py +++ b/app/onboarding/interfaces/steps.py @@ -127,11 +127,9 @@ def get_default(self) -> str: class ApiKeyStep: - """API key input step.""" + """API key input step — or Ollama connection setup for the remote provider.""" name = "api_key" - title = "Enter API Key" - description = "Enter your API key for the selected provider." required = True # Maps provider to environment variable name @@ -140,19 +138,39 @@ class ApiKeyStep: "gemini": "GOOGLE_API_KEY", "byteplus": "BYTEPLUS_API_KEY", "anthropic": "ANTHROPIC_API_KEY", - "remote": None, # Ollama doesn't need API key + "remote": None, # Ollama uses a base URL, not an API key } def __init__(self, provider: str = "openai"): self.provider = provider + @property + def title(self) -> str: + if self.provider == "remote": + return "Connect Ollama" + return "Enter API Key" + + @property + def description(self) -> str: + if self.provider == "remote": + return ( + "Connect to your local Ollama instance.\n" + "If Ollama isn't installed yet, we'll help you set it up." + ) + return "Enter your API key for the selected provider." + def get_options(self) -> List[StepOption]: # Free-form input, no options return [] def validate(self, value: Any) -> tuple[bool, Optional[str]]: - # Remote (Ollama) doesn't need API key if self.provider == "remote": + # Value is the Ollama base URL + if not value or not isinstance(value, str): + return True, None # Empty = use default URL + v = value.strip() + if not (v.startswith("http://") or v.startswith("https://")): + return False, "Please enter a valid URL (e.g. http://localhost:11434)" return True, None if not value or not isinstance(value, str): @@ -164,6 +182,8 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: return True, None def get_default(self) -> str: + if self.provider == "remote": + return "http://localhost:11434" # Check settings.json for existing key from app.config import get_api_key return get_api_key(self.provider) diff --git a/app/tui/settings.py b/app/tui/settings.py index db881831..54d95638 100644 --- a/app/tui/settings.py +++ b/app/tui/settings.py @@ -100,6 +100,41 @@ def save_settings_to_json(provider: str, api_key: str) -> bool: save_settings_to_env = save_settings_to_json +def save_remote_endpoint(url: str) -> bool: + """Save the Ollama (remote) base URL to settings.json. + + Args: + url: The base URL for the Ollama server (e.g. http://localhost:11434) + + Returns: + True if saved successfully, False otherwise + """ + try: + settings = _load_settings() + + if "model" not in settings: + settings["model"] = {} + settings["model"]["llm_provider"] = "remote" + settings["model"]["vlm_provider"] = "remote" + + if "endpoints" not in settings: + settings["endpoints"] = {} + settings["endpoints"]["remote"] = url + + if not _save_settings(settings): + return False + + from app.config import reload_settings + reload_settings() + + logger.info(f"[SETTINGS] Saved remote endpoint={url} to settings.json") + return True + + except Exception as e: + logger.error(f"[SETTINGS] Failed to save remote endpoint: {e}") + return False + + def get_api_key_env_name(provider: str) -> Optional[str]: """Get the environment variable name for a provider's API key.""" if provider not in PROVIDER_CONFIG: diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index ca34cd0a..0e749c94 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1204,6 +1204,17 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: elif msg_type == "onboarding_back": await self._handle_onboarding_back() + # Local LLM (Ollama) helpers + elif msg_type == "local_llm_check": + await self._handle_local_llm_check() + elif msg_type == "local_llm_test": + url = data.get("url", "http://localhost:11434") + await self._handle_local_llm_test(url) + elif msg_type == "local_llm_install": + await self._handle_local_llm_install() + elif msg_type == "local_llm_start": + await self._handle_local_llm_start() + async def _handle_dashboard_metrics_filter(self, period: str) -> None: """Handle filtered metrics request for specific time period.""" try: @@ -1282,6 +1293,7 @@ async def _handle_onboarding_step_get(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1318,8 +1330,25 @@ async def _handle_onboarding_step_submit(self, value: Any) -> None: step = controller.get_current_step() if step.name == "api_key": provider = controller.get_collected_data().get("provider", "openai") - # Remote/Ollama provider doesn't require API key validation - if provider != "remote" and value: + if provider == "remote": + # Test Ollama connection with the submitted URL + ollama_url = (value or "http://localhost:11434").strip() + from app.ui_layer.local_llm_setup import test_ollama_connection_sync + test_result = test_ollama_connection_sync(ollama_url) + if not test_result.get("success"): + err = test_result.get("error", "Cannot reach Ollama") + await self._broadcast({ + "type": "onboarding_submit", + "data": { + "success": False, + "error": f"Ollama connection failed: {err}", + "index": controller.current_step_index, + }, + }) + return + # Normalise the value to the URL that actually worked + value = ollama_url + elif value: test_result = test_connection( provider=provider, api_key=value, @@ -1384,6 +1413,7 @@ async def _handle_onboarding_step_submit(self, value: Any) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1458,6 +1488,7 @@ async def _handle_onboarding_skip(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1513,6 +1544,7 @@ async def _handle_onboarding_back(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1526,6 +1558,78 @@ async def _handle_onboarding_back(self) -> None: }, }) + # ── Local LLM (Ollama) handlers ────────────────────────────────────────── + + async def _handle_local_llm_check(self) -> None: + """Return Ollama installation and runtime status.""" + try: + from app.ui_layer.local_llm_setup import get_ollama_status + status = get_ollama_status() + await self._broadcast({ + "type": "local_llm_check", + "data": {"success": True, **status}, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error checking status: {e}") + await self._broadcast({ + "type": "local_llm_check", + "data": {"success": False, "error": str(e)}, + }) + + async def _handle_local_llm_test(self, url: str) -> None: + """Test an HTTP connection to a running Ollama instance.""" + try: + from app.ui_layer.local_llm_setup import test_ollama_connection_sync + result = test_ollama_connection_sync(url) + await self._broadcast({ + "type": "local_llm_test", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error testing connection: {e}") + await self._broadcast({ + "type": "local_llm_test", + "data": {"success": False, "error": str(e)}, + }) + + async def _handle_local_llm_install(self) -> None: + """Install Ollama, streaming progress back to the client.""" + async def progress_callback(msg: str) -> None: + await self._broadcast({ + "type": "local_llm_install_progress", + "data": {"message": msg}, + }) + + try: + from app.ui_layer.local_llm_setup import install_ollama + result = await install_ollama(progress_callback) + await self._broadcast({ + "type": "local_llm_install", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error installing: {e}") + await self._broadcast({ + "type": "local_llm_install", + "data": {"success": False, "error": str(e)}, + }) + + async def _handle_local_llm_start(self) -> None: + """Start the Ollama server.""" + try: + from app.ui_layer.local_llm_setup import start_ollama + result = await start_ollama() + await self._broadcast({ + "type": "local_llm_start", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error starting Ollama: {e}") + await self._broadcast({ + "type": "local_llm_start", + "data": {"success": False, "error": str(e)}, + }) + async def _handle_task_cancel(self, task_id: str) -> None: """Cancel a running task.""" try: diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 37c64425..53576017 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallback, ReactNode } from 'react' -import type { ChatMessage, ActionItem, AgentStatus, InitialState, WSMessage, DashboardMetrics, TaskCancelResponse, FilteredDashboardMetrics, MetricsTimePeriod, OnboardingStep, OnboardingStepResponse, OnboardingSubmitResponse, OnboardingCompleteResponse } from '../types' +import type { ChatMessage, ActionItem, AgentStatus, InitialState, WSMessage, DashboardMetrics, TaskCancelResponse, FilteredDashboardMetrics, MetricsTimePeriod, OnboardingStep, OnboardingStepResponse, OnboardingSubmitResponse, OnboardingCompleteResponse, LocalLLMState, LocalLLMCheckResponse, LocalLLMTestResponse, LocalLLMInstallResponse, LocalLLMProgressResponse } from '../types' import { getWsUrl } from '../utils/connection' // Pending attachment type for upload @@ -27,6 +27,8 @@ interface WebSocketState { onboardingStep: OnboardingStep | null onboardingError: string | null onboardingLoading: boolean + // Local LLM (Ollama) state + localLLM: LocalLLMState } interface WebSocketContextType extends WebSocketState { @@ -42,6 +44,11 @@ interface WebSocketContextType extends WebSocketState { submitOnboardingStep: (value: string | string[]) => void skipOnboardingStep: () => void goBackOnboardingStep: () => void + // Local LLM (Ollama) methods + checkLocalLLM: () => void + testLocalLLMConnection: (url: string) => void + installLocalLLM: () => void + startLocalLLM: () => void } const defaultState: WebSocketState = { @@ -71,6 +78,12 @@ const defaultState: WebSocketState = { onboardingStep: null, onboardingError: null, onboardingLoading: false, + // Local LLM (Ollama) state + localLLM: { + phase: 'idle', + defaultUrl: 'http://localhost:11434', + installProgress: [], + }, } const WebSocketContext = createContext(undefined) @@ -428,6 +441,87 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } break } + + // ── Local LLM (Ollama) ─────────────────────────────────────────────── + case 'local_llm_check': { + const r = msg.data as unknown as LocalLLMCheckResponse + if (!r.success) { + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'error', error: r.error } })) + break + } + let phase: LocalLLMState['phase'] + if (r.running) { + phase = 'running' + } else if (r.installed) { + phase = 'not_running' + } else { + phase = 'not_installed' + } + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase, + version: r.version, + defaultUrl: r.default_url || 'http://localhost:11434', + error: undefined, + testResult: undefined, + }, + })) + break + } + + case 'local_llm_test': { + const r = msg.data as unknown as LocalLLMTestResponse + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: r.success ? 'connected' : prev.localLLM.phase, + testResult: { success: r.success, message: r.message, error: r.error, models: r.models }, + }, + })) + break + } + + case 'local_llm_install_progress': { + const r = msg.data as unknown as LocalLLMProgressResponse + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + installProgress: [...prev.localLLM.installProgress, r.message], + }, + })) + break + } + + case 'local_llm_install': { + const r = msg.data as unknown as LocalLLMInstallResponse + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: r.success ? 'not_running' : 'error', + error: r.success ? undefined : (r.error ?? 'Installation failed'), + }, + })) + break + } + + case 'local_llm_start': { + const r = msg.data as unknown as LocalLLMInstallResponse + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: r.success ? 'running' : 'error', + error: r.success ? undefined : (r.error ?? 'Failed to start Ollama'), + testResult: undefined, + }, + })) + break + } } }, []) @@ -524,6 +618,37 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + // Local LLM (Ollama) methods + const checkLocalLLM = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'checking', error: undefined } })) + wsRef.current.send(JSON.stringify({ type: 'local_llm_check' })) + } + }, []) + + const testLocalLLMConnection = useCallback((url: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'local_llm_test', url })) + } + }, []) + + const installLocalLLM = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'installing', installProgress: [], error: undefined }, + })) + wsRef.current.send(JSON.stringify({ type: 'local_llm_install' })) + } + }, []) + + const startLocalLLM = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'starting', error: undefined } })) + wsRef.current.send(JSON.stringify({ type: 'local_llm_start' })) + } + }, []) + return ( {children} diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css index 5a4c81b8..86cbc553 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css @@ -541,6 +541,137 @@ } } +/* ───────────────────────────────────────────────────────────────────── + Ollama / Local LLM Setup + ───────────────────────────────────────────────────────────────────── */ + +.ollamaBox { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); +} + +.ollamaStatusRow { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + font-weight: var(--font-medium); +} + +.ollamaStatusLabel { + color: var(--text-primary); +} + +.ollamaHint { + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 0; + line-height: 1.5; +} + +.ollamaSuccessMsg { + font-size: var(--text-sm); + color: var(--color-success); + margin: 0; +} + +.ollamaLabel { + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.ollamaInputRow { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.ollamaInput { + flex: 1; + padding: var(--space-2) var(--space-3); + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: var(--text-sm); + font-family: var(--font-mono, monospace); + transition: border-color var(--transition-base); +} + +.ollamaInput:focus { + outline: none; + border-color: var(--color-primary); +} + +.ollamaError { + font-size: var(--text-xs); + color: var(--color-error); + margin: 0; +} + +.ollamaChecking { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.spinnerSmall { + width: 16px; + height: 16px; + border: 2px solid var(--border-primary); + border-top-color: var(--color-primary); + border-radius: var(--radius-full); + animation: spin 1s linear infinite; + flex-shrink: 0; +} + +.installLog { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 140px; + overflow-y: auto; + padding: var(--space-2) var(--space-3); + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); +} + +.installLogLine { + font-size: var(--text-xs); + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; +} + +.iconSuccess { + color: var(--color-success); + flex-shrink: 0; +} + +.iconError { + color: var(--color-error); + flex-shrink: 0; +} + +.iconWarning { + color: var(--color-warning); + flex-shrink: 0; +} + +.iconMuted { + color: var(--text-tertiary); + flex-shrink: 0; +} + /* Touch device optimizations */ @media (hover: none) and (pointer: coarse) { .optionItem { diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index 0ac87419..ab64fd28 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -21,6 +21,11 @@ import { ClipboardList, Cloud, Sheet, + Download, + Play, + Wifi, + WifiOff, + RefreshCw, type LucideIcon, } from 'lucide-react' import { Button } from '../../components/ui' @@ -49,6 +54,175 @@ const ICON_MAP: Record = { const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'MCP Servers', 'Skills'] +// ── Ollama local-setup component ───────────────────────────────────────────── + +interface OllamaSetupProps { + defaultUrl: string + onConnected: (url: string) => void +} + +function OllamaSetup({ defaultUrl, onConnected }: OllamaSetupProps) { + const { localLLM, checkLocalLLM, testLocalLLMConnection, installLocalLLM, startLocalLLM } = useWebSocket() + const [url, setUrl] = useState(defaultUrl) + + // Auto-check on mount + useEffect(() => { + checkLocalLLM() + }, [checkLocalLLM]) + + // Notify parent when connected + useEffect(() => { + if (localLLM.phase === 'connected' && localLLM.testResult?.success) { + onConnected(url) + } + }, [localLLM.phase, localLLM.testResult, url, onConnected]) + + const { phase, installProgress, testResult, error } = localLLM + + const isWorking = phase === 'checking' || phase === 'installing' || phase === 'starting' + + // ── Checking ── + if (phase === 'idle' || phase === 'checking') { + return ( +
+
+
+ Checking if Ollama is running… +
+
+ ) + } + + // ── Not installed ── + if (phase === 'not_installed') { + return ( +
+
+ + Ollama is not installed +
+

+ Ollama lets you run AI models locally — no cloud needed. We'll install it automatically for you. +

+ +
+ ) + } + + // ── Installing ── + if (phase === 'installing') { + return ( +
+
+
+ Installing Ollama… +
+
+ {installProgress.length === 0 && Starting…} + {installProgress.map((line, i) => ( + {line} + ))} +
+
+ ) + } + + // ── Installed but not running ── + if (phase === 'not_running') { + return ( +
+
+ + Ollama is installed but not running +
+

Click below to start the Ollama server.

+ +
+ ) + } + + // ── Starting ── + if (phase === 'starting') { + return ( +
+
+
+ Starting Ollama… +
+
+ ) + } + + // ── Error ── + if (phase === 'error') { + return ( +
+
+ + Something went wrong +
+ {error &&

{error}

} + +
+ ) + } + + // ── Running — show URL field + test button ── + const connected = phase === 'connected' && testResult?.success + + return ( +
+
+ {connected + ? + : } + + {connected ? 'Connected to Ollama' : 'Ollama is running'} + +
+ + {connected && testResult?.message && ( +

{testResult.message}

+ )} + + {!connected && ( + <> + +
+ setUrl(e.target.value)} + placeholder="http://localhost:11434" + disabled={isWorking} + /> + +
+ {testResult && !testResult.success && ( +

{testResult.error}

+ )} + + )} +
+ ) +} + +// ── Main onboarding page ────────────────────────────────────────────────────── + export function OnboardingPage() { const { connected, @@ -59,11 +233,15 @@ export function OnboardingPage() { submitOnboardingStep, skipOnboardingStep, goBackOnboardingStep, + localLLM, } = useWebSocket() // Local form state const [selectedValue, setSelectedValue] = useState('') const [textValue, setTextValue] = useState('') + // URL submitted from OllamaSetup + const [ollamaUrl, setOllamaUrl] = useState('http://localhost:11434') + const [ollamaConnected, setOllamaConnected] = useState(false) // Request first step when connected useEffect(() => { @@ -75,86 +253,81 @@ export function OnboardingPage() { // Reset local state when step changes useEffect(() => { if (onboardingStep) { - // For multi-select steps, use array + setOllamaConnected(false) + if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { setSelectedValue(Array.isArray(onboardingStep.default) ? onboardingStep.default : []) } else if (onboardingStep.options.length > 0) { - // Single select - find default option const defaultOption = onboardingStep.options.find(opt => opt.default) setSelectedValue(defaultOption?.value || onboardingStep.options[0]?.value || '') } else { - // Text input setSelectedValue('') setTextValue(typeof onboardingStep.default === 'string' ? onboardingStep.default : '') } } }, [onboardingStep]) + // Keep ollamaUrl in sync with step default + useEffect(() => { + if (onboardingStep?.name === 'api_key' && onboardingStep.provider === 'remote') { + const def = typeof onboardingStep.default === 'string' ? onboardingStep.default : 'http://localhost:11434' + setOllamaUrl(def) + } + }, [onboardingStep]) + + const handleOllamaConnected = useCallback((url: string) => { + setOllamaUrl(url) + setOllamaConnected(true) + }, []) + const handleOptionSelect = useCallback((value: string) => { if (!onboardingStep) return - if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { - // Multi-select toggle setSelectedValue(prev => { const arr = Array.isArray(prev) ? prev : [] - if (arr.includes(value)) { - return arr.filter(v => v !== value) - } else { - return [...arr, value] - } + return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value] }) } else { - // Single select setSelectedValue(value) } }, [onboardingStep]) const handleSubmit = useCallback(() => { if (!onboardingStep) return + const isOllamaStep = onboardingStep.name === 'api_key' && onboardingStep.provider === 'remote' - if (onboardingStep.options.length > 0) { - // Option-based step + if (isOllamaStep) { + submitOnboardingStep(ollamaUrl) + } else if (onboardingStep.options.length > 0) { submitOnboardingStep(selectedValue) } else { - // Text input step submitOnboardingStep(textValue) } - }, [onboardingStep, selectedValue, textValue, submitOnboardingStep]) - - const handleSkip = useCallback(() => { - skipOnboardingStep() - }, [skipOnboardingStep]) + }, [onboardingStep, selectedValue, textValue, ollamaUrl, submitOnboardingStep]) - const handleBack = useCallback(() => { - goBackOnboardingStep() - }, [goBackOnboardingStep]) + const handleSkip = useCallback(() => skipOnboardingStep(), [skipOnboardingStep]) + const handleBack = useCallback(() => goBackOnboardingStep(), [goBackOnboardingStep]) const isMultiSelect = onboardingStep?.name === 'mcp' || onboardingStep?.name === 'skills' - const isWideStep = isMultiSelect // MCP and Skills need wider container + const isWideStep = isMultiSelect const isLastStep = onboardingStep ? onboardingStep.index === onboardingStep.total - 1 : false - // Check if submit is valid + const isOllamaStep = + onboardingStep?.name === 'api_key' && onboardingStep?.provider === 'remote' + const canSubmit = (() => { if (!onboardingStep) return false if (onboardingLoading) return false - + if (isOllamaStep) { + return ollamaConnected || (localLLM.phase === 'connected' && !!localLLM.testResult?.success) + } if (onboardingStep.options.length > 0) { - if (isMultiSelect) { - // Multi-select: can always submit (empty array is valid) - return true - } - // Single select: must have selection - return !!selectedValue - } else { - // Text input: required steps need non-empty value - if (onboardingStep.required) { - return textValue.trim().length > 0 - } - return true + return isMultiSelect ? true : !!selectedValue } + return onboardingStep.required ? textValue.trim().length > 0 : true })() - // Render loading state + // Loading if (!connected || (!onboardingStep && onboardingLoading)) { return (
@@ -170,11 +343,23 @@ export function OnboardingPage() { ) } - // Render step content + // ── Render step form ────────────────────────────────────────────────────── const renderStepForm = () => { if (!onboardingStep) return null - // Option-based step (single or multi select) + // Ollama local setup + if (isOllamaStep) { + return ( +
+ +
+ ) + } + + // Option-based step if (onboardingStep.options.length > 0) { return (
@@ -219,26 +404,19 @@ export function OnboardingPage() { // Text input step const isApiKey = onboardingStep.name === 'api_key' - return (
setTextValue(e.target.value)} + onChange={e => setTextValue(e.target.value)} placeholder={isApiKey ? 'Enter your API key' : 'Enter a name'} autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && canSubmit) { - handleSubmit() - } - }} + onKeyDown={e => { if (e.key === 'Enter' && canSubmit) handleSubmit() }} /> {isApiKey && ( -
- Your API key is stored locally. -
+
Your API key is stored locally.
)}
) @@ -256,14 +434,10 @@ export function OnboardingPage() { return (
-
+
{isCompleted ? : index + 1}
- - {name} - + {name}
{index < STEP_NAMES.length - 1 && (
@@ -301,24 +475,14 @@ export function OnboardingPage() {
{onboardingStep.index > 0 && ( - )}
{!onboardingStep.required && ( - )} @@ -331,10 +495,8 @@ export function OnboardingPage() { iconPosition="right" > {onboardingLoading && onboardingStep?.name === 'api_key' - ? 'Testing API Key...' - : isLastStep - ? 'Finish' - : 'Next'} + ? (isOllamaStep ? 'Connecting…' : 'Testing API Key…') + : isLastStep ? 'Finish' : 'Next'}
diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index 57cbeb9c..80d3a414 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -100,6 +100,12 @@ export type WSMessageType = | 'onboarding_skip' | 'onboarding_back' | 'onboarding_complete' + // Local LLM (Ollama) + | 'local_llm_check' + | 'local_llm_test' + | 'local_llm_install' + | 'local_llm_install_progress' + | 'local_llm_start' export interface WSMessage { type: WSMessageType @@ -521,6 +527,57 @@ export interface OnboardingStep { total: number options: OnboardingStepOption[] default: string | string[] | null + provider?: string | null // only present on the api_key step +} + +// ───────────────────────────────────────────────────────────────────── +// Local LLM (Ollama) Types +// ───────────────────────────────────────────────────────────────────── + +export type LocalLLMPhase = + | 'idle' + | 'checking' + | 'not_installed' + | 'not_running' + | 'running' + | 'installing' + | 'starting' + | 'connected' + | 'error' + +export interface LocalLLMState { + phase: LocalLLMPhase + version?: string + defaultUrl: string + installProgress: string[] + testResult?: { success: boolean; message?: string; error?: string; models?: string[] } + error?: string +} + +export interface LocalLLMCheckResponse { + success: boolean + installed: boolean + running: boolean + version?: string + default_url: string + error?: string +} + +export interface LocalLLMTestResponse { + success: boolean + message?: string + models?: string[] + error?: string +} + +export interface LocalLLMInstallResponse { + success: boolean + message?: string + error?: string +} + +export interface LocalLLMProgressResponse { + message: string } export interface OnboardingStepResponse { diff --git a/app/ui_layer/local_llm_setup.py b/app/ui_layer/local_llm_setup.py new file mode 100644 index 00000000..1cfbda52 --- /dev/null +++ b/app/ui_layer/local_llm_setup.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""Local LLM setup utilities for Ollama.""" + +import asyncio +import json +import logging +import platform +import socket +import subprocess +import urllib.error +import urllib.request +from typing import Any, Callable, Dict + +logger = logging.getLogger(__name__) + +OLLAMA_DEFAULT_URL = "http://localhost:11434" + + +def check_port_open(host: str, port: int, timeout: float = 2.0) -> bool: + """Check if a TCP port is open.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except (socket.timeout, ConnectionRefusedError, OSError): + return False + + +def get_ollama_status() -> Dict[str, Any]: + """Return Ollama installation and runtime status.""" + installed = False + version = None + + try: + result = subprocess.run( + ["ollama", "--version"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + installed = True + version = result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + running = check_port_open("localhost", 11434) + + return { + "installed": installed, + "running": running, + "version": version, + "default_url": OLLAMA_DEFAULT_URL, + } + + +def test_ollama_connection_sync(url: str) -> Dict[str, Any]: + """Test an HTTP connection to an Ollama instance (synchronous).""" + try: + tags_url = url.rstrip("/") + "/api/tags" + with urllib.request.urlopen(tags_url, timeout=5) as resp: + data = json.loads(resp.read()) + models = [m["name"] for m in data.get("models", [])] + if models: + msg = f"Connected! {len(models)} model(s) available." + else: + msg = "Connected! No models downloaded yet — run 'ollama pull llama3' to get one." + return {"success": True, "models": models, "message": msg} + except urllib.error.URLError as exc: + return {"success": False, "error": f"Cannot connect: {exc.reason}"} + except Exception as exc: + return {"success": False, "error": str(exc)} + + +async def install_ollama(progress_callback: Callable) -> Dict[str, Any]: + """Install Ollama for the current platform, streaming progress via callback.""" + system = platform.system() + + try: + if system == "Windows": + # Try winget first + await progress_callback("Checking for winget...") + try: + proc = await asyncio.create_subprocess_exec( + "winget", "install", "--id", "Ollama.Ollama", + "--accept-package-agreements", "--accept-source-agreements", + "--silent", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await progress_callback("Installing Ollama via winget (this may take a minute)...") + _, stderr = await proc.communicate() + if proc.returncode == 0: + await progress_callback("Ollama installed successfully!") + return {"success": True, "message": "Ollama installed via winget"} + await progress_callback("winget install failed, switching to direct download...") + except FileNotFoundError: + await progress_callback("winget not found — downloading installer directly...") + + # Direct download fallback + import os + tmp = os.environ.get("TEMP", os.getcwd()) + installer_path = os.path.join(tmp, "OllamaSetup.exe") + installer_url = "https://ollama.com/download/OllamaSetup.exe" + + await progress_callback("Downloading Ollama installer from ollama.com...") + dl_proc = await asyncio.create_subprocess_exec( + "powershell", "-Command", + f"Invoke-WebRequest -Uri '{installer_url}' -OutFile '{installer_path}' -UseBasicParsing", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await dl_proc.communicate() + if dl_proc.returncode != 0: + err = stderr.decode(errors="replace")[:300] + return {"success": False, "error": f"Download failed: {err}"} + + await progress_callback("Running installer silently...") + run_proc = await asyncio.create_subprocess_exec( + installer_path, "/S", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await run_proc.communicate() + await progress_callback("Installation complete!") + return {"success": True, "message": "Ollama installed"} + + elif system in ("Darwin", "Linux"): + await progress_callback("Downloading Ollama install script...") + proc = await asyncio.create_subprocess_exec( + "sh", "-c", "curl -fsSL https://ollama.com/install.sh | sh", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + # Stream stdout lines as progress + while True: + line = await proc.stdout.readline() + if not line: + break + text = line.decode(errors="replace").strip() + if text: + await progress_callback(text[:120]) + + await proc.wait() + if proc.returncode == 0: + await progress_callback("Ollama installed successfully!") + return {"success": True} + else: + return {"success": False, "error": "Install script exited with an error"} + + else: + return {"success": False, "error": f"Unsupported platform: {system}"} + + except Exception as exc: + logger.exception("Error installing Ollama") + return {"success": False, "error": str(exc)} + + +async def start_ollama() -> Dict[str, Any]: + """Start the Ollama server in the background and wait for it to become ready.""" + try: + kwargs: Dict[str, Any] = { + "stdout": asyncio.subprocess.DEVNULL, + "stderr": asyncio.subprocess.DEVNULL, + } + if platform.system() == "Windows": + kwargs["creationflags"] = 0x00000008 # DETACHED_PROCESS + + await asyncio.create_subprocess_exec("ollama", "serve", **kwargs) + + # Poll until ready (max 15 seconds) + for _ in range(15): + await asyncio.sleep(1) + if check_port_open("localhost", 11434): + return {"success": True, "message": "Ollama started successfully"} + + return {"success": False, "error": "Ollama started but not responding on port 11434"} + + except FileNotFoundError: + return {"success": False, "error": "Ollama executable not found — is it installed?"} + except Exception as exc: + return {"success": False, "error": str(exc)} diff --git a/app/ui_layer/onboarding/controller.py b/app/ui_layer/onboarding/controller.py index 4c348bbc..59f3ff8f 100644 --- a/app/ui_layer/onboarding/controller.py +++ b/app/ui_layer/onboarding/controller.py @@ -248,9 +248,15 @@ def _complete(self) -> None: selected_skills = self._state.collected_data.get("skills", []) # Save provider configuration to settings.json - if provider and api_key: + if provider == "remote": + # api_key holds the Ollama base URL for the remote provider + remote_url = api_key or "http://localhost:11434" + from app.tui.settings import save_remote_endpoint + save_remote_endpoint(remote_url) + elif provider and api_key: save_settings_to_json(provider, api_key) + if provider: # Reinitialize the LLM with the new provider settings if self._controller and self._controller.agent: try: From 12317ba77b16449609c1e3177c768c172c4c2322 Mon Sep 17 00:00:00 2001 From: Korivi Date: Wed, 25 Mar 2026 13:10:39 +0900 Subject: [PATCH 2/5] Added the MiniMax, Deepseek, Moonshot to UI Added the MiniMax, Deepseek, Moonshot to the UI --- agent_core/core/impl/llm/interface.py | 2 +- agent_core/core/impl/settings/manager.py | 5 +- agent_core/core/impl/vlm/interface.py | 2 +- agent_core/core/models/connection_tester.py | 37 +++++++++ agent_core/core/models/factory.py | 20 +++++ agent_core/core/models/model_registry.py | 15 ++++ agent_core/core/models/provider_config.py | 12 +++ app/config/settings.json | 7 +- app/ui_layer/adapters/browser_adapter.py | 19 +++++ .../src/pages/Settings/SettingsPage.tsx | 81 ++++++++++++++++--- app/ui_layer/settings/__init__.py | 2 + app/ui_layer/settings/model_settings.py | 46 +++++++++++ 12 files changed, 229 insertions(+), 19 deletions(-) diff --git a/agent_core/core/impl/llm/interface.py b/agent_core/core/impl/llm/interface.py index a4f2525f..460a3860 100644 --- a/agent_core/core/impl/llm/interface.py +++ b/agent_core/core/impl/llm/interface.py @@ -331,7 +331,7 @@ def _generate_response_sync( if log_response: logger.info(f"[LLM SEND] system={system_prompt} | user={user_prompt}") - if self.provider == "openai": + if self.provider in ("openai", "minimax", "deepseek", "moonshot"): response = self._generate_openai(system_prompt, user_prompt) elif self.provider == "remote": response = self._generate_ollama(system_prompt, user_prompt) diff --git a/agent_core/core/impl/settings/manager.py b/agent_core/core/impl/settings/manager.py index c184f985..a4774711 100644 --- a/agent_core/core/impl/settings/manager.py +++ b/agent_core/core/impl/settings/manager.py @@ -38,7 +38,10 @@ "openai": "", "anthropic": "", "google": "", - "byteplus": "" + "byteplus": "", + "minimax": "", + "deepseek": "", + "moonshot": "" }, "endpoints": { "remote_model_url": "", diff --git a/agent_core/core/impl/vlm/interface.py b/agent_core/core/impl/vlm/interface.py index 657da07f..e46d0ac3 100644 --- a/agent_core/core/impl/vlm/interface.py +++ b/agent_core/core/impl/vlm/interface.py @@ -227,7 +227,7 @@ def describe_image_bytes( if log_response: logger.info(f"[LLM SEND] system={system_prompt} | user={user_prompt}") - if self.provider == "openai": + if self.provider in ("openai", "minimax", "deepseek", "moonshot"): response = self._openai_describe_bytes(image_bytes, system_prompt, user_prompt) elif self.provider == "remote": response = self._ollama_describe_bytes(image_bytes, system_prompt, user_prompt) diff --git a/agent_core/core/models/connection_tester.py b/agent_core/core/models/connection_tester.py index 87d25fd5..a1846bc4 100644 --- a/agent_core/core/models/connection_tester.py +++ b/agent_core/core/models/connection_tester.py @@ -51,6 +51,9 @@ def test_provider_connection( elif provider == "remote": url = base_url or cfg.default_base_url return _test_remote(url, timeout) + elif provider in ("minimax", "deepseek", "moonshot"): + url = cfg.default_base_url + return _test_openai_compat(provider, api_key, url, timeout) else: return { "success": False, @@ -348,3 +351,37 @@ def _test_remote(base_url: Optional[str], timeout: float) -> Dict[str, Any]: "provider": "remote", "error": f"Could not connect to {url}: {str(e)}", } + + +def _test_openai_compat( + provider: str, api_key: Optional[str], base_url: str, timeout: float +) -> Dict[str, Any]: + """Test an OpenAI-compatible API (MiniMax, DeepSeek, Moonshot).""" + names = {"minimax": "MiniMax", "deepseek": "DeepSeek", "moonshot": "Moonshot"} + display = names.get(provider, provider) + + if not api_key: + return { + "success": False, + "message": f"API key is required for {display}", + "provider": provider, + "error": "Missing API key", + } + + try: + with httpx.Client(timeout=timeout) as client: + response = client.get( + f"{base_url.rstrip('/')}/models", + headers={"Authorization": f"Bearer {api_key}"}, + ) + + if response.status_code == 200: + return {"success": True, "message": f"Successfully connected to {display} API", "provider": provider} + elif response.status_code == 401: + return {"success": False, "message": "Invalid API key", "provider": provider, "error": "Authentication failed - check your API key"} + else: + return {"success": False, "message": f"API returned status {response.status_code}", "provider": provider, "error": response.text[:200] if response.text else "Unknown error"} + except httpx.TimeoutException: + return {"success": False, "message": "Connection timed out", "provider": provider, "error": "Request timed out - check your network connection"} + except httpx.RequestError as e: + return {"success": False, "message": "Network error", "provider": provider, "error": str(e)} diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index d83528bb..ee7bf931 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -38,6 +38,9 @@ def create( Returns: Dictionary with provider context including client instances """ + # OpenAI-compatible providers that use OpenAI client with a custom base_url + _OPENAI_COMPAT = {"minimax", "deepseek", "moonshot"} + if provider not in PROVIDER_CONFIG: raise ValueError(f"Unsupported provider: {provider}") @@ -144,4 +147,21 @@ def create( "initialized": True, } + if provider in _OPENAI_COMPAT: + if not api_key: + if deferred: + return empty_context + raise ValueError(f"API key required for {provider}") + + return { + "provider": provider, + "model": model, + "client": OpenAI(api_key=api_key, base_url=resolved_base_url), + "gemini_client": None, + "remote_url": None, + "byteplus": None, + "anthropic_client": None, + "initialized": True, + } + raise RuntimeError("Unreachable") diff --git a/agent_core/core/models/model_registry.py b/agent_core/core/models/model_registry.py index d9f2270a..16fd279a 100644 --- a/agent_core/core/models/model_registry.py +++ b/agent_core/core/models/model_registry.py @@ -29,4 +29,19 @@ InterfaceType.VLM: "llava-v1.6", InterfaceType.EMBEDDING: "nomic-embed-text", }, + "minimax": { + InterfaceType.LLM: "MiniMax-Text-01", + InterfaceType.VLM: None, + InterfaceType.EMBEDDING: None, + }, + "deepseek": { + InterfaceType.LLM: "deepseek-chat", + InterfaceType.VLM: "deepseek-chat", + InterfaceType.EMBEDDING: None, + }, + "moonshot": { + InterfaceType.LLM: "moonshot-v1-8k", + InterfaceType.VLM: None, + InterfaceType.EMBEDDING: None, + }, } diff --git a/agent_core/core/models/provider_config.py b/agent_core/core/models/provider_config.py index 3a13ff4e..bc6357f3 100644 --- a/agent_core/core/models/provider_config.py +++ b/agent_core/core/models/provider_config.py @@ -25,4 +25,16 @@ class ProviderConfig: base_url_env="REMOTE_MODEL_URL", default_base_url="http://localhost:11434", ), + "minimax": ProviderConfig( + api_key_env="MINIMAX_API_KEY", + default_base_url="https://api.minimax.chat/v1", + ), + "deepseek": ProviderConfig( + api_key_env="DEEPSEEK_API_KEY", + default_base_url="https://api.deepseek.com", + ), + "moonshot": ProviderConfig( + api_key_env="MOONSHOT_API_KEY", + default_base_url="https://api.moonshot.cn/v1", + ), } diff --git a/app/config/settings.json b/app/config/settings.json index 403e9084..026b2f90 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -9,8 +9,8 @@ "enabled": true }, "model": { - "llm_provider": "gemini", - "vlm_provider": "gemini", + "llm_provider": "remote", + "vlm_provider": "remote", "llm_model": null, "vlm_model": null }, @@ -24,7 +24,8 @@ "remote_model_url": "", "byteplus_base_url": "https://ark.ap-southeast.bytepluses.com/api/v3", "google_api_base": "", - "google_api_version": "" + "google_api_version": "", + "remote": "http://localhost:11434" }, "gui": { "enabled": true, diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 0e749c94..3dccc6db 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -53,6 +53,7 @@ update_model_settings, test_connection, validate_can_save, + get_ollama_models, # MCP settings list_mcp_servers, add_mcp_server_from_json, @@ -1076,6 +1077,10 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: elif msg_type == "model_validate_save": await self._handle_model_validate_save(data) + elif msg_type == "ollama_models_get": + base_url = data.get("baseUrl") + await self._handle_ollama_models_get(base_url) + # MCP settings operations elif msg_type == "mcp_list": await self._handle_mcp_list() @@ -2590,6 +2595,20 @@ async def _handle_model_validate_save(self, data: Dict[str, Any]) -> None: }, }) + async def _handle_ollama_models_get(self, base_url: Optional[str] = None) -> None: + """Fetch available models from Ollama and broadcast to frontend.""" + try: + if not base_url: + settings_data = get_model_settings() + base_url = settings_data.get("base_urls", {}).get("remote") + result = get_ollama_models(base_url=base_url) + await self._broadcast({"type": "ollama_models_get", "data": result}) + except Exception as e: + await self._broadcast({ + "type": "ollama_models_get", + "data": {"success": False, "models": [], "error": str(e)}, + }) + # ───────────────────────────────────────────────────────────────────── # MCP Settings Handlers # ───────────────────────────────────────────────────────────────────── diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx index 51ca70ca..07ebf99c 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx @@ -1860,6 +1860,10 @@ function ModelSettings() { const [testResult, setTestResult] = useState(null) const [testBeforeSave, setTestBeforeSave] = useState(false) + // Ollama model list state + const [ollamaModels, setOllamaModels] = useState([]) + const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false) + // Set up message handlers (runs once when connected) useEffect(() => { if (!isConnected) return @@ -1931,7 +1935,7 @@ function ModelSettings() { message: d.message, error: d.error, }) - + // If this test is before save and it was successful, proceed with save if (testBeforeSave && d.success) { setTestBeforeSave(false) @@ -1951,6 +1955,15 @@ function ModelSettings() { setTestBeforeSave(false) } }), + onMessage('ollama_models_get', (data: unknown) => { + const d = data as { success: boolean; models: string[]; error?: string } + setOllamaModelsLoading(false) + if (d.success && d.models && d.models.length > 0) { + setOllamaModels(d.models) + } else { + setOllamaModels([]) + } + }), ] return () => cleanups.forEach(cleanup => cleanup()) @@ -1964,6 +1977,13 @@ function ModelSettings() { send('model_settings_get') }, [isConnected, send]) + // Fetch Ollama models whenever the active provider is 'remote' + useEffect(() => { + if (!isConnected || provider !== 'remote') return + setOllamaModelsLoading(true) + send('ollama_models_get', { baseUrl: baseUrls['remote'] || undefined }) + }, [provider, isConnected]) + const currentProvider = providers.find(p => p.id === provider) const hasKey = apiKeys[provider]?.has_key || newApiKey.length > 0 const needsKey = currentProvider?.requires_api_key && !hasKey @@ -2056,22 +2076,57 @@ function ModelSettings() { <>
- { setNewLlmModel(e.target.value); setHasChanges(true) }} - placeholder={currentLlmModel || 'Enter LLM model name...'} - /> + {provider === 'remote' && ollamaModels.length > 0 ? ( + + ) : ( + { setNewLlmModel(e.target.value); setHasChanges(true) }} + placeholder={ + provider === 'remote' && ollamaModelsLoading + ? 'Loading models...' + : currentLlmModel || 'Enter LLM model name...' + } + /> + )}
{currentProvider.has_vlm && (
- { setNewVlmModel(e.target.value); setHasChanges(true) }} - placeholder={currentVlmModel || 'Enter VLM model name...'} - /> + {(() => { + const visionKeywords = ['llava', 'vision', 'moondream', 'bakllava'] + const visionModels = ollamaModels.filter(m => + visionKeywords.some(kw => m.toLowerCase().includes(kw)) + ) + const vlmOptions = provider === 'remote' && ollamaModels.length > 0 + ? (visionModels.length > 0 ? visionModels : ollamaModels) + : [] + return vlmOptions.length > 0 ? ( + + ) : ( + { setNewVlmModel(e.target.value); setHasChanges(true) }} + placeholder={ + provider === 'remote' && ollamaModelsLoading + ? 'Loading models...' + : currentVlmModel || 'Enter VLM model name...' + } + /> + ) + })()}
)} diff --git a/app/ui_layer/settings/__init__.py b/app/ui_layer/settings/__init__.py index e0299b79..e20e5fb5 100644 --- a/app/ui_layer/settings/__init__.py +++ b/app/ui_layer/settings/__init__.py @@ -102,6 +102,7 @@ update_model_settings, test_connection, validate_can_save, + get_ollama_models, ) __all__ = [ @@ -182,4 +183,5 @@ "update_model_settings", "test_connection", "validate_can_save", + "get_ollama_models", ] diff --git a/app/ui_layer/settings/model_settings.py b/app/ui_layer/settings/model_settings.py index e8514ee7..4efb5a71 100644 --- a/app/ui_layer/settings/model_settings.py +++ b/app/ui_layer/settings/model_settings.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import Dict, Any, Optional, List +import httpx + from app.config import SETTINGS_CONFIG_PATH from app.models import ( PROVIDER_CONFIG, @@ -49,6 +51,24 @@ "settings_key": "byteplus", "requires_api_key": True, }, + "minimax": { + "name": "MiniMax", + "api_key_env": "MINIMAX_API_KEY", + "settings_key": "minimax", + "requires_api_key": True, + }, + "deepseek": { + "name": "DeepSeek", + "api_key_env": "DEEPSEEK_API_KEY", + "settings_key": "deepseek", + "requires_api_key": True, + }, + "moonshot": { + "name": "Moonshot", + "api_key_env": "MOONSHOT_API_KEY", + "settings_key": "moonshot", + "requires_api_key": True, + }, "remote": { "name": "Local (Ollama)", "base_url_env": "REMOTE_MODEL_URL", @@ -370,6 +390,32 @@ def test_connection( } +def get_ollama_models(base_url: Optional[str] = None) -> Dict[str, Any]: + """Fetch available models from a running Ollama instance. + + Args: + base_url: Optional Ollama base URL. Defaults to http://localhost:11434. + + Returns: + Dict with success, models (list of name strings), and optional error. + """ + url = base_url or "http://localhost:11434" + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(f"{url.rstrip('/')}/api/tags") + if response.status_code == 200: + models = [m["name"] for m in response.json().get("models", [])] + return {"success": True, "models": models} + else: + return { + "success": False, + "models": [], + "error": f"Ollama returned status {response.status_code}", + } + except Exception as e: + return {"success": False, "models": [], "error": str(e)} + + def validate_can_save( llm_provider: str, vlm_provider: Optional[str] = None, From 4f426b23f032358e5f4c45af45cf22c44b4d330c Mon Sep 17 00:00:00 2001 From: Korivi Date: Thu, 26 Mar 2026 14:04:41 +0900 Subject: [PATCH 3/5] Local LLM's Model handling improved I have made the following updates : 1. UI fixes on install/test stage: it now attempts automation if something fails during installation. 2. Model handling improved: automatic model selection, download, and status tracking are now implemented. No need for the Ollama chat box; the CraftBot UI will handle model selection and installation automatically. 3. Added support for 30+ models and User Guidance. --- app/ui_layer/adapters/browser_adapter.py | 42 +++++ .../src/contexts/WebSocketContext.tsx | 168 ++++++++++++++---- .../Onboarding/OnboardingPage.module.css | 119 +++++++++++++ .../src/pages/Onboarding/OnboardingPage.tsx | 116 +++++++++++- .../browser/frontend/src/types/index.ts | 22 +++ app/ui_layer/local_llm_setup.py | 145 ++++++++++++++- install.py | 2 - 7 files changed, 573 insertions(+), 41 deletions(-) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 3dccc6db..dfc584b6 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1219,6 +1219,11 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: await self._handle_local_llm_install() elif msg_type == "local_llm_start": await self._handle_local_llm_start() + elif msg_type == "local_llm_suggested_models": + await self._handle_local_llm_suggested_models() + elif msg_type == "local_llm_pull_model": + model = data.get("model", "") + await self._handle_local_llm_pull_model(model) async def _handle_dashboard_metrics_filter(self, period: str) -> None: """Handle filtered metrics request for specific time period.""" @@ -1635,6 +1640,43 @@ async def _handle_local_llm_start(self) -> None: "data": {"success": False, "error": str(e)}, }) + async def _handle_local_llm_suggested_models(self) -> None: + """Return the list of suggested Ollama models.""" + from app.ui_layer.local_llm_setup import SUGGESTED_MODELS + await self._broadcast({ + "type": "local_llm_suggested_models", + "data": {"models": SUGGESTED_MODELS}, + }) + + async def _handle_local_llm_pull_model(self, model: str) -> None: + """Pull an Ollama model, streaming progress back to the client.""" + if not model: + await self._broadcast({ + "type": "local_llm_pull_model", + "data": {"success": False, "error": "No model specified"}, + }) + return + + async def progress_callback(data: dict) -> None: + await self._broadcast({ + "type": "local_llm_pull_progress", + "data": data, + }) + + try: + from app.ui_layer.local_llm_setup import pull_ollama_model + result = await pull_ollama_model(model, progress_callback) + await self._broadcast({ + "type": "local_llm_pull_model", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error pulling model {model}: {e}") + await self._broadcast({ + "type": "local_llm_pull_model", + "data": {"success": False, "error": str(e)}, + }) + async def _handle_task_cancel(self, task_id: str) -> None: """Cancel a running task.""" try: diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 53576017..2c9e5796 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallback, ReactNode } from 'react' -import type { ChatMessage, ActionItem, AgentStatus, InitialState, WSMessage, DashboardMetrics, TaskCancelResponse, FilteredDashboardMetrics, MetricsTimePeriod, OnboardingStep, OnboardingStepResponse, OnboardingSubmitResponse, OnboardingCompleteResponse, LocalLLMState, LocalLLMCheckResponse, LocalLLMTestResponse, LocalLLMInstallResponse, LocalLLMProgressResponse } from '../types' +import type { ChatMessage, ActionItem, AgentStatus, InitialState, WSMessage, DashboardMetrics, TaskCancelResponse, FilteredDashboardMetrics, MetricsTimePeriod, OnboardingStep, OnboardingStepResponse, OnboardingSubmitResponse, OnboardingCompleteResponse, LocalLLMState, LocalLLMCheckResponse, LocalLLMTestResponse, LocalLLMInstallResponse, LocalLLMProgressResponse, LocalLLMPullProgressResponse, SuggestedModel } from '../types' import { getWsUrl } from '../utils/connection' // Pending attachment type for upload @@ -49,6 +49,8 @@ interface WebSocketContextType extends WebSocketState { testLocalLLMConnection: (url: string) => void installLocalLLM: () => void startLocalLLM: () => void + requestSuggestedModels: () => void + pullOllamaModel: (model: string) => void } const defaultState: WebSocketState = { @@ -83,6 +85,9 @@ const defaultState: WebSocketState = { phase: 'idle', defaultUrl: 'http://localhost:11434', installProgress: [], + pullProgress: [], + pullBytes: null, + suggestedModels: [], }, } @@ -445,8 +450,13 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { // ── Local LLM (Ollama) ─────────────────────────────────────────────── case 'local_llm_check': { const r = msg.data as unknown as LocalLLMCheckResponse + // Phases that must not be overridden by a background check result + const BUSY_PHASES: LocalLLMState['phase'][] = ['installing', 'starting', 'pulling_model'] if (!r.success) { - setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'error', error: r.error } })) + setState(prev => { + if (BUSY_PHASES.includes(prev.localLLM.phase)) return prev + return { ...prev, localLLM: { ...prev.localLLM, phase: 'error', error: r.error } } + }) break } let phase: LocalLLMState['phase'] @@ -457,30 +467,46 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } else { phase = 'not_installed' } - setState(prev => ({ - ...prev, - localLLM: { - ...prev.localLLM, - phase, - version: r.version, - defaultUrl: r.default_url || 'http://localhost:11434', - error: undefined, - testResult: undefined, - }, - })) + setState(prev => { + if (BUSY_PHASES.includes(prev.localLLM.phase)) return prev + return { + ...prev, + localLLM: { + ...prev.localLLM, + phase, + version: r.version, + defaultUrl: r.default_url || 'http://localhost:11434', + error: undefined, + testResult: undefined, + }, + } + }) break } case 'local_llm_test': { const r = msg.data as unknown as LocalLLMTestResponse - setState(prev => ({ - ...prev, - localLLM: { - ...prev.localLLM, - phase: r.success ? 'connected' : prev.localLLM.phase, - testResult: { success: r.success, message: r.message, error: r.error, models: r.models }, - }, - })) + if (r.success && (!r.models || r.models.length === 0)) { + // Connected but no models — ask user to pick one + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: 'selecting_model', + testResult: { success: r.success, message: r.message, error: r.error, models: r.models }, + }, + })) + wsRef.current?.send(JSON.stringify({ type: 'local_llm_suggested_models' })) + } else { + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: r.success ? 'connected' : prev.localLLM.phase, + testResult: { success: r.success, message: r.message, error: r.error, models: r.models }, + }, + })) + } break } @@ -498,14 +524,17 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { case 'local_llm_install': { const r = msg.data as unknown as LocalLLMInstallResponse - setState(prev => ({ - ...prev, - localLLM: { - ...prev.localLLM, - phase: r.success ? 'not_running' : 'error', - error: r.success ? undefined : (r.error ?? 'Installation failed'), - }, - })) + if (r.success) { + // Trigger a status check instead of assuming 'not_running' — + // the installer may have auto-launched Ollama already + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'checking', installProgress: [] } })) + wsRef.current?.send(JSON.stringify({ type: 'local_llm_check' })) + } else { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'error', error: r.error ?? 'Installation failed' }, + })) + } break } @@ -522,6 +551,53 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { })) break } + + case 'local_llm_suggested_models': { + const r = msg.data as unknown as { models: SuggestedModel[] } + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, suggestedModels: r.models } })) + break + } + + case 'local_llm_pull_progress': { + const r = msg.data as unknown as LocalLLMPullProgressResponse + setState(prev => { + // Only append to the log for non-byte-progress status lines + const isDownloading = r.total > 0 + const newLog = isDownloading + ? prev.localLLM.pullProgress // don't spam log with repeated byte updates + : r.message && !prev.localLLM.pullProgress.includes(r.message) + ? [...prev.localLLM.pullProgress, r.message] + : prev.localLLM.pullProgress + return { + ...prev, + localLLM: { + ...prev.localLLM, + pullProgress: newLog, + pullBytes: isDownloading + ? { completed: r.completed, total: r.total, percent: r.percent } + : prev.localLLM.pullBytes, + }, + } + }) + break + } + + case 'local_llm_pull_model': { + const r = msg.data as unknown as LocalLLMInstallResponse & { model?: string } + if (r.success) { + // Re-test to refresh model count and advance to 'connected' + setState(prev => { + wsRef.current?.send(JSON.stringify({ type: 'local_llm_test', url: prev.localLLM.defaultUrl })) + return { ...prev, localLLM: { ...prev.localLLM, pullProgress: [], error: undefined } } + }) + } else { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'error', error: r.error ?? 'Model download failed' }, + })) + } + break + } } }, []) @@ -620,10 +696,13 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { // Local LLM (Ollama) methods const checkLocalLLM = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'checking', error: undefined } })) - wsRef.current.send(JSON.stringify({ type: 'local_llm_check' })) - } + if (wsRef.current?.readyState !== WebSocket.OPEN) return + const BUSY_PHASES: LocalLLMState['phase'][] = ['installing', 'starting', 'pulling_model'] + setState(prev => { + if (BUSY_PHASES.includes(prev.localLLM.phase)) return prev // Don't interrupt active ops + return { ...prev, localLLM: { ...prev.localLLM, phase: 'checking', error: undefined } } + }) + wsRef.current.send(JSON.stringify({ type: 'local_llm_check' })) }, []) const testLocalLLMConnection = useCallback((url: string) => { @@ -639,6 +718,11 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { localLLM: { ...prev.localLLM, phase: 'installing', installProgress: [], error: undefined }, })) wsRef.current.send(JSON.stringify({ type: 'local_llm_install' })) + } else { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'error', error: 'Not connected — please wait a moment and retry.' }, + })) } }, []) @@ -649,6 +733,22 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + const requestSuggestedModels = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'local_llm_suggested_models' })) + } + }, []) + + const pullOllamaModel = useCallback((model: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'pulling_model', pullProgress: [], pullBytes: null, error: undefined }, + })) + wsRef.current.send(JSON.stringify({ type: 'local_llm_pull_model', model })) + } + }, []) + return ( {children} diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css index 86cbc553..41ead2ac 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css @@ -672,6 +672,125 @@ flex-shrink: 0; } +.downloadProgressBar { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--bg-tertiary, rgba(255,255,255,0.1)); + overflow: hidden; + margin: 10px 0 6px; +} + +.downloadProgressFill { + height: 100%; + border-radius: 3px; + background: var(--color-primary); + transition: width 0.3s ease; +} + +.downloadProgressInfo { + display: flex; + justify-content: space-between; + font-size: var(--text-xs); + color: var(--text-secondary); + margin-bottom: 8px; +} + +.downloadStatus { + font-size: var(--text-xs); + color: var(--text-tertiary); + font-family: var(--font-mono, monospace); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.modelSearchInput { + width: 100%; + box-sizing: border-box; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + margin-bottom: 8px; + outline: none; +} + +.modelSearchInput::placeholder { + color: var(--text-tertiary); +} + +.modelSearchInput:focus { + border-color: var(--color-primary); +} + +.modelList { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 220px; + overflow-y: auto; + margin-bottom: 14px; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + background: var(--bg-secondary); +} + +.modelOption { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: var(--text-sm); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 0; + transition: background 0.1s; +} + +.modelOption:first-child { + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.modelOption:last-child { + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +.modelOption:hover { + background: var(--bg-hover, rgba(255,255,255,0.06)); +} + +.modelOptionSelected { + background: var(--bg-hover, rgba(255,255,255,0.08)); +} + +.modelOption input[type="radio"] { + accent-color: var(--color-primary); + flex-shrink: 0; +} + +.modelOptionName { + flex: 1; +} + +.modelOptionSize { + font-size: var(--text-xs); + color: var(--text-tertiary); + white-space: nowrap; +} + +.modelOptionBadge { + font-size: var(--text-xs); + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 15%, transparent); + border-radius: var(--radius-sm); + padding: 1px 6px; + white-space: nowrap; +} + /* Touch device optimizations */ @media (hover: none) and (pointer: coarse) { .optionItem { diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index ab64fd28..5cb90a27 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -62,14 +62,24 @@ interface OllamaSetupProps { } function OllamaSetup({ defaultUrl, onConnected }: OllamaSetupProps) { - const { localLLM, checkLocalLLM, testLocalLLMConnection, installLocalLLM, startLocalLLM } = useWebSocket() + const { localLLM, checkLocalLLM, testLocalLLMConnection, installLocalLLM, startLocalLLM, pullOllamaModel } = useWebSocket() const [url, setUrl] = useState(defaultUrl) + const [selectedModel, setSelectedModel] = useState('llama3.2:3b') + const [modelSearch, setModelSearch] = useState('') // Auto-check on mount useEffect(() => { checkLocalLLM() }, [checkLocalLLM]) + // Pre-select the recommended model when the list loads + useEffect(() => { + if (localLLM.suggestedModels.length > 0) { + const rec = localLLM.suggestedModels.find(m => m.recommended) + if (rec) setSelectedModel(rec.name) + } + }, [localLLM.suggestedModels]) + // Notify parent when connected useEffect(() => { if (localLLM.phase === 'connected' && localLLM.testResult?.success) { @@ -79,7 +89,7 @@ function OllamaSetup({ defaultUrl, onConnected }: OllamaSetupProps) { const { phase, installProgress, testResult, error } = localLLM - const isWorking = phase === 'checking' || phase === 'installing' || phase === 'starting' + const isWorking = phase === 'checking' || phase === 'installing' || phase === 'starting' || phase === 'pulling_model' // ── Checking ── if (phase === 'idle' || phase === 'checking') { @@ -173,6 +183,88 @@ function OllamaSetup({ defaultUrl, onConnected }: OllamaSetupProps) { ) } + // ── Select model ── + if (phase === 'selecting_model') { + const allModels = localLLM.suggestedModels.length > 0 ? localLLM.suggestedModels : [] + const filteredModels = allModels.filter(m => + m.name.toLowerCase().includes(modelSearch.toLowerCase()) || + m.label.toLowerCase().includes(modelSearch.toLowerCase()) + ) + return ( +
+
+ + Ollama is running — no models yet +
+

Select a model to download so you can start chatting:

+ setModelSearch(e.target.value)} + /> +
+ {filteredModels.map(m => ( + + ))} + {filteredModels.length === 0 && ( +

No models match "{modelSearch}"

+ )} +
+ +
+ ) + } + + // ── Pulling model ── + if (phase === 'pulling_model') { + const bytes = localLLM.pullBytes + const fmtBytes = (n: number) => { + if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB` + if (n >= 1048576) return `${(n / 1048576).toFixed(0)} MB` + return `${(n / 1024).toFixed(0)} KB` + } + const latestStatus = localLLM.pullProgress[localLLM.pullProgress.length - 1] ?? 'Starting download…' + return ( +
+
+
+ Downloading {selectedModel}… +
+ {bytes && bytes.total > 0 ? ( + <> +
+
+
+
+ {fmtBytes(bytes.completed)} / {fmtBytes(bytes.total)} + {bytes.percent}% +
+ + ) : ( +
+
+
+ )} +

{latestStatus}

+
+ ) + } + // ── Running — show URL field + test button ── const connected = phase === 'connected' && testResult?.success @@ -458,7 +550,25 @@ export function OnboardingPage() { Optional )} -

{onboardingStep.description}

+

+ {isOllamaStep ? (() => { + switch (localLLM.phase) { + case 'not_installed': return "Ollama isn't installed yet — we'll download and install it automatically." + case 'installing': return "Installing Ollama on your machine. This may take a minute…" + case 'not_running': return "Ollama is installed but not running. Click below to start the server." + case 'starting': return "Starting the Ollama server…" + case 'running': return "Ollama is running. Enter the server URL and test the connection." + case 'selecting_model': return "Ollama is connected but has no models yet. Pick one to download." + case 'pulling_model': return "Downloading your model — this may take a few minutes depending on size." + case 'connected': { + const n = localLLM.testResult?.models?.length ?? 0 + return `Connected to Ollama — ${n} model${n === 1 ? '' : 's'} available.` + } + case 'error': return localLLM.error ?? "Something went wrong. Check the error below and retry." + default: return "Checking Ollama status…" + } + })() : onboardingStep.description} +

{/* Error Message */} {onboardingError && ( diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index 80d3a414..bd60fc43 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -106,6 +106,9 @@ export type WSMessageType = | 'local_llm_install' | 'local_llm_install_progress' | 'local_llm_start' + | 'local_llm_suggested_models' + | 'local_llm_pull_model' + | 'local_llm_pull_progress' export interface WSMessage { type: WSMessageType @@ -544,12 +547,24 @@ export type LocalLLMPhase = | 'starting' | 'connected' | 'error' + | 'selecting_model' + | 'pulling_model' + +export interface SuggestedModel { + name: string + label: string + size: string + recommended: boolean +} export interface LocalLLMState { phase: LocalLLMPhase version?: string defaultUrl: string installProgress: string[] + pullProgress: string[] + pullBytes: { completed: number; total: number; percent: number } | null + suggestedModels: SuggestedModel[] testResult?: { success: boolean; message?: string; error?: string; models?: string[] } error?: string } @@ -580,6 +595,13 @@ export interface LocalLLMProgressResponse { message: string } +export interface LocalLLMPullProgressResponse { + message: string + total: number + completed: number + percent: number +} + export interface OnboardingStepResponse { success: boolean completed?: boolean diff --git a/app/ui_layer/local_llm_setup.py b/app/ui_layer/local_llm_setup.py index 1cfbda52..43cf298d 100644 --- a/app/ui_layer/local_llm_setup.py +++ b/app/ui_layer/local_llm_setup.py @@ -15,6 +15,53 @@ OLLAMA_DEFAULT_URL = "http://localhost:11434" +SUGGESTED_MODELS = [ + # ── Llama ────────────────────────────────────────────────────────────── + {"name": "llama3.2:1b", "label": "Llama 3.2 1B", "size": "~1 GB", "recommended": False}, + {"name": "llama3.2:3b", "label": "Llama 3.2 3B", "size": "~2 GB", "recommended": True}, + {"name": "llama3.1:8b", "label": "Llama 3.1 8B", "size": "~5 GB", "recommended": False}, + # ── Phi ──────────────────────────────────────────────────────────────── + {"name": "phi4-mini", "label": "Phi-4 Mini", "size": "~2.5 GB", "recommended": False}, + {"name": "phi4", "label": "Phi-4", "size": "~9 GB", "recommended": False}, + # ── Gemma ────────────────────────────────────────────────────────────── + {"name": "gemma3:1b", "label": "Gemma 3 1B", "size": "~1 GB", "recommended": False}, + {"name": "gemma3:4b", "label": "Gemma 3 4B", "size": "~3 GB", "recommended": False}, + {"name": "gemma3:12b", "label": "Gemma 3 12B", "size": "~8 GB", "recommended": False}, + {"name": "gemma3:27b", "label": "Gemma 3 27B", "size": "~17 GB", "recommended": False}, + # ── Qwen ─────────────────────────────────────────────────────────────── + {"name": "qwen3:0.6b", "label": "Qwen 3 0.6B", "size": "~0.5 GB", "recommended": False}, + {"name": "qwen3:1.7b", "label": "Qwen 3 1.7B", "size": "~1 GB", "recommended": False}, + {"name": "qwen3:4b", "label": "Qwen 3 4B", "size": "~3 GB", "recommended": False}, + {"name": "qwen3:8b", "label": "Qwen 3 8B", "size": "~5 GB", "recommended": False}, + {"name": "qwen3:14b", "label": "Qwen 3 14B", "size": "~9 GB", "recommended": False}, + {"name": "qwen3:30b", "label": "Qwen 3 30B", "size": "~18 GB", "recommended": False}, + {"name": "qwen3-coder:4b", "label": "Qwen 3 Coder 4B", "size": "~3 GB", "recommended": False}, + {"name": "qwen3-coder:8b", "label": "Qwen 3 Coder 8B", "size": "~5 GB", "recommended": False}, + # ── Mistral ──────────────────────────────────────────────────────────── + {"name": "mistral:7b", "label": "Mistral 7B", "size": "~4 GB", "recommended": False}, + {"name": "mistral-nemo", "label": "Mistral Nemo 12B", "size": "~7 GB", "recommended": False}, + # ── DeepSeek ─────────────────────────────────────────────────────────── + {"name": "deepseek-r1:1.5b", "label": "DeepSeek R1 1.5B", "size": "~1 GB", "recommended": False}, + {"name": "deepseek-r1:7b", "label": "DeepSeek R1 7B", "size": "~4 GB", "recommended": False}, + {"name": "deepseek-r1:8b", "label": "DeepSeek R1 8B", "size": "~5 GB", "recommended": False}, + {"name": "deepseek-r1:14b", "label": "DeepSeek R1 14B", "size": "~9 GB", "recommended": False}, + {"name": "deepseek-r1:32b", "label": "DeepSeek R1 32B", "size": "~20 GB", "recommended": False}, + # ── Code models ──────────────────────────────────────────────────────── + {"name": "codellama:7b", "label": "Code Llama 7B", "size": "~4 GB", "recommended": False}, + {"name": "codellama:13b", "label": "Code Llama 13B", "size": "~8 GB", "recommended": False}, + {"name": "starcoder2:3b", "label": "StarCoder2 3B", "size": "~2 GB", "recommended": False}, + {"name": "starcoder2:7b", "label": "StarCoder2 7B", "size": "~4 GB", "recommended": False}, + # ── Multimodal ───────────────────────────────────────────────────────── + {"name": "llava:7b", "label": "LLaVA 7B (vision)", "size": "~4 GB", "recommended": False}, + {"name": "llava:13b", "label": "LLaVA 13B (vision)", "size": "~8 GB", "recommended": False}, + # ── Other ────────────────────────────────────────────────────────────── + {"name": "orca-mini:3b", "label": "Orca Mini 3B", "size": "~2 GB", "recommended": False}, + {"name": "vicuna:7b", "label": "Vicuna 7B", "size": "~4 GB", "recommended": False}, + {"name": "openchat:7b", "label": "OpenChat 7B", "size": "~4 GB", "recommended": False}, + {"name": "neural-chat:7b", "label": "Neural Chat 7B", "size": "~4 GB", "recommended": False}, + {"name": "dolphin-phi:2.7b", "label": "Dolphin Phi 2.7B", "size": "~2 GB", "recommended": False}, +] + def check_port_open(host: str, port: int, timeout: float = 2.0) -> bool: """Check if a TCP port is open.""" @@ -89,7 +136,12 @@ async def install_ollama(progress_callback: Callable) -> Dict[str, Any]: ) await progress_callback("Installing Ollama via winget (this may take a minute)...") _, stderr = await proc.communicate() - if proc.returncode == 0: + # Verify actual install regardless of exit code — winget can return non-zero on success + if get_ollama_status()["installed"]: + subprocess.run( + ["taskkill", "/F", "/IM", "ollama app.exe", "/T"], + capture_output=True, + ) await progress_callback("Ollama installed successfully!") return {"success": True, "message": "Ollama installed via winget"} await progress_callback("winget install failed, switching to direct download...") @@ -121,8 +173,14 @@ async def install_ollama(progress_callback: Callable) -> Dict[str, Any]: stderr=asyncio.subprocess.PIPE, ) await run_proc.communicate() - await progress_callback("Installation complete!") - return {"success": True, "message": "Ollama installed"} + if get_ollama_status()["installed"]: + subprocess.run( + ["taskkill", "/F", "/IM", "ollama app.exe", "/T"], + capture_output=True, + ) + await progress_callback("Ollama installed successfully!") + return {"success": True, "message": "Ollama installed"} + return {"success": False, "error": "Installer ran but Ollama was not detected"} elif system in ("Darwin", "Linux"): await progress_callback("Downloading Ollama install script...") @@ -179,3 +237,84 @@ async def start_ollama() -> Dict[str, Any]: return {"success": False, "error": "Ollama executable not found — is it installed?"} except Exception as exc: return {"success": False, "error": str(exc)} + + +async def pull_ollama_model(model: str, progress_callback: Callable) -> Dict[str, Any]: + """Pull an Ollama model via REST API, streaming structured progress via callback. + + Uses a background thread so the asyncio event loop stays unblocked and no + asyncio.timeout() issues occur (Python 3.11 + aiohttp compatibility). + + Each progress_callback call receives a dict with: + message – current status string + total – total bytes (0 if unknown) + completed – bytes downloaded so far + percent – 0-100 integer + """ + import queue + import threading + import urllib.request as _ureq + + pull_url = OLLAMA_DEFAULT_URL + "/api/pull" + payload = json.dumps({"name": model, "stream": True}).encode() + + line_queue: "queue.Queue[str | Exception | None]" = queue.Queue() + + def _pull_thread() -> None: + try: + req = _ureq.Request( + pull_url, + data=payload, + method="POST", + headers={"Content-Type": "application/json"}, + ) + with _ureq.urlopen(req) as resp: + for raw in resp: + line_queue.put(raw.decode(errors="replace").strip()) + line_queue.put(None) # sentinel — done + except Exception as exc: + line_queue.put(exc) + + thread = threading.Thread(target=_pull_thread, daemon=True) + thread.start() + + try: + while True: + try: + item = line_queue.get_nowait() + except queue.Empty: + await asyncio.sleep(0.05) + continue + + if item is None: + break + if isinstance(item, Exception): + return {"success": False, "error": str(item)} + + line = str(item).strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + + status = obj.get("status", "") + total = obj.get("total", 0) or 0 + completed = obj.get("completed", 0) or 0 + percent = int(completed / total * 100) if total > 0 else 0 + await progress_callback({ + "message": status, + "total": total, + "completed": completed, + "percent": percent, + }) + if status == "success": + break + + thread.join(timeout=5) + return {"success": True, "model": model} + + except Exception as exc: + logger.exception("Error pulling model %s", model) + return {"success": False, "error": str(exc)} diff --git a/install.py b/install.py index f225c5a0..87e235b8 100644 --- a/install.py +++ b/install.py @@ -1177,8 +1177,6 @@ def show_api_setup_instructions(): print(" Mode: Global pip") if install_gui: print(" GUI: Enabled (OmniParser)") - else: - print(" GUI: Disabled") print("="*60 + "\n") # Pre-flight check: Disk space (especially important for Kali) From 1ab3e653ec9d24d0de1e1aeba077e276c4e041bf Mon Sep 17 00:00:00 2001 From: Korivi Date: Thu, 26 Mar 2026 14:44:54 +0900 Subject: [PATCH 4/5] Added Upadetes to local LLM in Model Configuration UI For extra backup, I also added the option for users to choose models manually from the "Model Configuration" section if needed. This gives them more flexibility to select and use the right model based on their needs. --- .../pages/Settings/SettingsPage.module.css | 158 ++++++++++++++++++ .../src/pages/Settings/SettingsPage.tsx | 149 ++++++++++++++++- 2 files changed, 304 insertions(+), 3 deletions(-) diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css index 86c199bc..9c879a4c 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css @@ -1771,3 +1771,161 @@ max-height: 80vh; } } + +/* ── Ollama model download section ───────────────────────────────── */ +.ollamaDownloadSection { + margin-top: var(--space-2); +} + +.downloadModelBtn { + font-size: var(--text-sm); + color: var(--color-primary); + background: none; + border: 1px dashed var(--color-primary); + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-3); + cursor: pointer; + transition: background var(--transition-fast); +} +.downloadModelBtn:hover { + background: var(--color-primary-subtle); +} + +.pullModelPanel { + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.pullPanelHeader { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: var(--font-medium); + font-size: var(--text-sm); +} +.pullPanelHeader button { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + font-size: var(--text-base); + padding: 0 var(--space-1); +} +.pullPanelHeader button:hover { + color: var(--text-primary); +} + +.pullModelSearch { + width: 100%; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + font-size: var(--text-sm); + box-sizing: border-box; +} +.pullModelSearch:focus { + outline: none; + border-color: var(--color-primary); +} + +.pullModelList { + max-height: 220px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pullModelItem { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--text-sm); +} +.pullModelItem:hover { + background: var(--bg-tertiary); +} +.pullModelItemSelected { + background: var(--color-primary-subtle); +} + +.pullModelName { + flex: 1; +} +.pullModelSize { + color: var(--text-secondary); + font-size: var(--text-xs); + white-space: nowrap; +} +.pullModelBadge { + font-size: 10px; + background: var(--color-primary); + color: white; + border-radius: var(--radius-sm); + padding: 1px 6px; + white-space: nowrap; +} + +.pullPanelFooter { + display: flex; + justify-content: flex-end; +} +.pullStartBtn { + padding: var(--space-2) var(--space-4); + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--text-sm); + transition: opacity var(--transition-fast); +} +.pullStartBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pullProgressPanel { + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--bg-secondary); + font-size: var(--text-sm); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.pullProgressBar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; +} +.pullProgressFill { + height: 100%; + background: var(--color-primary); + border-radius: 3px; + transition: width 0.3s ease; +} +.pullProgressInfo { + display: flex; + justify-content: space-between; + font-size: var(--text-xs); + color: var(--text-secondary); +} +.pullStatusText { + color: var(--text-secondary); + font-size: var(--text-xs); + margin: 0; +} diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx index 07ebf99c..e3f0c184 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx @@ -1831,6 +1831,14 @@ interface TestResult { error?: string } +// Suggested Ollama model type +interface SuggestedModel { + name: string + label: string + size: string + recommended: boolean +} + function ModelSettings() { const { send, onMessage, isConnected } = useSettingsWebSocket() const { showToast } = useToast() @@ -1864,6 +1872,20 @@ function ModelSettings() { const [ollamaModels, setOllamaModels] = useState([]) const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false) + // Ollama model download state + const [pullPhase, setPullPhase] = useState<'idle' | 'selecting' | 'pulling'>('idle') + const [suggestedModels, setSuggestedModels] = useState([]) + const [selectedPullModel, setSelectedPullModel] = useState('') + const [modelSearch, setModelSearch] = useState('') + const [pullBytes, setPullBytes] = useState<{ completed: number; total: number; percent: number } | null>(null) + const [pullStatus, setPullStatus] = useState('') + + const fmtBytes = (n: number) => { + if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GB` + if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(0)} MB` + return `${(n / 1024).toFixed(0)} KB` + } + // Set up message handlers (runs once when connected) useEffect(() => { if (!isConnected) return @@ -1964,10 +1986,35 @@ function ModelSettings() { setOllamaModels([]) } }), + onMessage('local_llm_suggested_models', (data: unknown) => { + const d = data as { models: SuggestedModel[] } + setSuggestedModels(d.models || []) + const rec = d.models?.find(m => m.recommended) + if (rec) setSelectedPullModel(rec.name) + }), + onMessage('local_llm_pull_progress', (data: unknown) => { + const d = data as { message: string; total: number; completed: number; percent: number } + setPullStatus(d.message || '') + if (d.total > 0) setPullBytes({ completed: d.completed, total: d.total, percent: d.percent }) + }), + onMessage('local_llm_pull_model', (data: unknown) => { + const d = data as { success: boolean; model?: string; error?: string } + if (d.success) { + setPullPhase('idle') + setPullBytes(null) + setPullStatus('') + setOllamaModelsLoading(true) + send('ollama_models_get', { baseUrl: baseUrls['remote'] || undefined }) + showToast('success', `Model ${d.model} downloaded successfully`) + } else { + setPullPhase('idle') + showToast('error', d.error || 'Model download failed') + } + }), ] return () => cleanups.forEach(cleanup => cleanup()) - }, [isConnected, onMessage, send, testBeforeSave, provider, newApiKey, newBaseUrl]) + }, [isConnected, onMessage, send, testBeforeSave, provider, newApiKey, newBaseUrl, baseUrls]) // Load initial data only once when connected useEffect(() => { @@ -2047,6 +2094,22 @@ function ModelSettings() { } } + const handleDownloadModelClick = () => { + setPullPhase('selecting') + setPullBytes(null) + setPullStatus('') + setModelSearch('') + if (suggestedModels.length === 0) { + send('local_llm_suggested_models') + } + } + + const handleStartPull = () => { + if (!selectedPullModel) return + setPullPhase('pulling') + send('local_llm_pull_model', { model: selectedPullModel }) + } + return (
@@ -2096,6 +2159,86 @@ function ModelSettings() { /> )}
+ + {/* Download new Ollama model */} + {provider === 'remote' && ( +
+ {pullPhase === 'idle' && ( + + )} + + {pullPhase === 'selecting' && ( +
+
+ Select model to download + +
+ setModelSearch(e.target.value)} + /> +
+ {suggestedModels + .filter(m => + m.name.toLowerCase().includes(modelSearch.toLowerCase()) || + m.label.toLowerCase().includes(modelSearch.toLowerCase()) + ) + .map(m => ( + + ))} +
+
+ +
+
+ )} + + {pullPhase === 'pulling' && ( +
+ Downloading {selectedPullModel}… + {pullBytes && pullBytes.total > 0 ? ( + <> +
+
+
+
+ {fmtBytes(pullBytes.completed)} / {fmtBytes(pullBytes.total)} + {pullBytes.percent}% +
+ + ) : ( +
+
+
+ )} +

{pullStatus || 'Starting…'}

+
+ )} +
+ )} + {currentProvider.has_vlm && (
@@ -2173,8 +2316,8 @@ function ModelSettings() { + {saveStatus === 'success' && ( + + Settings saved + + )} + {saveStatus === 'error' && ( + + Save failed + + )} +
+ + {/* Reset Section */} +
+
+ +

Reset Agent

+
+

+ Reset the agent to its initial state. This will clear the current task, conversation history, + and restore the agent file system from templates. Saved settings and credentials are preserved. +

+ + {resetStatus === 'success' && ( + + Agent reset successfully + + )} + {resetStatus === 'error' && ( + + Reset failed + + )} +
+ + {/* Advanced Section */} +
+ + + {showAdvanced && ( +
+ {/* USER.md Editor */} +
+
+
+

USER.md

+ User Profile +
+

+ This file contains your personal information and preferences that help the agent + understand how to interact with you. Editing this file will change how the agent + addresses you and tailors its responses to your preferences. +

+
+
+ {isLoadingUserMd ? ( +
+ + Loading USER.md... +
+ ) : ( +