feat: add portable NexusOS CLI and packaging
This commit is contained in:
+42
-15
@@ -12,12 +12,16 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
try:
|
||||
import psutil
|
||||
except ImportError: # optional in the portable/Termux core install
|
||||
psutil = None
|
||||
|
||||
from .nexus_config import PROJECT_ROOT, RUNTIME_DIR
|
||||
from .nexus_config import FRONTEND_SOURCE_DIR, RUNTIME_DIR
|
||||
|
||||
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
|
||||
FRONTEND_DIR = FRONTEND_SOURCE_DIR
|
||||
PID_FILE = RUNTIME_DIR / "pids" / "frontend.pid"
|
||||
LOG_FILE = RUNTIME_DIR / "frontend.log"
|
||||
PORT = 5173
|
||||
@@ -41,12 +45,30 @@ def _is_ours(pid: int) -> bool:
|
||||
# the tracked PID's own cmdline. Loosen to "vite" (matches once the tree
|
||||
# gets that far) or "npm"+"dev" both present (matches the wrapper hop too).
|
||||
try:
|
||||
cmd = " ".join(psutil.Process(pid).cmdline())
|
||||
if psutil is not None:
|
||||
cmd = " ".join(psutil.Process(pid).cmdline())
|
||||
elif os.name != "nt":
|
||||
cmd = (Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
.replace(b"\0", b" ").decode(errors="replace"))
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return "vite" in cmd or ("npm" in cmd and "dev" in cmd)
|
||||
|
||||
|
||||
def _alive(pid: int | None) -> bool:
|
||||
if pid is None:
|
||||
return False
|
||||
if psutil is not None:
|
||||
return psutil.pid_exists(pid)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _http_up() -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1.5) as r:
|
||||
@@ -60,7 +82,7 @@ def is_running() -> bool:
|
||||
port actually answers (covers Vite started by another process, or a lost
|
||||
PID file) - not the stricter pattern match `stop()` uses before killing."""
|
||||
pid = _read_pid()
|
||||
if pid is not None and psutil.pid_exists(pid):
|
||||
if _alive(pid):
|
||||
return True
|
||||
return _http_up()
|
||||
|
||||
@@ -68,6 +90,8 @@ def is_running() -> bool:
|
||||
def start() -> dict:
|
||||
if is_running():
|
||||
return {"status": "already_running"}
|
||||
if not FRONTEND_DIR.is_dir():
|
||||
return {"status": "unavailable", "detail": "Vite source is not included in wheel installs"}
|
||||
npm = _npm()
|
||||
if not npm:
|
||||
return {"status": "error", "detail": "npm not found - install Node.js"}
|
||||
@@ -92,17 +116,20 @@ def start() -> dict:
|
||||
|
||||
def stop() -> dict:
|
||||
pid = _read_pid()
|
||||
if pid is not None and psutil.pid_exists(pid) and _is_ours(pid):
|
||||
if _alive(pid) and _is_ours(pid):
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
# npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first
|
||||
# so the parent doesn't outlive its children as an orphaned shell.
|
||||
for child in proc.children(recursive=True):
|
||||
try:
|
||||
child.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
proc.terminate()
|
||||
if psutil is not None:
|
||||
proc = psutil.Process(pid)
|
||||
# npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first
|
||||
# so the parent doesn't outlive its children as an orphaned shell.
|
||||
for child in proc.children(recursive=True):
|
||||
try:
|
||||
child.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
proc.terminate()
|
||||
elif os.name != "nt":
|
||||
os.kill(pid, 15)
|
||||
except Exception:
|
||||
pass
|
||||
PID_FILE.unlink(missing_ok=True)
|
||||
|
||||
@@ -5,9 +5,11 @@ import tempfile
|
||||
import os
|
||||
import re
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
UNDERLAY_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay.svg"
|
||||
RING_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
|
||||
from ..nexus_config import settings
|
||||
|
||||
_ROOT = settings.project_root
|
||||
UNDERLAY_FILE = settings.assets_dir / "themes/NexusOS-icons-src/nexus-underlay.svg"
|
||||
RING_FILE = settings.assets_dir / "themes/NexusOS-icons-src/nexus-underlay-ring.svg"
|
||||
ICONS_OUT = Path.home() / ".icons" / "NexusOS"
|
||||
SIZES = [16, 22, 24, 32, 48, 64, 128]
|
||||
|
||||
@@ -18,7 +20,7 @@ _ALLOWED_ROOTS = [
|
||||
"/opt",
|
||||
str(Path.home() / ".local/share/icons"),
|
||||
str(Path.home() / ".icons"),
|
||||
str(_ROOT / "assets"),
|
||||
str(settings.assets_dir),
|
||||
]
|
||||
|
||||
_UNDERLAY_FALLBACK = """\
|
||||
|
||||
+3
-2
@@ -184,6 +184,7 @@ from .memory.store import store, MemoryItem
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
from .search import needs_web_search, web_search
|
||||
|
||||
MEMORY_SERVICE = settings.memory_url
|
||||
|
||||
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||
|
||||
@@ -1591,7 +1592,7 @@ async def delete_conversation(conversation_id: str):
|
||||
|
||||
# ── Icon branding routes ──────────────────────────────────────────────────────
|
||||
|
||||
_REPO_ASSETS = str(Path(__file__).resolve().parents[1] / "assets")
|
||||
_REPO_ASSETS = str(settings.assets_dir)
|
||||
_ALLOWED_ICON_ROOTS = [
|
||||
"/usr/share/icons",
|
||||
"/usr/share/pixmaps",
|
||||
@@ -1672,7 +1673,7 @@ async def apply_icon_cache_route():
|
||||
# and uses the Vite dev server as before.
|
||||
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||
|
||||
_DIST = Path(__file__).resolve().parent.parent / "interface" / "web" / "dist"
|
||||
_DIST = settings.web_dist_dir
|
||||
if _DIST.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="ui")
|
||||
|
||||
|
||||
+189
-20
@@ -1,7 +1,10 @@
|
||||
# config.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from importlib import metadata
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
@@ -12,14 +15,84 @@ try:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- PROJECT ROOT ---
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
# --- INSTALL / RESOURCE LAYOUT ---
|
||||
PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
_CHECKOUT_ROOT = PACKAGE_DIR.parent
|
||||
SOURCE_CHECKOUT = (
|
||||
(_CHECKOUT_ROOT / "VERSION").is_file()
|
||||
and (_CHECKOUT_ROOT / "interface" / "web" / "package.json").is_file()
|
||||
)
|
||||
|
||||
# --- VERSION (single source of truth: the VERSION file at the repo root) ---
|
||||
# PROJECT_ROOT remains the source checkout for developer installs. In a wheel it
|
||||
# is the installed package directory; writable state is deliberately elsewhere.
|
||||
PROJECT_ROOT = Path(os.getenv("NEXUS_PROJECT_ROOT", "")).expanduser() if os.getenv(
|
||||
"NEXUS_PROJECT_ROOT"
|
||||
) else (_CHECKOUT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR)
|
||||
PROJECT_ROOT = PROJECT_ROOT.resolve()
|
||||
RESOURCE_ROOT = PROJECT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR / "_resources"
|
||||
|
||||
|
||||
def _user_dir(env_name: str, windows_leaf: str, xdg_name: str, xdg_fallback: str) -> Path:
|
||||
override = os.getenv(env_name, "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
if os.name == "nt":
|
||||
base = Path(os.getenv("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||
return base / windows_leaf
|
||||
base = Path(os.getenv(xdg_name, Path.home() / xdg_fallback)).expanduser()
|
||||
return base / "nexusos"
|
||||
|
||||
|
||||
CONFIG_DIR = _user_dir("NEXUS_CONFIG_DIR", "NexusOS", "XDG_CONFIG_HOME", ".config")
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
|
||||
|
||||
def read_user_config() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def write_user_config(values: dict[str, Any]) -> None:
|
||||
"""Atomically persist CLI-managed configuration."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CONFIG_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(values, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.replace(CONFIG_FILE)
|
||||
|
||||
|
||||
USER_CONFIG = read_user_config()
|
||||
|
||||
|
||||
def _value(key: str, env_name: str, default: Any) -> Any:
|
||||
raw = os.getenv(env_name)
|
||||
return raw if raw not in (None, "") else USER_CONFIG.get(key, default)
|
||||
|
||||
|
||||
def _int_value(key: str, env_name: str, default: int) -> int:
|
||||
try:
|
||||
return int(_value(key, env_name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _configured_path(key: str, env_name: str, default: Path) -> Path:
|
||||
return Path(str(_value(key, env_name, default))).expanduser().resolve()
|
||||
|
||||
|
||||
# --- VERSION (repo file in a checkout; distribution metadata in a wheel) ---
|
||||
try:
|
||||
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
|
||||
if SOURCE_CHECKOUT:
|
||||
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
VERSION = metadata.version("nexusos-ai")
|
||||
except Exception:
|
||||
VERSION = "0.0.0"
|
||||
try:
|
||||
VERSION = (RESOURCE_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
VERSION = "0.0.0"
|
||||
|
||||
# --- MODEL DEFAULTS ---
|
||||
# Single source of truth for the three models NexusOS ships with. The installers
|
||||
@@ -43,11 +116,24 @@ DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
|
||||
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
# --- CORE DIRECTORIES ---
|
||||
DATA_DIR = PROJECT_ROOT / "data"
|
||||
MODELS_DIR = PROJECT_ROOT / "models"
|
||||
RUNTIME_DIR = PROJECT_ROOT / "runtime"
|
||||
_DEFAULT_STATE = PROJECT_ROOT if SOURCE_CHECKOUT else _user_dir(
|
||||
"NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share"
|
||||
)
|
||||
STATE_DIR = _configured_path("home", "NEXUS_HOME", _DEFAULT_STATE)
|
||||
_USE_CHECKOUT_STATE = SOURCE_CHECKOUT and not os.getenv("NEXUS_HOME", "").strip()
|
||||
DATA_DIR = _configured_path("data_dir", "NEXUS_DATA_DIR", (
|
||||
PROJECT_ROOT / "data" if _USE_CHECKOUT_STATE else STATE_DIR / "data"
|
||||
))
|
||||
MODELS_DIR = _configured_path("models_dir", "NEXUS_MODELS_DIR", (
|
||||
PROJECT_ROOT / "models" if _USE_CHECKOUT_STATE else STATE_DIR / "models"
|
||||
))
|
||||
RUNTIME_DIR = _configured_path("runtime_dir", "NEXUS_RUNTIME_DIR", (
|
||||
PROJECT_ROOT / "runtime" if _USE_CHECKOUT_STATE else STATE_DIR / "runtime"
|
||||
))
|
||||
|
||||
MEMORY_DIR = PROJECT_ROOT / "synapse" / "memory"
|
||||
MEMORY_DIR = _configured_path("memory_dir", "NEXUS_MEMORY_DIR", (
|
||||
PROJECT_ROOT / "synapse" / "memory" if _USE_CHECKOUT_STATE else DATA_DIR
|
||||
))
|
||||
|
||||
LOGS_DIR = RUNTIME_DIR / "logs"
|
||||
CACHE_DIR = RUNTIME_DIR / "cache"
|
||||
@@ -57,9 +143,19 @@ TEMP_DIR = RUNTIME_DIR / "tmp"
|
||||
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
|
||||
UPLOADS_DIR = DATA_DIR / "uploads"
|
||||
EXPORTS_DIR = DATA_DIR / "exports"
|
||||
WEB_DIST_DIR = (
|
||||
PROJECT_ROOT / "interface" / "web" / "dist"
|
||||
if SOURCE_CHECKOUT else RESOURCE_ROOT / "web"
|
||||
)
|
||||
FRONTEND_SOURCE_DIR = PROJECT_ROOT / "interface" / "web"
|
||||
ASSETS_DIR = PROJECT_ROOT / "assets" if SOURCE_CHECKOUT else RESOURCE_ROOT / "assets"
|
||||
SEED_PLAYBOOK_DIR = (
|
||||
PROJECT_ROOT / "data" / "playbooks"
|
||||
if SOURCE_CHECKOUT else RESOURCE_ROOT / "playbooks"
|
||||
)
|
||||
|
||||
# --- DATABASE / STORAGE FILES (match your repo) ---
|
||||
MEMORY_DB = MEMORY_DIR / "memory.db"
|
||||
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
|
||||
|
||||
# --- LOG FILES ---
|
||||
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
||||
@@ -67,7 +163,8 @@ OLLAMA_LOG = LOGS_DIR / "ollama.log"
|
||||
CHAT_LOG = LOGS_DIR / "chat.log"
|
||||
|
||||
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
|
||||
for d in (
|
||||
_REQUIRED_DIRS = (
|
||||
STATE_DIR,
|
||||
DATA_DIR,
|
||||
MODELS_DIR,
|
||||
RUNTIME_DIR,
|
||||
@@ -78,8 +175,25 @@ for d in (
|
||||
PLAYBOOK_DIR,
|
||||
UPLOADS_DIR,
|
||||
EXPORTS_DIR,
|
||||
):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
MEMORY_DB.parent,
|
||||
)
|
||||
|
||||
|
||||
def init_state() -> list[Path]:
|
||||
"""Create writable state and seed playbooks on a first wheel install."""
|
||||
for directory in _REQUIRED_DIRS:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
copied: list[Path] = []
|
||||
if SEED_PLAYBOOK_DIR.resolve() != PLAYBOOK_DIR.resolve() and SEED_PLAYBOOK_DIR.is_dir():
|
||||
for source in SEED_PLAYBOOK_DIR.glob("*.yaml"):
|
||||
target = PLAYBOOK_DIR / source.name
|
||||
if not target.exists():
|
||||
shutil.copy2(source, target)
|
||||
copied.append(target)
|
||||
return copied
|
||||
|
||||
|
||||
INITIALIZED_FILES = init_state()
|
||||
|
||||
# --- PATH ACCESSOR (fail-fast) ---
|
||||
def path(name: str) -> Path:
|
||||
@@ -88,6 +202,9 @@ def path(name: str) -> Path:
|
||||
"""
|
||||
mapping = {
|
||||
"root": PROJECT_ROOT,
|
||||
"resources": RESOURCE_ROOT,
|
||||
"state": STATE_DIR,
|
||||
"config": CONFIG_FILE,
|
||||
"data": DATA_DIR,
|
||||
"models": MODELS_DIR,
|
||||
"runtime": RUNTIME_DIR,
|
||||
@@ -98,6 +215,8 @@ def path(name: str) -> Path:
|
||||
"playbooks": PLAYBOOK_DIR,
|
||||
"uploads": UPLOADS_DIR,
|
||||
"exports": EXPORTS_DIR,
|
||||
"web": WEB_DIST_DIR,
|
||||
"assets": ASSETS_DIR,
|
||||
"memory_db": MEMORY_DB,
|
||||
"backend_log": BACKEND_LOG,
|
||||
"ollama_log": OLLAMA_LOG,
|
||||
@@ -146,12 +265,19 @@ class Settings:
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self.version: str = VERSION
|
||||
self.source_checkout: bool = SOURCE_CHECKOUT
|
||||
self.project_root: Path = PROJECT_ROOT
|
||||
self.resource_root: Path = RESOURCE_ROOT
|
||||
self.state_dir: Path = STATE_DIR
|
||||
self.config_file: Path = CONFIG_FILE
|
||||
self.data_dir: Path = DATA_DIR
|
||||
self.models_dir: Path = MODELS_DIR
|
||||
self.runtime_dir: Path = RUNTIME_DIR
|
||||
self.memory_dir: Path = MEMORY_DIR
|
||||
self.logs_dir: Path = LOGS_DIR
|
||||
self.web_dist_dir: Path = WEB_DIST_DIR
|
||||
self.frontend_source_dir: Path = FRONTEND_SOURCE_DIR
|
||||
self.assets_dir: Path = ASSETS_DIR
|
||||
|
||||
# DB files
|
||||
self.memory_db: Path = MEMORY_DB
|
||||
@@ -166,23 +292,57 @@ class Settings:
|
||||
# should CONNECT. `ollama_bind` keeps the user's literal intent for a
|
||||
# serve we spawn (0.0.0.0 to expose it on the LAN); `ollama_host` is the
|
||||
# connectable form for our own requests.
|
||||
self.ollama_bind: str = os.getenv("OLLAMA_HOST", "") or "127.0.0.1:11434"
|
||||
self.ollama_host: str = _normalize_ollama_host(
|
||||
os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
self.provider: str = str(_value("provider", "NEXUS_PROVIDER", "ollama"))
|
||||
# A Nexus provider setting is more specific than the legacy Ollama bind
|
||||
# variable. This matters on a desktop that has OLLAMA_HOST globally set
|
||||
# but configures NexusOS to use a different remote inference machine.
|
||||
provider_url = str(_value("provider_url", "NEXUS_PROVIDER_URL", "")).strip()
|
||||
configured_host = (
|
||||
provider_url
|
||||
or os.getenv("OLLAMA_HOST", "").strip()
|
||||
or "http://127.0.0.1:11434"
|
||||
)
|
||||
self.ollama_timeout: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
|
||||
self.ollama_bind: str = configured_host or "127.0.0.1:11434"
|
||||
self.ollama_host: str = _normalize_ollama_host(
|
||||
configured_host
|
||||
)
|
||||
self.provider_url: str = self.ollama_host
|
||||
self.manage_ollama: bool = self.provider == "ollama"
|
||||
self.ollama_timeout: int = _int_value("provider_timeout", "OLLAMA_TIMEOUT", 120)
|
||||
self.bind_host: str = str(_value(
|
||||
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
|
||||
))
|
||||
self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
|
||||
self.memory_port: int = _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001)
|
||||
self.api_url: str = str(_value(
|
||||
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
|
||||
)).rstrip("/")
|
||||
self.memory_url: str = str(_value(
|
||||
"memory_url", "NEXUS_MEMORY_URL", f"http://127.0.0.1:{self.memory_port}"
|
||||
)).rstrip("/")
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"source_checkout": self.source_checkout,
|
||||
"project_root": str(self.project_root),
|
||||
"resource_root": str(self.resource_root),
|
||||
"state_dir": str(self.state_dir),
|
||||
"config_file": str(self.config_file),
|
||||
"data_dir": str(self.data_dir),
|
||||
"models_dir": str(self.models_dir),
|
||||
"runtime_dir": str(self.runtime_dir),
|
||||
"memory_dir": str(self.memory_dir),
|
||||
"memory_db": str(self.memory_db),
|
||||
"web_dist_dir": str(self.web_dist_dir),
|
||||
"provider": self.provider,
|
||||
"ollama_host": self.ollama_host,
|
||||
"ollama_timeout": self.ollama_timeout,
|
||||
"api_url": self.api_url,
|
||||
"bind_host": self.bind_host,
|
||||
"backend_port": self.backend_port,
|
||||
"memory_port": self.memory_port,
|
||||
"memory_url": self.memory_url,
|
||||
}
|
||||
|
||||
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
|
||||
@@ -203,8 +363,13 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
|
||||
_LOCAL_ORIGINS = [
|
||||
f"http://{h}:{p}"
|
||||
for h in ("localhost", "127.0.0.1")
|
||||
for p in (8000, 5173)
|
||||
for p in (
|
||||
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
|
||||
_int_value("memory_port", "NEXUS_MEMORY_PORT", 8001),
|
||||
5173,
|
||||
)
|
||||
]
|
||||
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
||||
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
||||
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
|
||||
|
||||
@@ -250,9 +415,13 @@ settings = Settings()
|
||||
# explicit exports for static checkers and IDEs
|
||||
__all__ = ["Settings", "settings", "path", "VERSION",
|
||||
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
|
||||
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||
"PACKAGE_DIR", "PROJECT_ROOT", "RESOURCE_ROOT", "SOURCE_CHECKOUT",
|
||||
"STATE_DIR", "CONFIG_DIR", "CONFIG_FILE", "USER_CONFIG",
|
||||
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
|
||||
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
||||
"EXPORTS_DIR", "MEMORY_DB",
|
||||
"EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
||||
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
||||
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
||||
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
||||
"MAX_REQUEST_BYTES", "MAX_UPLOAD_BYTES", "MAX_PDF_PAGES",
|
||||
|
||||
@@ -20,7 +20,9 @@ _log = logging.getLogger("nexus.ollama")
|
||||
_ollama_manager = None
|
||||
|
||||
# Bundled binary ships alongside the project; fall back to system PATH
|
||||
_BUNDLED_OLLAMA = Path(__file__).resolve().parent.parent / "ollama" / "bin" / "ollama"
|
||||
_BUNDLED_OLLAMA = settings.project_root / "ollama" / "bin" / (
|
||||
"ollama.exe" if os.name == "nt" else "ollama"
|
||||
)
|
||||
|
||||
# POSIX: detach the child into its own session so we can signal the whole group.
|
||||
# Windows has no setsid/killpg — run the child normally and terminate() it.
|
||||
@@ -340,7 +342,7 @@ class OllamaManager:
|
||||
self.running = False
|
||||
self._available = None # see is_available()
|
||||
|
||||
self.runtime_dir = Path(runtime_dir) if runtime_dir else Path(__file__).resolve().parent.parent / "runtime"
|
||||
self.runtime_dir = Path(runtime_dir) if runtime_dir else settings.runtime_dir
|
||||
(self.runtime_dir / "logs").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.log_file = self.runtime_dir / "logs" / "ollama.log"
|
||||
@@ -406,6 +408,10 @@ class OllamaManager:
|
||||
return env
|
||||
|
||||
def is_available(self):
|
||||
if not settings.manage_ollama:
|
||||
# A remote provider has no local executable to discover. Availability
|
||||
# means it is configured; is_running() performs the live probe.
|
||||
return True
|
||||
# ponytail: cached for the life of the process. This spawns a subprocess,
|
||||
# and /status calls it on every poll - the frontend polls continuously,
|
||||
# so it was a process spawn per tick to answer a question whose answer
|
||||
@@ -433,6 +439,9 @@ class OllamaManager:
|
||||
return False
|
||||
|
||||
def start(self):
|
||||
if not settings.manage_ollama:
|
||||
_log.info("Remote Ollama is externally managed at %s", self._api_base)
|
||||
return self.is_running()
|
||||
if not self.is_available():
|
||||
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
|
||||
return False
|
||||
@@ -474,6 +483,9 @@ class OllamaManager:
|
||||
|
||||
async def start_async(self):
|
||||
"""Async-safe version of start() for use inside async startup handlers."""
|
||||
if not settings.manage_ollama:
|
||||
_log.info("Remote Ollama is externally managed at %s", self._api_base)
|
||||
return self.is_running()
|
||||
if not self.is_available():
|
||||
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
|
||||
return False
|
||||
@@ -514,6 +526,9 @@ class OllamaManager:
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
if not settings.manage_ollama:
|
||||
_log.info("Not stopping externally managed Ollama at %s", self._api_base)
|
||||
return False
|
||||
# Terminate a server we spawned ourselves.
|
||||
if self.process:
|
||||
try:
|
||||
@@ -902,4 +917,4 @@ def shutdown_ollama() -> None:
|
||||
global _ollama_manager
|
||||
if _ollama_manager is not None:
|
||||
_ollama_manager.stop()
|
||||
_ollama_manager = None
|
||||
_ollama_manager = None
|
||||
|
||||
Reference in New Issue
Block a user