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/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 46d4ce0a..94cc2172 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() @@ -1222,6 +1227,23 @@ 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() + 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", "") + base_url = data.get("baseUrl") + await self._handle_local_llm_pull_model(model, base_url) + async def _handle_dashboard_metrics_filter(self, period: str) -> None: """Handle filtered metrics request for specific time period.""" try: @@ -1300,6 +1322,7 @@ async def _handle_onboarding_step_get(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1336,8 +1359,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, @@ -1402,6 +1442,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), }, }, }) @@ -1476,6 +1517,7 @@ async def _handle_onboarding_skip(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1531,6 +1573,7 @@ async def _handle_onboarding_back(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1544,6 +1587,124 @@ 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_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, base_url: str | None = None) -> 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 + + # Resolve base URL: explicit param > stored settings > default + if not base_url: + try: + from app.ui_layer.settings.model_settings import get_model_settings + settings_data = get_model_settings() + base_url = settings_data.get("base_urls", {}).get("remote") + except Exception: + pass + + 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, base_url=base_url) + 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: @@ -2504,6 +2665,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/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 37c64425..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 } 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 @@ -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,13 @@ 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 + requestSuggestedModels: () => void + pullOllamaModel: (model: string) => void } const defaultState: WebSocketState = { @@ -71,6 +80,15 @@ const defaultState: WebSocketState = { onboardingStep: null, onboardingError: null, onboardingLoading: false, + // Local LLM (Ollama) state + localLLM: { + phase: 'idle', + defaultUrl: 'http://localhost:11434', + installProgress: [], + pullProgress: [], + pullBytes: null, + suggestedModels: [], + }, } const WebSocketContext = createContext(undefined) @@ -428,6 +446,158 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } break } + + // ── 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 => { + if (BUSY_PHASES.includes(prev.localLLM.phase)) return prev + return { ...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 => { + 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 + 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 + } + + 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 + 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 + } + + 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 + } + + 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 + } } }, []) @@ -524,6 +694,61 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + // Local LLM (Ollama) methods + const checkLocalLLM = useCallback(() => { + 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) => { + 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' })) + } else { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'error', error: 'Not connected — please wait a moment and retry.' }, + })) + } + }, []) + + 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' })) + } + }, []) + + 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 5a4c81b8..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 @@ -541,6 +541,256 @@ } } +/* ───────────────────────────────────────────────────────────────────── + 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; +} + +.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 0ac87419..5cb90a27 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,267 @@ 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, 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) { + onConnected(url) + } + }, [localLLM.phase, localLLM.testResult, url, onConnected]) + + const { phase, installProgress, testResult, error } = localLLM + + const isWorking = phase === 'checking' || phase === 'installing' || phase === 'starting' || phase === 'pulling_model' + + // ── 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}

} + +
+ ) + } + + // ── 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 + + 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 +325,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 +345,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 +435,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 +496,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 +526,10 @@ export function OnboardingPage() { return (
-
+
{isCompleted ? : index + 1}
- - {name} - + {name}
{index < STEP_NAMES.length - 1 && (
@@ -284,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 && ( @@ -301,24 +585,14 @@ export function OnboardingPage() {
{onboardingStep.index > 0 && ( - )}
{!onboardingStep.required && ( - )} @@ -331,10 +605,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/pages/Settings/GeneralSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx index ad60ea00..ca399961 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx @@ -1,19 +1,44 @@ import { useState, useEffect, useRef } from 'react' import { + ChevronRight, RotateCcw, FileText, AlertTriangle, Check, X, Loader2, - ChevronRight, } from 'lucide-react' import { Button, Badge, ConfirmModal } from '../../components/ui' import { useTheme } from '../../contexts/ThemeContext' import { useConfirmModal } from '../../hooks' import styles from './SettingsPage.module.css' import { useSettingsWebSocket } from './useSettingsWebSocket' -import { applyTheme, getInitialTheme, getInitialAgentName } from './helpers' + +// Theme application helper +function applyTheme(theme: string) { + const root = document.documentElement + + if (theme === 'system') { + // Check system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + root.setAttribute('data-theme', prefersDark ? 'dark' : 'light') + } else { + root.setAttribute('data-theme', theme) + } + + // Persist to localStorage + localStorage.setItem('craftbot-theme', theme) +} + +// Get initial theme from localStorage or default +function getInitialTheme(): string { + return localStorage.getItem('craftbot-theme') || 'dark' +} + +// Get initial agent name from localStorage or default +function getInitialAgentName(): string { + return localStorage.getItem('craftbot-agent-name') || 'CraftBot' +} export function GeneralSettings() { const { send, onMessage, isConnected } = useSettingsWebSocket() @@ -128,14 +153,14 @@ export function GeneralSettings() { if (d.filename === 'USER.md') { setIsSavingUserMd(false) if (d.success) { - setOriginalUserMdContent(userMdContentRef.current) // Use ref for closure-safe access + setOriginalUserMdContent(userMdContentRef.current) } setUserMdSaveStatus(d.success ? 'success' : 'error') setTimeout(() => setUserMdSaveStatus('idle'), 3000) } else if (d.filename === 'AGENT.md') { setIsSavingAgentMd(false) if (d.success) { - setOriginalAgentMdContent(agentMdContentRef.current) // Use ref for closure-safe access + setOriginalAgentMdContent(agentMdContentRef.current) } setAgentMdSaveStatus(d.success ? 'success' : 'error') setTimeout(() => setAgentMdSaveStatus('idle'), 3000) @@ -147,7 +172,7 @@ export function GeneralSettings() { setIsRestoringUserMd(false) if (d.success) { setUserMdContent(d.content) - setOriginalUserMdContent(d.content) // Also update original + setOriginalUserMdContent(d.content) setUserMdSaveStatus('success') setTimeout(() => setUserMdSaveStatus('idle'), 3000) } @@ -155,7 +180,7 @@ export function GeneralSettings() { setIsRestoringAgentMd(false) if (d.success) { setAgentMdContent(d.content) - setOriginalAgentMdContent(d.content) // Also update original + setOriginalAgentMdContent(d.content) setAgentMdSaveStatus('success') setTimeout(() => setAgentMdSaveStatus('idle'), 3000) } diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx index 1b317899..dd27c4c5 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect } from 'react' import { Globe, - RotateCcw, + Package, AlertTriangle, - X, Loader2, Plus, - Package, + RotateCcw, + X, Power, Wrench, } from 'lucide-react' @@ -16,7 +16,7 @@ import { useConfirmModal } from '../../hooks' import styles from './SettingsPage.module.css' import { useSettingsWebSocket } from './useSettingsWebSocket' -// Integration types +// Types interface IntegrationField { key: string label: string @@ -88,23 +88,6 @@ const IntegrationIcon = ({ id, size = 20 }: { id: string; size?: number }) => { ), - jira: ( - - - - - - ), - github: ( - - - - ), - twitter: ( - - - - ), recall: ( @@ -151,18 +134,6 @@ export function IntegrationsSettings() { const [whatsappError, setWhatsappError] = useState(null) const whatsappPollRef = React.useRef | null>(null) - // Jira settings state - const [jiraWatchTag, setJiraWatchTag] = useState('') - const [jiraWatchLabels, setJiraWatchLabels] = useState('') - const [jiraSettingsLoaded, setJiraSettingsLoaded] = useState(false) - const [jiraSaving, setJiraSaving] = useState(false) - - // GitHub settings state - const [githubWatchTag, setGithubWatchTag] = useState('') - const [githubWatchRepos, setGithubWatchRepos] = useState('') - const [githubSettingsLoaded, setGithubSettingsLoaded] = useState(false) - const [githubSaving, setGithubSaving] = useState(false) - // Confirm modal const { modalProps: confirmModalProps, confirm } = useConfirmModal() @@ -238,76 +209,30 @@ export function IntegrationsSettings() { setWhatsappStatus('connected') setShowConnectModal(false) showToast('success', d.message || 'WhatsApp connected successfully') - // Stop polling if (whatsappPollRef.current) { clearInterval(whatsappPollRef.current) whatsappPollRef.current = null } - // Reset state setWhatsappQrCode(null) setWhatsappSessionId(null) setWhatsappStatus('idle') } else if (d.status === 'error' || d.status === 'disconnected') { setWhatsappStatus('error') setWhatsappError(d.message || 'Session failed') - // Stop polling if (whatsappPollRef.current) { clearInterval(whatsappPollRef.current) whatsappPollRef.current = null } } - // Otherwise still waiting for scan }), - onMessage('whatsapp_cancel_result', (data: unknown) => { - // Session cancelled, reset state + onMessage('whatsapp_cancel_result', (_data: unknown) => { setWhatsappQrCode(null) setWhatsappSessionId(null) setWhatsappStatus('idle') setWhatsappError(null) }), - // GitHub settings handlers - onMessage('github_settings', (data: unknown) => { - const d = data as { success: boolean; watch_tag?: string; watch_repos?: string[] } - if (d.success) { - setGithubWatchTag(d.watch_tag || '') - setGithubWatchRepos((d.watch_repos || []).join(', ')) - setGithubSettingsLoaded(true) - } - }), - onMessage('github_settings_result', (data: unknown) => { - const d = data as { success: boolean; watch_tag?: string; watch_repos?: string[]; message?: string; error?: string } - setGithubSaving(false) - if (d.success) { - setGithubWatchTag(d.watch_tag || '') - setGithubWatchRepos((d.watch_repos || []).join(', ')) - showToast('success', d.message || 'GitHub settings saved') - } else { - showToast('error', d.error || 'Failed to save GitHub settings') - } - }), - // Jira settings handlers - onMessage('jira_settings', (data: unknown) => { - const d = data as { success: boolean; watch_tag?: string; watch_labels?: string[] } - if (d.success) { - setJiraWatchTag(d.watch_tag || '') - setJiraWatchLabels((d.watch_labels || []).join(', ')) - setJiraSettingsLoaded(true) - } - }), - onMessage('jira_settings_result', (data: unknown) => { - const d = data as { success: boolean; watch_tag?: string; watch_labels?: string[]; message?: string; error?: string } - setJiraSaving(false) - if (d.success) { - setJiraWatchTag(d.watch_tag || '') - setJiraWatchLabels((d.watch_labels || []).join(', ')) - showToast('success', d.message || 'Jira settings saved') - } else { - showToast('error', d.error || 'Failed to save Jira settings') - } - }), ] - // Load initial data send('integration_list') return () => cleanups.forEach(c => c()) @@ -318,7 +243,6 @@ export function IntegrationsSettings() { if (whatsappStatus === 'qr_ready' && whatsappSessionId) { startWhatsAppPolling(whatsappSessionId) } - // Cleanup polling on unmount return () => { if (whatsappPollRef.current) { clearInterval(whatsappPollRef.current) @@ -340,7 +264,6 @@ export function IntegrationsSettings() { setConnectError('') setShowConnectModal(true) - // Auto-start WhatsApp QR flow when opening modal if (integration.auth_type === 'interactive' && integration.id === 'whatsapp') { handleStartWhatsAppQR() } @@ -352,33 +275,25 @@ export function IntegrationsSettings() { setWhatsappSessionId(null) setWhatsappError(null) send('whatsapp_start_qr') - - // Start polling for status after QR is ready - // We'll start the poll once we receive the QR code } const startWhatsAppPolling = (sessionId: string) => { - // Clear any existing poll if (whatsappPollRef.current) { clearInterval(whatsappPollRef.current) } - // Poll every 2 seconds whatsappPollRef.current = setInterval(() => { send('whatsapp_check_status', { session_id: sessionId }) }, 2000) } const handleCancelWhatsApp = () => { - // Stop polling if (whatsappPollRef.current) { clearInterval(whatsappPollRef.current) whatsappPollRef.current = null } - // Cancel session on backend if (whatsappSessionId) { send('whatsapp_cancel', { session_id: whatsappSessionId }) } - // Reset state setWhatsappQrCode(null) setWhatsappSessionId(null) setWhatsappStatus('idle') @@ -386,33 +301,7 @@ export function IntegrationsSettings() { setShowConnectModal(false) } - const handleSaveGithubSettings = () => { - setGithubSaving(true) - const reposArray = githubWatchRepos.split(',').map(r => r.trim()).filter(Boolean) - send('github_update_settings', { - watch_tag: githubWatchTag, - watch_repos: reposArray, - }) - } - - const handleSaveJiraSettings = () => { - setJiraSaving(true) - const labelsArray = jiraWatchLabels.split(',').map(l => l.trim()).filter(Boolean) - send('jira_update_settings', { - watch_tag: jiraWatchTag, - watch_labels: labelsArray, - }) - } - const handleOpenManage = (integration: Integration) => { - if (integration.id === 'jira') { - setJiraSettingsLoaded(false) - send('jira_get_settings') - } - if (integration.id === 'github') { - setGithubSettingsLoaded(false) - send('github_get_settings') - } send('integration_info', { id: integration.id }) } @@ -448,8 +337,12 @@ export function IntegrationsSettings() { }) } + const handleAddAnother = () => { + if (!managingIntegration) return + setShowManageModal(false) + handleOpenConnect(managingIntegration) + } - // Filter by search and sort alphabetically const filteredIntegrations = integrations .filter(integration => { if (!searchQuery) return true @@ -527,6 +420,9 @@ export function IntegrationsSettings() { {integration.connected ? 'Connected' : 'Not connected'} + {integration.connected && integration.accounts.length > 0 && ( + {integration.accounts.length} account{integration.accounts.length > 1 ? 's' : ''} + )}

{integration.description}

@@ -698,7 +594,6 @@ export function IntegrationsSettings() { {/* Token + Interactive QR integrations (Telegram) */} {selectedIntegration.auth_type === 'token_with_interactive' && (
- {/* Token fields (e.g. Bot Token) */} {selectedIntegration.fields.map(field => (
@@ -771,7 +666,7 @@ export function IntegrationsSettings() { WhatsApp QR Code

- Open WhatsApp → Settings → Linked Devices → Link a Device + Open WhatsApp → Settings → Linked Devices → Link a Device

)} @@ -839,121 +734,11 @@ export function IntegrationsSettings() { ))}
)} -
- - {/* Jira-specific settings */} - {managingIntegration.id === 'jira' && managingIntegration.connected && ( - <> -

Listener Settings

- {!jiraSettingsLoaded ? ( -
- - Loading settings... -
- ) : ( -
-
- - setJiraWatchTag(e.target.value)} - /> -

- Only trigger on comments containing this tag. Leave empty to trigger on all updates. -

-
-
- - setJiraWatchLabels(e.target.value)} - /> -

- Comma-separated Jira labels. Only issues with these labels will be watched. Leave empty for all issues. -

-
- -
- )} - - )} - - {/* GitHub-specific settings */} - {managingIntegration.id === 'github' && managingIntegration.connected && ( - <> -

Listener Settings

- {!githubSettingsLoaded ? ( -
- - Loading settings... -
- ) : ( -
-
- - setGithubWatchTag(e.target.value)} - /> -

- Only trigger on comments containing this tag. Leave empty to trigger on all notifications. -

-
-
- - setGithubWatchRepos(e.target.value)} - /> -

- Comma-separated repos (owner/repo). Only events from these repos will trigger. Leave empty for all. -

-
- -
- )} - - )} +
+ +
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/MCPSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/MCPSettings.tsx index 3c921f44..860c08f0 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/MCPSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/MCPSettings.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from 'react' import { - RotateCcw, - X, Loader2, Plus, Edit2, Trash2, + RotateCcw, + X, } from 'lucide-react' import { Button, Badge, ConfirmModal } from '../../components/ui' import { useToast } from '../../contexts/ToastContext' @@ -13,7 +13,7 @@ import { useConfirmModal } from '../../hooks' import styles from './SettingsPage.module.css' import { useSettingsWebSocket } from './useSettingsWebSocket' -// MCP Server config type +// Types interface MCPServerConfig { name: string description: string @@ -24,7 +24,6 @@ interface MCPServerConfig { env: Record } -// MCP item type for display interface MCPItem { name: string description: string @@ -32,7 +31,7 @@ interface MCPItem { transport?: string action_set?: string env?: Record - needsConfig?: boolean // has empty env vars + needsConfig?: boolean } export function MCPSettings() { @@ -91,7 +90,6 @@ export function MCPSettings() { const d = data as { success: boolean; message?: string; error?: string } if (d.success) { showToast('success', d.message || 'Server removed') - // Refresh list send('mcp_list') } else { showToast('error', d.error || 'Failed to remove server') @@ -105,7 +103,6 @@ export function MCPSettings() { setShowAddModal(false) setCustomJsonConfig('') setAddError('') - // Refresh list send('mcp_list') } else { setAddError(d.error || 'Failed to add server') @@ -123,7 +120,6 @@ export function MCPSettings() { if (d.success) { showToast('success', d.message || 'Configuration saved') setConfigServer(null) - // Refresh list send('mcp_list') } else { showToast('error', d.error || 'Failed to update configuration') @@ -131,13 +127,12 @@ export function MCPSettings() { }), ] - // Load initial data send('mcp_list') return () => cleanups.forEach(c => c()) }, [isConnected, send, onMessage]) - // Build MCP list from configured servers, filter and sort + // Build MCP list const mcpList: MCPItem[] = servers .filter(s => { if (!searchQuery) return true @@ -155,7 +150,6 @@ export function MCPSettings() { })) .sort((a, b) => a.name.localeCompare(b.name)) - // Stats const totalServers = servers.length const enabledServers = servers.filter(s => s.enabled).length @@ -163,7 +157,6 @@ export function MCPSettings() { const handleReloadServers = () => { setIsReloading(true) send('mcp_list') - // Reset after a short delay since mcp_list doesn't have a specific "reload" response setTimeout(() => { setIsReloading(false) showToast('success', 'MCP servers reloaded') @@ -176,7 +169,6 @@ export function MCPSettings() { } else { send('mcp_disable', { name }) } - // Optimistic update setServers(prev => prev.map(s => s.name === name ? { ...s, enabled } : s)) } @@ -188,7 +180,6 @@ export function MCPSettings() { variant: 'danger', }, () => { send('mcp_remove', { name }) - // Optimistic update setServers(prev => prev.filter(s => s.name !== name)) }) } @@ -203,7 +194,6 @@ export function MCPSettings() { if (!configServer) return setIsSavingEnv(true) - // Update each env var const envEntries = Object.entries(envValues) if (envEntries.length === 0) { setIsSavingEnv(false) diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx index c64e55d9..0c64c2c3 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect } from 'react' import { Brain, Database, - RotateCcw, AlertTriangle, - X, Loader2, Plus, Edit2, Trash2, + RotateCcw, + X, } from 'lucide-react' import { Button, Badge, ConfirmModal } from '../../components/ui' import { useToast } from '../../contexts/ToastContext' @@ -16,7 +16,7 @@ import { useConfirmModal } from '../../hooks' import styles from './SettingsPage.module.css' import { useSettingsWebSocket } from './useSettingsWebSocket' -// Types for memory settings +// Types interface MemoryItem { id: string timestamp: string @@ -25,6 +25,76 @@ interface MemoryItem { raw: string } +// Memory Item Form Modal Component +interface MemoryItemFormModalProps { + item: MemoryItem | null + onClose: () => void + onSave: (itemData: { category: string; content: string }) => void +} + +function MemoryItemFormModal({ item, onClose, onSave }: MemoryItemFormModalProps) { + const [category, setCategory] = useState(item?.category || 'preference') + const [content, setContent] = useState(item?.content || '') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave({ category: category.toLowerCase().trim(), content }) + } + + return ( +
+
e.stopPropagation()}> +
+

{item ? 'Edit Memory' : 'Add Memory Item'}

+ +
+
+
+
+ + setCategory(e.target.value)} + placeholder="e.g., preference, fact, work, reminder" + required + /> + + Use categories like preference, fact, work, event, or reminder + +
+ +
+ +