diff --git a/backend/app/main.py b/backend/app/main.py index 3acab17..52fed7f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -126,15 +126,30 @@ def setup_logging() -> None: ) +def setup_no_proxy() -> None: + """Ensure local loopback addresses bypass any HTTP/HTTPS proxies (such as Clash).""" + current_no_proxy = os.environ.get("NO_PROXY", os.environ.get("no_proxy", "")) + needed = ["localhost", "127.0.0.1", "::1", "0.0.0.0"] + existing = [p.strip() for p in current_no_proxy.split(",") if p.strip()] + for n in needed: + if n not in existing: + existing.append(n) + merged = ",".join(existing) + os.environ["NO_PROXY"] = merged + os.environ["no_proxy"] = merged + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: setup_logging() + setup_no_proxy() logger.info("Berry AI Studio API starting up...") yield logger.info("Berry AI Studio API shutting down...") setup_logging() +setup_no_proxy() logger = logging.getLogger("berry_ai_studio") app = FastAPI( @@ -892,6 +907,12 @@ async def stop_ollama_runtime() -> Dict[str, Any]: return ollama_supervisor.stop() +@app.post("/api/v1/ollama/install") +async def install_ollama_runtime() -> Dict[str, Any]: + """Trigger background installation or get installation instructions for Ollama.""" + return ollama_supervisor.install() + + class OllamaPullModelRequest(BaseModel): model_name: str diff --git a/backend/app/runners/comfy_runner.py b/backend/app/runners/comfy_runner.py index f82e7cd..31984fa 100644 --- a/backend/app/runners/comfy_runner.py +++ b/backend/app/runners/comfy_runner.py @@ -38,9 +38,10 @@ def __init__(self, host: str = "127.0.0.1", port: int = 8188, timeout: float = 5 self._client: Optional[httpx.AsyncClient] = None def _get_client(self) -> httpx.AsyncClient: - """Reuse long-lived pooled client to prevent socket exhaustion.""" + """Reuse long-lived pooled client to prevent socket exhaustion, bypassing proxy for local host.""" if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.timeout) + is_local = self.host in ("127.0.0.1", "localhost", "0.0.0.0") + self._client = httpx.AsyncClient(timeout=self.timeout, trust_env=not is_local) return self._client async def aclose(self) -> None: diff --git a/backend/app/runners/webui_runner.py b/backend/app/runners/webui_runner.py index d9e64cc..5ff952d 100644 --- a/backend/app/runners/webui_runner.py +++ b/backend/app/runners/webui_runner.py @@ -24,7 +24,8 @@ def __init__(self, endpoint_url: str = "http://127.0.0.1:7860") -> None: async def execute_action(self, req: CreativeActionRequest) -> Dict[str, Any]: """Execute creative action via SD WebUI REST API.""" - async with httpx.AsyncClient(timeout=120.0) as client: + is_local = "127.0.0.1" in self.endpoint_url or "localhost" in self.endpoint_url + async with httpx.AsyncClient(timeout=120.0, trust_env=not is_local) as client: if req.action == CreativeActionType.TXT2IMG: return await self._run_txt2img(client, req) elif req.action == CreativeActionType.IMG2IMG: @@ -39,7 +40,8 @@ async def execute_action(self, req: CreativeActionRequest) -> Dict[str, Any]: async def interrupt(self) -> bool: """Interrupt active execution on SD WebUI via POST /sdapi/v1/interrupt.""" try: - async with httpx.AsyncClient(timeout=5.0) as client: + is_local = "127.0.0.1" in self.endpoint_url or "localhost" in self.endpoint_url + async with httpx.AsyncClient(timeout=5.0, trust_env=not is_local) as client: resp = await client.post(f"{self.endpoint_url}/sdapi/v1/interrupt") return resp.status_code == 200 except Exception as err: diff --git a/backend/app/runtime/engine_manager.py b/backend/app/runtime/engine_manager.py index 22897cb..3aedeea 100644 --- a/backend/app/runtime/engine_manager.py +++ b/backend/app/runtime/engine_manager.py @@ -116,7 +116,8 @@ async def connect_external_engine( async def test_engine_connection(self, connection: EngineConnection) -> EngineConnection: """Probe engine endpoint and update status and capabilities.""" endpoint = connection.endpoint_url - async with httpx.AsyncClient(timeout=4.0) as client: + is_local = "127.0.0.1" in endpoint or "localhost" in endpoint + async with httpx.AsyncClient(timeout=4.0, trust_env=not is_local) as client: try: if connection.engine_type == EngineType.COMFYUI: # Query /system_stats diff --git a/backend/app/runtime/ollama_supervisor.py b/backend/app/runtime/ollama_supervisor.py index 73f2059..3f6c47e 100644 --- a/backend/app/runtime/ollama_supervisor.py +++ b/backend/app/runtime/ollama_supervisor.py @@ -119,9 +119,9 @@ def is_running(self) -> bool: return self.get_pid() is not None async def check_health(self) -> bool: - """Ping local Ollama HTTP endpoint.""" + """Ping local Ollama HTTP endpoint bypassing any system proxies.""" try: - async with httpx.AsyncClient(timeout=2.0) as client: + async with httpx.AsyncClient(timeout=2.0, trust_env=False) as client: res = await client.get(f"http://127.0.0.1:{self.port}/api/tags") return res.status_code == 200 except Exception: @@ -139,7 +139,7 @@ async def get_status(self) -> OllamaRuntimeStatus: if healthy: running = True try: - async with httpx.AsyncClient(timeout=3.0) as client: + async with httpx.AsyncClient(timeout=3.0, trust_env=False) as client: tags_res = await client.get(f"http://127.0.0.1:{self.port}/api/tags") if tags_res.status_code == 200: data = tags_res.json() @@ -233,16 +233,94 @@ def stop(self) -> Dict[str, Any]: return {"success": True, "message": f"Ollama process {pid} stopped"} + def install(self) -> Dict[str, Any]: + """Attempt to install Ollama runtime on the host system.""" + if self.is_installed(): + return { + "success": True, + "message": "Ollama is already installed", + "installed": True, + } + + if sys.platform == "win32": + winget_cmd = shutil.which("winget") + if winget_cmd: + try: + subprocess.Popen( + [winget_cmd, "install", "Ollama.Ollama", "--accept-source-agreements", "--accept-package-agreements", "--silent"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + return { + "success": True, + "message": "Installing Ollama in background via winget. Please wait 1-2 minutes.", + "installing": True, + "method": "winget", + } + except Exception as e: + logger.warning(f"winget installation failed: {e}") + + return { + "success": False, + "message": "Please install Ollama from https://ollama.com", + "installing": False, + "download_url": "https://ollama.com/download/windows", + } + else: + return { + "success": False, + "message": "Please install Ollama via: curl -fsSL https://ollama.com/install.sh | sh", + "installing": False, + "download_url": "https://ollama.com", + } + async def pull_model_stream(self, model_name: str) -> AsyncGenerator[Dict[str, Any], None]: - """Stream model pulling progress from Ollama /api/pull.""" + """Stream model pulling progress from Ollama /api/pull, ensuring Ollama is active and bypassing proxies.""" + if not self.is_installed(): + yield { + "status": "error", + "error": "Ollama is not installed. Please install Ollama from https://ollama.com first.", + "code": "NOT_INSTALLED", + } + return + + # Ensure Ollama daemon is running, auto-start if needed + if not (await self.check_health()): + logger.info("Ollama is not running. Auto-starting Ollama service...") + start_res = self.start() + if not start_res.get("success") and not self.is_running(): + yield { + "status": "error", + "error": f"Failed to start local Ollama engine: {start_res.get('message', 'Unknown error')}", + "code": "START_FAILED", + } + return + + # Wait up to 6 seconds for Ollama HTTP endpoint to become healthy + ready = False + for _ in range(12): + await asyncio.sleep(0.5) + if await self.check_health(): + ready = True + break + + if not ready: + yield { + "status": "error", + "error": f"Ollama service started but is not responding on port {self.port}.", + "code": "PORT_UNRESPONSIVE", + } + return + url = f"http://127.0.0.1:{self.port}/api/pull" self._pulling_tasks[model_name] = {"status": "pulling", "total": 0, "completed": 0} try: - async with httpx.AsyncClient(timeout=None) as client: + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: async with client.stream("POST", url, json={"name": model_name, "stream": True}) as response: if response.status_code != 200: - yield {"status": "error", "error": f"Failed to pull model ({response.status_code})"} + yield {"status": "error", "error": f"Ollama returned HTTP error ({response.status_code})"} return async for line in response.aiter_lines(): diff --git a/backend/tests/test_ollama_supervisor.py b/backend/tests/test_ollama_supervisor.py new file mode 100644 index 0000000..d071ff8 --- /dev/null +++ b/backend/tests/test_ollama_supervisor.py @@ -0,0 +1,54 @@ +import os +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from fastapi.testclient import TestClient +from app.main import app, setup_no_proxy +from app.runtime.ollama_supervisor import ollama_supervisor, OllamaSupervisor + + +def test_setup_no_proxy(): + setup_no_proxy() + no_proxy = os.environ.get("NO_PROXY", "") + assert "127.0.0.1" in no_proxy + assert "localhost" in no_proxy + + +@pytest.mark.asyncio +async def test_pull_model_stream_not_installed(): + supervisor = OllamaSupervisor() + with patch.object(supervisor, "is_installed", return_value=False): + events = [] + async for chunk in supervisor.pull_model_stream("qwen2.5:14b"): + events.append(chunk) + + assert len(events) == 1 + assert events[0]["status"] == "error" + assert "not installed" in events[0]["error"].lower() + assert events[0].get("code") == "NOT_INSTALLED" + + +@pytest.mark.asyncio +async def test_pull_model_stream_auto_start_failure(): + supervisor = OllamaSupervisor() + with patch.object(supervisor, "is_installed", return_value=True), \ + patch.object(supervisor, "check_health", new_callable=AsyncMock, return_value=False), \ + patch.object(supervisor, "start", return_value={"success": False, "message": "Exec error"}), \ + patch.object(supervisor, "is_running", return_value=False): + + events = [] + async for chunk in supervisor.pull_model_stream("qwen2.5:7b"): + events.append(chunk) + + assert len(events) == 1 + assert events[0]["status"] == "error" + assert "failed to start" in events[0]["error"].lower() + + +def test_ollama_install_endpoint(): + client = TestClient(app) + with patch.object(ollama_supervisor, "install", return_value={"success": True, "message": "Installing", "installing": True}): + res = client.post("/api/v1/ollama/install") + assert res.status_code == 200 + data = res.json() + assert data["success"] is True + assert data["installing"] is True diff --git a/frontend/src/components/launcher/SetupWizardModal.tsx b/frontend/src/components/launcher/SetupWizardModal.tsx index 1dc1518..a3eb2d1 100644 --- a/frontend/src/components/launcher/SetupWizardModal.tsx +++ b/frontend/src/components/launcher/SetupWizardModal.tsx @@ -12,6 +12,7 @@ import { Server, Zap, RefreshCw, + ExternalLink, } from 'lucide-react'; import { useSettingsStore } from '../../stores/useSettingsStore'; @@ -148,6 +149,9 @@ export const SetupWizardModal: React.FC = ({ const [ollamaStatus, setOllamaStatus] = useState(null); const [loadingOllama, setLoadingOllama] = useState(false); + const [installingOllama, setInstallingOllama] = useState(false); + const [installMessage, setInstallMessage] = useState(null); + const [startError, setStartError] = useState(null); const [selectedModel, setSelectedModel] = useState('qwen2.5:7b'); const [isPulling, setIsPulling] = useState(false); const [pullProgress, setPullProgress] = useState<{ status: string; percent: number }>({ @@ -205,19 +209,67 @@ export const SetupWizardModal: React.FC = ({ const handleStartOllama = async () => { setLoadingOllama(true); + setStartError(null); try { - await fetch('/api/v1/ollama/start', { method: 'POST' }); + const res = await fetch('/api/v1/ollama/start', { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!data.success) { + setStartError(data.message || (isZh ? '启动 Ollama 失败' : 'Failed to start Ollama')); + } await fetchDiagnostics(); - } catch (e) { + } catch (e: any) { console.error(e); + setStartError(e?.message || (isZh ? '启动失败' : 'Start failed')); } finally { setLoadingOllama(false); } }; + const handleInstallOllama = async () => { + setInstallingOllama(true); + setInstallMessage(isZh ? '正在发起 Ollama 安装...' : 'Initiating Ollama install...'); + try { + const res = await fetch('/api/v1/ollama/install', { method: 'POST' }); + const data = await res.json(); + if (data.installing) { + setInstallMessage( + isZh + ? '已通过 winget 在后台启动静默安装,约需 1~2 分钟,请稍候点击“重新体检”刷新状态。' + : 'Installing Ollama in background via winget. Please wait 1-2 minutes and rescan.' + ); + } else if (data.download_url) { + window.open(data.download_url, '_blank'); + setInstallMessage( + isZh + ? '已打开 Ollama 官网下载页面。请安装后点击下方“重新体检”。' + : 'Opened Ollama download site. Please install and rescan.' + ); + } else { + setInstallMessage(data.message || (isZh ? '未能启动安装' : 'Install request failed')); + } + setTimeout(() => fetchDiagnostics(), 4000); + } catch (e: any) { + setInstallMessage(e?.message || (isZh ? '请求安装失败' : 'Failed to request install')); + } finally { + setInstallingOllama(false); + } + }; + const handlePullModel = async (modelName: string) => { setIsPulling(true); setPullError(null); + + // Guard: check if Ollama is installed + if (ollamaStatus && !ollamaStatus.installed) { + setPullError( + isZh + ? '未检测到 Ollama 运行环境。请先完成 Ollama 安装(可点击上方“一键安装”或前往官网下载)。' + : 'Ollama is not installed. Please install Ollama from https://ollama.com first.' + ); + setIsPulling(false); + return; + } + setPullProgress({ status: isZh ? '正在建立下载连接...' : 'Connecting...', percent: 0 }); try { @@ -250,7 +302,17 @@ export const SetupWizardModal: React.FC = ({ try { const data = JSON.parse(line); if (data.error) { - setPullError(data.error); + let errMsg = data.error; + if (errMsg.includes('502') || errMsg.includes('actively refused') || errMsg.includes('ConnectError')) { + errMsg = isZh + ? '无法连接到 Ollama 服务(服务未就绪或本地代理冲突)。后台正尝试拉起服务,请稍后重试。' + : 'Cannot connect to Ollama service. Please make sure Ollama is running.'; + } else if (errMsg.includes('not installed')) { + errMsg = isZh + ? '未检测到 Ollama 运行环境,请先安装 Ollama 引擎。' + : 'Ollama is not installed. Please install Ollama first.'; + } + setPullError(errMsg); setIsPulling(false); return; } @@ -269,7 +331,13 @@ export const SetupWizardModal: React.FC = ({ await fetchDiagnostics(); setActiveStep(3); } catch (err: any) { - setPullError(err?.message || 'Download failed'); + let msg = err?.message || 'Download failed'; + if (msg.includes('502') || msg.includes('Failed to fetch')) { + msg = isZh + ? '拉取模型失败:无法连接到本地 Ollama 服务或代理劫持回环。请检查 Ollama 是否安装并启动。' + : 'Failed to pull model: cannot connect to local Ollama service. Please ensure Ollama is installed and active.'; + } + setPullError(msg); setIsPulling(false); } }; @@ -561,42 +629,85 @@ export const SetupWizardModal: React.FC = ({ {/* Ollama Service Status Card */} -
-
-
- -
-
-
- {isZh ? '内置 Ollama 运行状态' : 'Embedded Ollama Runtime'} - {ollamaStatus?.running ? ( - - ) : ( - - )} +
+
+
+
+ +
+
+
+ {isZh ? '内置 Ollama 运行状态' : 'Embedded Ollama Runtime'} + {ollamaStatus?.running ? ( + + ) : !ollamaStatus?.installed ? ( + + {isZh ? '未安装' : 'Not Installed'} + + ) : ( + + )} +
+

+ {ollamaStatus?.running + ? (isZh ? `已就绪并监听端口 ${ollamaStatus.port}` : `Active on port ${ollamaStatus.port}`) + : !ollamaStatus?.installed + ? (isZh ? '未检测到 Ollama 运行环境,需先安装' : 'Ollama runtime not found on host') + : (isZh ? '服务尚未运行,点击右侧立即启动' : 'Service is currently stopped')} +

-

- {ollamaStatus?.running - ? (isZh ? `已就绪并监听端口 ${ollamaStatus.port}` : `Active on port ${ollamaStatus.port}`) - : (isZh ? '服务尚未运行,点击右侧立即启动' : 'Service is currently stopped')} -

+
+ +
+ {!ollamaStatus?.installed ? ( + <> + + + + {isZh ? '官网下载' : 'Website'} + + + ) : !ollamaStatus?.running ? ( + + ) : ( + + + {isZh ? '已在线运行' : 'Running'} + + )}
- {!ollamaStatus?.running ? ( - - ) : ( - - - {isZh ? '已在线运行' : 'Running'} - + {installMessage && ( +
+ {installMessage} +
+ )} + + {startError && ( +
+ + {startError} +
)}
@@ -605,6 +716,37 @@ export const SetupWizardModal: React.FC = ({ {/* STEP 2: Deploy Model */} {activeStep === 2 && (
+ {/* Ollama Not Ready Warning Banner */} + {ollamaStatus && !ollamaStatus.installed && ( +
+
+ + + {isZh + ? '注意:未检测到本地 Ollama 运行环境。拉取与部署模型需要本地已安装 Ollama。' + : 'Notice: Ollama is not installed. Models require Ollama runtime.'} + +
+
+ + + {isZh ? '官网下载' : 'Website'} + +
+
+ )} +
{isZh ? '硬件适配诊断:' : 'Hardware Telemetry: '}