mirror of
https://github.com/trevorsandy/ai-suite.git
synced 2026-09-30 21:45:14 +02:00
1323 lines
59 KiB
Python
1323 lines
59 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Trevor SANDY
|
|
Last Update January 11, 2026
|
|
Copyright (c) 2025-Present by Trevor SANDY
|
|
|
|
AI-Suite uses this script for the installation command that handles the AI-Suite
|
|
functional module selection, llama CPU/GPU configuration, and starting Supabase
|
|
and Open WebUI Filesystem when specified.
|
|
|
|
If specified, the Supabase stack is started first. The script waits for it to initialize,
|
|
then starts open-webui filesystem tool - if specified, and then starts the AI-Suite
|
|
stack. All stacks use the same Docker Compose services project name ("ai-suite")
|
|
so they appear grouped together in Docker Desktop.
|
|
|
|
This script is also used for operation commands that start, stop, stop-llama,
|
|
pause, unpause, update and install the AI-Suite services using the optional
|
|
--operation argument. A llama (Ollama/Llama.cpp) check is performed when it is
|
|
assumed llama is running from the Docker Host. If llama is determined to be installed
|
|
but not running, an attempt to launch the Ollama/Llama.cpp service is executed
|
|
on install, start and unpause. The check will also attempt to stop the llama service
|
|
(in addition to stopping the AI-Suite services) when the stop-llama operational
|
|
command is specified.
|
|
|
|
Both installation and operation commands utilize the optional --profile
|
|
arguments to specify which AI-Suite functional modules and which llama CPU/GPU
|
|
configuration to use. When no functional profile argument is specified, the
|
|
default functional module open-webui is used, Likewise, if no CPU/GPU configuration
|
|
profile is specified, it is assumed llama is being run from the Docker Host.
|
|
Multiple profile arguments (functional modules) are supported.
|
|
|
|
The --environment command allows the installation to be defined as private (default)
|
|
or public. A public install restricts the communication ports exposed to the network.
|
|
|
|
For full installation and operation details, see the AI-Suite repository README.md
|
|
|
|
This script has been adapted from start_services.py by Cole Medin
|
|
|
|
This program is free software; you can redistribute it and/or modify it under
|
|
the terms of the Apache License, Version 2.0.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import argparse
|
|
import datetime
|
|
import dotenv
|
|
import pathlib
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import textwrap
|
|
import time
|
|
|
|
# Info attributes
|
|
INFO = {
|
|
"name" : "AI-Suite",
|
|
"version" : (0, 4, 0),
|
|
"title" : "AI-Suite installation and operation",
|
|
"file" : os.path.basename(__file__),
|
|
"description": "A dockerized suite of AI agents in a no-code, workflow, LLM environment",
|
|
"author" : "Trevor SANDY <trevor.sandy@gmail.com>",
|
|
"author_url" : "https://github.com/trevorsandy/",
|
|
"repository" : "https://github.com/trevorsandy/ai-suite",
|
|
"issues" : "https://github.com/trevorsandy/ai-suite/issues",
|
|
"license" : "Apache License 2.0",
|
|
"copyright" : "Copyright (c) 2025-present by Trevor SANDY"
|
|
}
|
|
|
|
def run_command(cmd, cwd=None):
|
|
"""Run a shell command and print it."""
|
|
print("Running command:", " ".join(cmd))
|
|
try:
|
|
completed = subprocess.run(cmd, cwd=cwd, check=True)
|
|
if completed.returncode != 0:
|
|
print(f"Command Error: {completed.stderr}")
|
|
except Exception as e:
|
|
print(f"Command Exception: {e}.")
|
|
|
|
def launch_llama_process(args):
|
|
"""Launch Ollama/Llama.cpp server on the host"""
|
|
llama_log_dir = "llama.cpp" if llama_cpp else ""
|
|
llama_log_path = os.path.join(os.getcwd(), llama_log_dir, 'llama_start.log')
|
|
llama_log = "".join(['>', llama_log_path, ' 2>&1'])
|
|
if system == "Windows":
|
|
win = "".join(['/c,"', llama_exe])
|
|
cmd = ['powershell', '-Command', 'Start-Process cmd -Args', win, args,
|
|
"".join([llama_log, '"']), '-WindowStyle Hidden']
|
|
else: # Unix-based systems (Linux, macOS)
|
|
cmd = [llama_exe, args, llama_log]
|
|
print("Running command:", " ".join(cmd))
|
|
try:
|
|
completed = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
if completed.returncode != 0:
|
|
print(f"Error: {llama} process: {completed.stderr}")
|
|
except Exception as e:
|
|
print(f"Exception: {llama} process: {e} - assuming {llama} did not start.")
|
|
global attempted_launch
|
|
attempted_launch = True
|
|
print(f"Waiting for {llama} on host to initialize...")
|
|
time.sleep(4)
|
|
check_llama_process(None, {})
|
|
|
|
def check_llama_cpp_model(operation, env_vars, using_hf):
|
|
"""Check if the specified llama.cpp model exists, offer to download if not"""
|
|
model_path = normalize_path(env_vars.get('LLAMACPP_MODEL_PATH'))
|
|
model_name = env_vars.get('LLAMACPP_MODEL_NAME', 'gemma-4b')
|
|
if not os.path.exists(model_path):
|
|
proceed = True
|
|
# Dictionary of common llama.cpp model names and their download identifiers
|
|
llama_cpp_models = {
|
|
"LLAMACPP_MODEL_GEMMA": "LLAMACPP_MODEL_GEMMA_ID",
|
|
"LLAMACPP_MODEL_DEEPSEEK": "LLAMACPP_MODEL_DEEPSEEK_ID",
|
|
"LLAMACPP_MODEL_MISTRAL": "LLAMACPP_MODEL_MISTRAL_ID",
|
|
"LLAMACPP_MODEL_LLAMA": "LLAMACPP_MODEL_LLAMA_ID",
|
|
"LLAMACPP_MODEL_QWEN": "LLAMACPP_MODEL_QWEN_ID",
|
|
"LLAMACPP_MODEL_USER": "LLAMACPP_MODEL_USER_ID"
|
|
}
|
|
if operation != 'install' and not using_hf:
|
|
print(f"{llama} model not found at {model_path}")
|
|
response = input(f"Would you like to download the {model_name} model now? (y/n): ")
|
|
proceed = response.lower() == 'y'
|
|
if proceed:
|
|
model_dir = os.path.dirname(model_path)
|
|
if not os.path.exists(model_dir):
|
|
os.makedirs(model_dir,exist_ok=True)
|
|
best_match = None
|
|
best_match_key = None
|
|
best_match_score = 0
|
|
for model_key in llama_cpp_models.keys():
|
|
known_model = env_vars.get(model_key)
|
|
if not known_model:
|
|
continue
|
|
match_score = sum(c1 == c2 for c1, c2 in zip(model_name.lower(), known_model.lower()))
|
|
if match_score > best_match_score:
|
|
best_match = known_model
|
|
best_match_key = model_key
|
|
best_match_score = match_score
|
|
if best_match and best_match_key and best_match_score > len(best_match) / 2:
|
|
print(f"Using {llama} model: {best_match}...")
|
|
return env_vars.get(llama_cpp_models[best_match_key])
|
|
else:
|
|
print(f"Error: Unknown model '{model_name}', download model manually - Models:")
|
|
proceed = False
|
|
else: # User elected not to proceed
|
|
print("Notice: Download the model manually and update your .env file - Models:")
|
|
if not proceed:
|
|
llama_server_args = env_vars.get('LLAMACPP_SERVER_ARGS')
|
|
for model_key, model_value in llama_cpp_models.items():
|
|
model = env_vars.get(model_key)
|
|
model_id = env_vars.get(model_value)
|
|
print(f"- {model} command: {llama_app} {llama_server_args} {model_id}")
|
|
return None
|
|
print(f"Using {llama} model: {model_name}...")
|
|
return model_name
|
|
|
|
def check_llama_process(operation=None, env_vars={}):
|
|
"""Check for Ollama/Llama.cpp (on host) and attempt to launch if not running."""
|
|
if not attempted_launch:
|
|
print(f"Checking for {llama} process on host...")
|
|
llama_running = False
|
|
llama_proc = llama_app.lower()
|
|
try:
|
|
if system == "Windows":
|
|
cmd = ["tasklist"]
|
|
else: # Unix-based systems (Linux, macOS)
|
|
cmd = ["pgrep", "-f", llama_proc]
|
|
print("Running command:", " ".join(cmd))
|
|
completed = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
if system == "Windows":
|
|
llama_running = True if llama_proc in completed.stdout.lower() else False
|
|
else: # Unix-based systems (Linux, macOS)
|
|
llama_running = completed.returncode == 0 if completed else False
|
|
except Exception as e:
|
|
print(f"Exception: {llama} process: {e} - assuming {llama} is not running.")
|
|
|
|
stop_llama = operation == 'stop-llama'
|
|
start_llama = not stop_llama and operation in ['start', 'unpause']
|
|
|
|
if llama_running:
|
|
if stop_llama:
|
|
print(f"Stopping {llama} process on host...")
|
|
if system == "Windows":
|
|
cmd = ["taskkill", "/f", "/im", llama_proc]
|
|
else: # Unix-based systems (Linux, macOS)
|
|
cmd = ["ps", "-C", llama_proc, "-o", "pid=|xargs", "kill", "-9"]
|
|
print("Running command:", " ".join(cmd))
|
|
os.system(" ".join(cmd))
|
|
else:
|
|
insert = "is now" if attempted_launch else "is"
|
|
print(f"{llama} on host {insert} running...")
|
|
else:
|
|
if attempted_launch:
|
|
print(f"Failed to launch {llama} on host - exiting...")
|
|
sys.exit(1)
|
|
print(f"{llama} is not running...")
|
|
if start_llama:
|
|
if llama_found:
|
|
print(f"Attempting to launch {llama} on host...")
|
|
llama_args = []
|
|
if llama_cpp:
|
|
regex = r"(?:-hf|--hf-file|-m|--model|--model-url)"
|
|
llama_hf_repo = env_vars.get('LLAMA_ARG_HF_REPO')
|
|
llama_server_args = env_vars.get('LLAMACPP_SERVER_ARGS')
|
|
llama_models_dir = normalize_path(env_vars.get('LLAMACPP_MODELS_DIR'))
|
|
llama_model_arg = re.search(regex, llama_server_args)
|
|
if llama_models_dir:
|
|
if os.path.isdir(llama_models_dir):
|
|
default_models_dir = normalize_path(os.path.join('llama.cpp','models'))
|
|
if llama_models_dir != default_models_dir and os.listdir(llama_models_dir):
|
|
regex = r"\b{--models-dir}\b"
|
|
if not re.search(regex, llama_server_args):
|
|
llama_args.extend(["--models-dir", llama_models_dir])
|
|
else:
|
|
print(f"Error: Models directory {llama_models_dir} does not exist.")
|
|
can_download = True if llama_hf_repo else False
|
|
if llama_server_args:
|
|
llama_args.extend([llama_server_args])
|
|
if not llama_hf_repo:
|
|
using_hf = False
|
|
if llama_model_arg:
|
|
using_hf = llama_model_arg.group() in ['-hf', '--hf-file']
|
|
can_download = llama_model_arg.group() not in ['-m', '--model']
|
|
llama_model = check_llama_cpp_model(operation, env_vars, using_hf)
|
|
if llama_model:
|
|
if not llama_model_arg:
|
|
llama_args.extend(['-hf'])
|
|
llama_args.extend([llama_model])
|
|
elif operation == 'install':
|
|
sys.exit(1)
|
|
if can_download and operation in ['install', 'update']:
|
|
print(f"""Configured to download {llama} model (this may take a while).\n
|
|
Check container log for download progress...""")
|
|
else:
|
|
llama_server_args = env_vars.get('OLLAMA_SERVER_ARGS')
|
|
if llama_server_args:
|
|
llama_args.extend([llama_server_args])
|
|
|
|
args = " ".join(llama_args)
|
|
launch_llama_process(args)
|
|
else:
|
|
print(textwrap.dedent(f"""\
|
|
Error: The {llama_app} file was not found at {llama_exe}.
|
|
If {llama} is installed in a non-standard location, you can set the LLAMA_PATH
|
|
environment variable with its full path (including the {llama} file) in .env and
|
|
re-run {info.get("file")} - exiting...
|
|
"""))
|
|
sys.exit(1)
|
|
|
|
def clone_supabase_repo():
|
|
"""Clone the Supabase repository using sparse checkout if not already present."""
|
|
if not os.path.exists("supabase"):
|
|
print("Cloning the Supabase repository...")
|
|
run_command([
|
|
"git", "clone", "--filter=blob:none", "--no-checkout",
|
|
"https://github.com/supabase/supabase.git"
|
|
])
|
|
os.chdir("supabase")
|
|
run_command(["git", "sparse-checkout", "init", "--cone"])
|
|
run_command(["git", "sparse-checkout", "set", "docker"])
|
|
run_command(["git", "checkout", "master"])
|
|
os.chdir("..")
|
|
else:
|
|
print("Supabase repository already exists, updating...")
|
|
os.chdir("supabase")
|
|
run_command(["git", "pull"])
|
|
os.chdir("..")
|
|
|
|
def clone_open_webui_tools_filesystem_repo():
|
|
"""Clone the Open WebUI Tools Filesystem repository using sparse checkout if
|
|
not already present.
|
|
"""
|
|
repo_path = os.path.join("open-webui", "tools", "servers")
|
|
if not os.path.exists(repo_path):
|
|
os.chdir("open-webui")
|
|
print("Cloning the Open WebUI Tools Filesystem repository...")
|
|
run_command([
|
|
"git", "clone", "--filter=blob:none", "--no-checkout",
|
|
"https://github.com/open-webui/openapi-servers.git", "tools"
|
|
])
|
|
os.chdir("tools")
|
|
run_command(["git", "sparse-checkout", "init", "--cone"])
|
|
run_command(["git", "sparse-checkout", "set", "servers/filesystem"])
|
|
run_command(["git", "checkout", "main"])
|
|
os.chdir("../../")
|
|
else:
|
|
repo_path = os.path.join("open-webui", "tools")
|
|
print("Open WebUI Tools Filesystem repository already exists, updating...")
|
|
os.chdir(repo_path)
|
|
run_command(["git", "pull"])
|
|
os.chdir("../../")
|
|
|
|
def clone_open_webui_functions_repos():
|
|
"""Clone the Open WebUI Functions repository using sparse checkout if not
|
|
already present.
|
|
"""
|
|
repo_path = os.path.join("open-webui", "functions", "open-webui")
|
|
if not os.path.exists(repo_path):
|
|
os.mkdir("open-webui/functions")
|
|
os.chdir("open-webui/functions")
|
|
print("Cloning the Open WebUI Functions repository...")
|
|
run_command([
|
|
"git", "clone", "--filter=blob:none", "--no-checkout",
|
|
"https://github.com/open-webui/functions.git", "open-webui"
|
|
])
|
|
os.chdir("open-webui")
|
|
run_command(["git", "sparse-checkout", "init", "--cone"])
|
|
run_command(["git", "sparse-checkout", "set", "functions/filters", "functions/pipes/openai"])
|
|
run_command(["git", "checkout", "main"])
|
|
os.chdir("../../../")
|
|
else:
|
|
print("Open WebUI Functions repository already exists, updating...")
|
|
os.chdir(repo_path)
|
|
run_command(["git", "pull"])
|
|
os.chdir("../../../")
|
|
|
|
repo_path = os.path.join("open-webui", "functions", "owndev")
|
|
if not os.path.exists(repo_path):
|
|
os.chdir("open-webui/functions")
|
|
print("Cloning the Open WebUI Owndev Functions repository...")
|
|
run_command([
|
|
"git", "clone", "--filter=blob:none", "--no-checkout",
|
|
"https://github.com/owndev/Open-WebUI-Functions.git", "owndev"
|
|
])
|
|
os.chdir("owndev")
|
|
run_command(["git", "sparse-checkout", "init", "--cone"])
|
|
run_command(["git", "sparse-checkout", "set", "pipelines/n8n", "filters", "docs"])
|
|
run_command(["git", "checkout", "main"])
|
|
os.chdir("../../../")
|
|
else:
|
|
print("Open WebUI Functions repository already exists, updating...")
|
|
os.chdir(repo_path)
|
|
run_command(["git", "pull"])
|
|
os.chdir("../../../")
|
|
|
|
os.chdir(repo_path)
|
|
docs_dir = os.path.join("docs")
|
|
retain = ["n8n-integration.md", "n8n-tool-usage-display.md"]
|
|
os.chdir(docs_dir)
|
|
for item in os.listdir(os.getcwd()):
|
|
if item not in retain:
|
|
if pathlib.Path(item).is_file():
|
|
os.remove(item)
|
|
elif pathlib.Path(item).is_dir():
|
|
shutil.rmtree(item)
|
|
os.chdir("../../../../")
|
|
|
|
def prepare_supabase_env(env_vars):
|
|
"""Write env_vars to .env in supabase/docker."""
|
|
env_file = os.path.join("supabase", "docker", ".env")
|
|
built_env_vars = env_vars
|
|
built_env_vars['COMPOSE_IGNORE_ORPHANS'] = 'true'
|
|
write_dotenv_file(env_file, built_env_vars)
|
|
|
|
def prepare_open_webui_tools_filesystem_env(env_vars):
|
|
"""Write env_vars to .env and compose.yaml to open-webui/tools/servers/filesystem."""
|
|
env_file = os.path.join("open-webui", "tools", "servers", "filesystem", ".env")
|
|
built_env_vars = env_vars
|
|
built_env_vars['COMPOSE_IGNORE_ORPHANS'] = 'true'
|
|
write_dotenv_file(env_file, built_env_vars)
|
|
|
|
docker_compose_path = os.path.join("open-webui", "tools", "servers", "filesystem", "compose.yaml")
|
|
print(f"Writing {docker_compose_path}...")
|
|
with open(docker_compose_path, 'w') as f:
|
|
f.write(textwrap.dedent("""\
|
|
services:
|
|
open-webui-filesystem:
|
|
container_name: open-webui-filesystem
|
|
restart: unless-stopped
|
|
build:
|
|
context: .
|
|
ports:
|
|
- 8091:8091
|
|
extra_hosts:
|
|
- host.docker.internal:host-gateway
|
|
volumes:
|
|
- ${PROJECTS_PATH:-../shared}:/nonexistent/tmp
|
|
environment:
|
|
- PROJECTS_PATH
|
|
"""))
|
|
|
|
def destroy_ai_suite(profile, install):
|
|
"""Stop and remove AI-Suite containers and volumes (using its compose file)
|
|
for the specified profile arguments.
|
|
"""
|
|
insert = "and volumes for" if install else "for"
|
|
print(f"Destroying {name} containers {insert} profile arguments: {profile}...")
|
|
cmd = ["docker", "compose", "-p", "ai-suite"]
|
|
if profile:
|
|
for argument in profile:
|
|
cmd.extend(["--profile", argument])
|
|
cmd.extend(["-f", "docker-compose.yml", "down"])
|
|
if install:
|
|
cmd.extend(["--volumes"])
|
|
run_command(cmd)
|
|
if install:
|
|
cmd = ["docker", "volume", "prune", "--force"]
|
|
run_command(cmd)
|
|
|
|
def operate_ai_suite(operation, profile, environment, env_vars):
|
|
"""Start, stop, pause or pull the AI-Suite containers (using its compose file)
|
|
for the specified profile arguments and environment argument.
|
|
"""
|
|
if not operation:
|
|
operation = "stop"
|
|
|
|
with open('.operation', 'w') as f:
|
|
f.write(operation + ':' + llama.lower())
|
|
|
|
supabase = False
|
|
open_webui = False
|
|
if profile and operation != 'pull':
|
|
supabase = any(p for p in profile if p in ['supabase', 'ai-all'])
|
|
open_webui = any(p for p in profile if p in open_webui_all_profiles)
|
|
|
|
if operation == 'start' and environment:
|
|
env_vars['POSTGRES_HOST'] = "db" if supabase else "postgres"
|
|
load_dotenv_vars(env_vars)
|
|
if supabase:
|
|
start_supabase(environment, False)
|
|
print("""Waiting for Supabase to initialize...""")
|
|
time.sleep(10)
|
|
if open_webui:
|
|
start_open_webui_tools_filesystem(environment, False)
|
|
time.sleep(1)
|
|
start_ai_suite(profile, environment, False)
|
|
return
|
|
|
|
if operation == 'stop':
|
|
insert = "Stopping"
|
|
elif operation == 'pull':
|
|
insert = "Pulling"
|
|
else:
|
|
insert = "Pausing" if operation == 'pause' else None
|
|
container = "images" if operation == 'pull' else "containers"
|
|
print(f"{insert} '{name}' {container} for profile arguments: {profile}...")
|
|
base = ["docker", "compose", "-p", "ai-suite"]
|
|
if supabase:
|
|
cmd = base + ["-f", "supabase/docker/docker-compose.yml", operation]
|
|
run_command(cmd)
|
|
if open_webui:
|
|
cmd = base + ["-f", "open-webui/tools/servers/filesystem/compose.yaml", operation]
|
|
run_command(cmd)
|
|
cmd = base
|
|
if profile:
|
|
for argument in profile:
|
|
cmd.extend(["--profile", argument])
|
|
cmd.extend(["-f", "docker-compose.yml", operation])
|
|
run_command(cmd)
|
|
if operation == 'pull':
|
|
cmd = ["docker", "image", "prune", "--force"]
|
|
run_command(cmd)
|
|
|
|
def start_built_container(compose_file=None, environment=None, build=False):
|
|
"""Start the locally built container services (using its compose file)."""
|
|
cmd = ["docker", "compose", "-p", "ai-suite", "-f", compose_file]
|
|
if environment == "public":
|
|
cmd.extend(["-f", "docker-compose.override.public.yml"])
|
|
cmd.extend(["up", "-d"])
|
|
if build:
|
|
cmd.extend(["--build"])
|
|
run_command(cmd)
|
|
|
|
def start_supabase(environment=None, build=False):
|
|
"""Start the Supabase services (using its compose file)."""
|
|
print("Starting Supabase services...")
|
|
compose_file = "supabase/docker/docker-compose.yml"
|
|
start_built_container(compose_file, environment, build)
|
|
|
|
def start_open_webui_tools_filesystem(environment=None, build=False):
|
|
"""Start the Open WebUI Tools Filesystem services (using its compose file)."""
|
|
print("Starting Open WebUI Tools Filesystem services...")
|
|
compose_file = "open-webui/tools/servers/filesystem/compose.yaml"
|
|
start_built_container(compose_file, environment, build)
|
|
|
|
def start_ai_suite(profile=None, environment=None, build=False):
|
|
"""Start the AI-Suite services (using its compose file) for the specified
|
|
profile arguments and environment argument.
|
|
"""
|
|
print(f"Starting {name} services for profile arguments: {profile}...")
|
|
cmd = ["docker", "compose", "-p", "ai-suite"]
|
|
if profile:
|
|
for argument in profile:
|
|
cmd.extend(["--profile", argument])
|
|
else:
|
|
cmd.extend(["--profile", 'open-webui'])
|
|
cmd.extend(["-f", "docker-compose.yml"])
|
|
if environment == "private":
|
|
cmd.extend(["-f", "docker-compose.override.private.yml"])
|
|
if environment == "public":
|
|
cmd.extend(["-f", "docker-compose.override.public.yml"])
|
|
cmd.extend(["up", "-d"])
|
|
if build:
|
|
cmd.extend(["--remove-orphans"])
|
|
run_command(cmd)
|
|
|
|
def generate_searxng_secret_key():
|
|
"""Generate a secret key for SearXNG based on the current platform."""
|
|
print("Checking SearXNG settings...")
|
|
|
|
# Define paths for SearXNG settings files
|
|
settings_path = os.path.join("searxng", "settings.yml")
|
|
settings_base_path = os.path.join("searxng", "settings-base.yml")
|
|
|
|
# Check if settings-base.yml exists
|
|
if not os.path.exists(settings_base_path):
|
|
print(f"Warning: SearXNG base settings file not found at {settings_base_path}")
|
|
return
|
|
|
|
# Check if settings.yml exists, if not create it from settings-base.yml
|
|
if not os.path.exists(settings_path):
|
|
print(f"SearXNG settings.yml not found. Creating from {settings_base_path}...")
|
|
try:
|
|
shutil.copyfile(settings_base_path, settings_path)
|
|
print(f"Created {settings_path} from {settings_base_path}")
|
|
except Exception as e:
|
|
print(f"Exception: Create SearXNG settings.yml: {e}")
|
|
return
|
|
else:
|
|
print(f"SearXNG settings.yml already exists at {settings_path}")
|
|
|
|
print("Generating SearXNG secret key...")
|
|
|
|
# Run the appropriate platform command
|
|
try:
|
|
if system == "Windows":
|
|
print("Using Windows PowerShell to generate secret key...")
|
|
# PowerShell command to generate a random key and replace in the settings file
|
|
ps_command = [
|
|
"powershell", "-Command",
|
|
"$randomBytes = New-Object byte[] 32; " +
|
|
"(New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($randomBytes); " +
|
|
"$secretKey = -join ($randomBytes | ForEach-Object { \"{0:x2}\" -f $_ }); " +
|
|
"(Get-Content searxng/settings.yml) -replace 'ultrasecretkey', $secretKey | Set-Content searxng/settings.yml"
|
|
]
|
|
subprocess.run(ps_command, check=True)
|
|
|
|
elif system == "Darwin": # macOS
|
|
print("Using macOS sed command with empty string parameter...")
|
|
# macOS sed command requires an empty string for the -i parameter
|
|
openssl_cmd = ["openssl", "rand", "-hex", "32"]
|
|
random_key = subprocess.check_output(openssl_cmd).decode('utf-8').strip()
|
|
sed_cmd = ["sed", "-i", "", f"s|ultrasecretkey|{random_key}|g", settings_path]
|
|
subprocess.run(sed_cmd, check=True)
|
|
|
|
else: # Linux and other Unix-like systems
|
|
print("Using standard Linux/Unix sed command...")
|
|
# Standard sed command for Linux
|
|
openssl_cmd = ["openssl", "rand", "-hex", "32"]
|
|
random_key = subprocess.check_output(openssl_cmd).decode('utf-8').strip()
|
|
sed_cmd = ["sed", "-i", f"s|ultrasecretkey|{random_key}|g", settings_path]
|
|
subprocess.run(sed_cmd, check=True)
|
|
|
|
print("SearXNG secret key generated successfully.")
|
|
|
|
except Exception as e:
|
|
print(f"Exception: Generate SearXNG secret key: {e}")
|
|
print("You may need to manually generate the secret key using the commands:")
|
|
print(" - Linux: sed -i \"s|ultrasecretkey|$(openssl rand -hex 32)|g\" searxng/settings.yml")
|
|
print(" - macOS: sed -i '' \"s|ultrasecretkey|$(openssl rand -hex 32)|g\" searxng/settings.yml")
|
|
print(" - Windows (PowerShell):")
|
|
print(" $randomBytes = New-Object byte[] 32")
|
|
print(" (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($randomBytes)")
|
|
print(" $secretKey = -join ($randomBytes | ForEach-Object { \"{0:x2}\" -f $_ })")
|
|
print(" (Get-Content searxng/settings.yml) -replace 'ultrasecretkey', $secretKey | Set-Content searxng/settings.yml")
|
|
|
|
def check_and_fix_docker_compose_for_searxng():
|
|
"""Check and modify docker-compose.yml for SearXNG first run."""
|
|
docker_compose_path = "docker-compose.yml"
|
|
if not os.path.exists(docker_compose_path):
|
|
print(f"Warning: Docker Compose file not found at {docker_compose_path}")
|
|
return
|
|
|
|
try:
|
|
# Default to first run
|
|
is_first_run = True
|
|
|
|
# Check if Docker is running and if the SearXNG container exists
|
|
try:
|
|
# Check if the SearXNG container is running
|
|
container_check = subprocess.run(
|
|
["docker", "ps", "--filter", "name=searxng", "--format", "{{.Names}}"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
searxng_containers = container_check.stdout.strip().split('\n')
|
|
|
|
# If SearXNG container is running, check inside for uwsgi.ini
|
|
if any(container for container in searxng_containers if container):
|
|
container_name = next(container for container in searxng_containers if container)
|
|
print(f"Found running SearXNG container: {container_name}")
|
|
|
|
# Check if uwsgi.ini exists inside the container
|
|
container_check_cmd = \
|
|
"[ -f /etc/searxng/uwsgi.ini ] && echo 'found' || echo 'not_found'"
|
|
container_check = subprocess.run(
|
|
["docker", "exec", container_name, "sh", "-c", container_check_cmd],
|
|
capture_output=True, text=True, check=False
|
|
)
|
|
|
|
if "found" in container_check.stdout:
|
|
print("Found uwsgi.ini inside the SearXNG container - not first run")
|
|
is_first_run = False
|
|
else:
|
|
print("uwsgi.ini not found inside the SearXNG container - first run")
|
|
is_first_run = True
|
|
else:
|
|
print("No running SearXNG container found - assuming first run")
|
|
is_first_run = True
|
|
except Exception as e:
|
|
print(f"Exception: Check Docker container running: {e} - assuming first run")
|
|
|
|
# Temporarily comment out the cap_drop line on first run
|
|
if is_first_run:
|
|
print("First run detected for SearXNG. Temporarily commenting 'cap_drop:' directive...")
|
|
with open(docker_compose_path, 'r+') as f:
|
|
commented = False
|
|
searxng_found = False
|
|
lines = f.readlines()
|
|
f.seek(0)
|
|
f.truncate()
|
|
for line in lines:
|
|
if not commented:
|
|
compare = line.strip()
|
|
if compare == 'searxng:':
|
|
searxng_found = True
|
|
if searxng_found:
|
|
if compare == 'cap_drop:':
|
|
line = " #cap_drop:\n"
|
|
if compare == '- ALL':
|
|
line = " # - ALL # Temporarily commented out for first run\n"
|
|
commented = True
|
|
searxng_found = False
|
|
print("SearXNG 'cap_drop:' directive temporarily commented...")
|
|
f.write(line)
|
|
print("Note: After the first run completes successfully, uncomment 'cap_drop:' in docker-compose.yml for security.")
|
|
else:
|
|
# Read the docker-compose.yml file
|
|
with open(docker_compose_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Uncomment the cap_drop line
|
|
cap_drop_comment = " #cap_drop:\n # - ALL # Temporarily commented out for first run\n"
|
|
if cap_drop_comment in content:
|
|
print(f"SearXNG has been initialized. Uncommenting 'cap_drop:' directive for security...")
|
|
cap_drop = "cap_drop:\n - ALL\n"
|
|
modified_content = content.replace(cap_drop_comment, cap_drop)
|
|
|
|
# Write the modified content back
|
|
with open(docker_compose_path, 'w') as f:
|
|
f.write(modified_content)
|
|
|
|
except Exception as e:
|
|
print(f"Exception: Check/modify docker-compose.yml for SearXNG: {e}")
|
|
|
|
# Treat Selfhosted Supavisor Pooler Keeps Restarting.
|
|
# See: https://github.com/supabase/supabase/issues/30210
|
|
def convert_supabase_pooler_line_endings():
|
|
"""Convert Windows line endings to Linux/Unix/MacOS line endings."""
|
|
if system == "Windows":
|
|
print("Converting supavisor pooler line endings...")
|
|
CR_LF = b'\r\n'
|
|
LF = b'\n'
|
|
file_path = "supabase/docker/volumes/pooler/pooler.exs"
|
|
with open(file_path, 'rb') as f:
|
|
content = f.read()
|
|
modified_content = content.replace(CR_LF, LF)
|
|
with open(file_path, 'wb') as f:
|
|
f.write(modified_content)
|
|
|
|
def docker_compose_include(supabase, filesystem, verbose):
|
|
"""Add or remove Supabase and Filesystem include compose.yml in docker-compose.yml"""
|
|
compose_file = "docker-compose.yml"
|
|
supabase_compose_file = "supabase/docker/docker-compose.yml"
|
|
filesystem_compose_file = "open-webui/tools/servers/filesystem/compose.yaml"
|
|
if not os.path.exists(compose_file):
|
|
print(f"Error: Docker Compose file '{compose_file}' not found - include skipped...")
|
|
return
|
|
if supabase and not os.path.exists(supabase_compose_file):
|
|
if verbose:
|
|
print(f"Warning: Include file '{supabase_compose_file}' not found.")
|
|
supabase = False
|
|
if filesystem and not os.path.exists(filesystem_compose_file):
|
|
if verbose:
|
|
print(f"Warning: Include file '{filesystem_compose_file}' not found.")
|
|
filesystem = False
|
|
if verbose:
|
|
supabase_ins = "add" if supabase else "remove"
|
|
filesystem_ins = "add" if filesystem else "remove"
|
|
print(f"Perform {supabase_ins} Supabase and {filesystem_ins} Filesystem "
|
|
f"'include:' in {compose_file}...")
|
|
include = supabase or filesystem
|
|
compose_include = "include:\n"
|
|
supabase_include = f" - ./{supabase_compose_file}\n"
|
|
filesystem_include = f" - ./{filesystem_compose_file}\n"
|
|
|
|
try:
|
|
with open(compose_file, 'r') as f:
|
|
content = f.read()
|
|
|
|
if include:
|
|
if compose_include in content:
|
|
content = content.replace(compose_include, "")
|
|
else:
|
|
if verbose:
|
|
print(f"Adding 'include:' element to {compose_file}...")
|
|
content = "\n" + content
|
|
elif compose_include in content and verbose:
|
|
if verbose:
|
|
print(f"Removing 'include:' element from {compose_file}...")
|
|
|
|
if supabase and supabase_include not in content:
|
|
if verbose:
|
|
print(f"Adding include file ./{supabase_compose_file}...")
|
|
content = supabase_include + content
|
|
elif not supabase and supabase_include in content:
|
|
if verbose:
|
|
print(f"Removing include file ./{supabase_compose_file}...")
|
|
content = content.replace(supabase_include, "")
|
|
|
|
if filesystem and filesystem_include not in content:
|
|
if verbose:
|
|
print(f"Adding include file ./{filesystem_compose_file}...")
|
|
content = filesystem_include + content
|
|
elif not filesystem and filesystem_include in content:
|
|
if verbose:
|
|
print(f"Removing include file ./{filesystem_compose_file}...")
|
|
content = content.replace(filesystem_include, "")
|
|
|
|
if include and not compose_include in content:
|
|
content = compose_include + content
|
|
elif not include and compose_include in content:
|
|
content = content.replace(compose_include + "\n", "")
|
|
|
|
with open(compose_file, 'w') as f:
|
|
f.write(content)
|
|
except Exception as e:
|
|
print(f"Exception: Set 'include:' in {compose_file}: {e}")
|
|
|
|
def normalize_path(path):
|
|
"""Normalize path for current platform"""
|
|
if path:
|
|
if path.startswith('~'):
|
|
path = os.path.expanduser(path)
|
|
elif path.strip() == '.':
|
|
path = os.getcwd()
|
|
path = os.path.abspath(path)
|
|
return path
|
|
|
|
def check_prerequisites():
|
|
"""Check if required tools are installed"""
|
|
required_tools = ['docker', 'git']
|
|
missing_tools = []
|
|
for tool in required_tools:
|
|
if shutil.which(tool) is None:
|
|
missing_tools.append(tool)
|
|
if missing_tools:
|
|
print(f"Missing required tools: {', '.join(missing_tools)}")
|
|
print("Install them before continuing.")
|
|
return False
|
|
try:
|
|
subprocess.run(
|
|
["docker", "info"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except subprocess.CalledProcessError:
|
|
print("Docker is not running. Start Docker Desktop.")
|
|
return False
|
|
return True
|
|
|
|
def get_dotenv_vars(env_file=None, force=False):
|
|
"""Load environment variables from .env file"""
|
|
if env_file is None:
|
|
env_file = os.path.join(".env")
|
|
if not os.path.exists(env_file):
|
|
if os.path.exists(".env.example"):
|
|
print("The .env file was not found - creating from .env.example template...")
|
|
shutil.copy('.env.example', '.env')
|
|
print("⚠️ IMPORTANT: Edit .env file with secure passwords and keys - exiting...")
|
|
else:
|
|
print("The .env file was not found - exiting...")
|
|
return {}
|
|
else:
|
|
with open(env_file, 'r') as f:
|
|
env_content = f.read()
|
|
default_secrets = [
|
|
'N8N_ENCRYPTION_KEY=change_me_to_a_long_super-secret-key',
|
|
'N8N_RUNNERS_AUTH_TOKEN=change_me_to_a_long_super-secret-key',
|
|
'N8N_USER_MANAGEMENT_JWT_SECRET=change_me_to_a_longer_even-more-secret',
|
|
'POSTGRES_PASSWORD=your-super-secret-postgres-password',
|
|
'JWT_SECRET=your-super-secret-jwt-token-with-at-least-40-characters-long',
|
|
'DASHBOARD_PASSWORD=your-super-secret-password-1',
|
|
'FLOWISE_PASSWORD=your-super-secret-postgres-password',
|
|
'NEO4J_AUTH=neo4j/your-super-secret-password-2',
|
|
'CLICKHOUSE_PASSWORD=your-super-secret-password-3',
|
|
'MINIO_ROOT_PASSWORD=your-super-secret-password-4',
|
|
'LANGFUSE_SALT=your-super-secret-key-1',
|
|
'NEXTAUTH_SECRET=your-super-secret-key-2',
|
|
'ENCRYPTION_KEY=your-super-secret-key-3']
|
|
unset_secrets = []
|
|
for secret in default_secrets:
|
|
if secret in env_content:
|
|
unset_secrets.append(secret)
|
|
if unset_secrets and not force:
|
|
print("ERROR: YOUR .env FILE CONTAINS DEFAULT VALUES THAT NEED TO BE CHANGED!")
|
|
for secret in unset_secrets:
|
|
print(f"⚠️ CHANGE THIS VALUE ⚠️: {secret}")
|
|
print("Exiting...")
|
|
return {}
|
|
|
|
env_vars = dotenv.dotenv_values(env_file)
|
|
path = env_vars['PROJECTS_PATH']
|
|
if not path:
|
|
path = os.path.join('~', 'projects')
|
|
env_vars['PROJECTS_PATH'] = normalize_path(path)
|
|
|
|
return env_vars
|
|
|
|
def load_dotenv_vars(env_vars):
|
|
"""Load working environment variables into environment"""
|
|
if env_vars:
|
|
print("Loading working environment variables...")
|
|
for env, var in env_vars.items():
|
|
if not var:
|
|
continue
|
|
os.environ[env] = var
|
|
else:
|
|
env_file = os.path.join(".env")
|
|
if not os.path.exists(env_file):
|
|
if not get_dotenv_vars():
|
|
sys.exit(1)
|
|
print("Loading .env environment variables...")
|
|
dotenv.load_dotenv(env_file)
|
|
|
|
def write_dotenv_file(env_file, env_vars):
|
|
"""Copy .env to .env in target compose file destination."""
|
|
if env_file is None:
|
|
print("Error: The .env file path to be written is not defined!")
|
|
return
|
|
if env_vars is None:
|
|
print("Error: The env_vars dictionary to be written is empty!")
|
|
return
|
|
print(f"Writing .env file to {env_file}...")
|
|
with open(env_file, 'w') as f:
|
|
now = " ".join(['on:', datetime.datetime.now().ctime()])
|
|
f.seek(0)
|
|
f.truncate()
|
|
f.write(f"# {now} - Generated {name} working .env environment variables.")
|
|
f.write('\n')
|
|
for env, var in env_vars.items():
|
|
if not var:
|
|
continue
|
|
quoted_var = var if var.isalnum() else f"'{var}'"
|
|
f.write("".join([env, '=', quoted_var, '\n']))
|
|
|
|
def set_dotenv_var(env_file, env, var, header):
|
|
"""Set or unset an environment variable and add optional header in .env file"""
|
|
if not env:
|
|
print("Error: A valid .env key was not specified.")
|
|
return
|
|
if env_file is None:
|
|
env_file = os.path.join(".env")
|
|
if not var:
|
|
try:
|
|
with open(env_file, 'r+') as f:
|
|
lines = f.readlines()
|
|
f.seek(0)
|
|
f.truncate()
|
|
for line in lines:
|
|
line_array = line.strip().split('=')
|
|
if len(line_array) > 1 and env == line_array[0].strip():
|
|
var_array = line_array[1].strip().split('#')
|
|
mod_line = line
|
|
if len(var_array) > 1 and var == var_array[0].strip():
|
|
mod_line = ''.join([env, '= #', var_array[1], '\n'])
|
|
elif len(var_array) == 1:
|
|
mod_line = ''.join([env, '=\n'])
|
|
line = mod_line
|
|
f.write("# Omitted '<value>' to return 'False' when queried")
|
|
print(f"Notice: Value for {env} removed...")
|
|
f.write(line)
|
|
except FileNotFoundError:
|
|
print(f"Exception: File '{env_file}' not found.")
|
|
return
|
|
quote_mode = "auto"
|
|
if header is not None:
|
|
with open(env_file, 'r') as f:
|
|
content = f.read()
|
|
if not header in content:
|
|
quote_mode = "never"
|
|
env = "".join([header, env])
|
|
print(f"Set '{env}' to {quote_mode}-quote '{var}' in {env_file}...")
|
|
dotenv.set_key(env_file, env, var, quote_mode)
|
|
|
|
def configure_n8n_database_settings(supabase):
|
|
"""Set n8n database depends_on and Postgres profiles and volume in Docker Compose file."""
|
|
compose_file = os.path.join("docker-compose.yml")
|
|
try:
|
|
with open(compose_file, 'r') as f:
|
|
content = f.read()
|
|
old_vol = "postgres_data" if supabase else "langfuse_postgres_data"
|
|
old_vol_regex = r"\b{}\b\:".format(old_vol)
|
|
if re.search(old_vol_regex, content):
|
|
new_vol = "langfuse_postgres_data:" if supabase else "postgres_data:"
|
|
print(f"Set Postgres volume: to '{new_vol}' from '{old_vol}' in {compose_file}...")
|
|
modified_content = re.sub(old_vol_regex, new_vol, content)
|
|
with open(compose_file, 'w') as f:
|
|
f.write(modified_content)
|
|
|
|
postgres_profiles = 'postgres:\n profiles: ["n8n", "langfuse", "n8n-all",'
|
|
supabase_profiles = 'postgres:\n profiles: ["langfuse",'
|
|
old_profiles = postgres_profiles if supabase else supabase_profiles
|
|
old_profiles_regex = re.escape(old_profiles)
|
|
if re.search(old_profiles_regex, content):
|
|
new_profiles = supabase_profiles if supabase else postgres_profiles
|
|
insert = "'langfuse'" if supabase else "'n8n' and 'langfuse'"
|
|
print(f"Set Postgres profiles: to include {insert} in {compose_file}...")
|
|
modified_content = re.sub(old_profiles_regex, new_profiles, content)
|
|
with open(compose_file, 'w') as f:
|
|
f.write(modified_content)
|
|
|
|
with open(compose_file, 'r+') as f:
|
|
n8n_updated = False
|
|
n8n_update = False
|
|
old_db = "postgres:" if supabase else "db:"
|
|
new_db = "db:" if supabase else "postgres:"
|
|
lines = f.readlines()
|
|
f.seek(0)
|
|
f.truncate()
|
|
for line in lines:
|
|
if not n8n_updated:
|
|
if line == ' n8n-import:\n':
|
|
n8n_update = True
|
|
if line == ' n8n-runner:\n':
|
|
n8n_updated = True
|
|
n8n_update = False
|
|
if n8n_update:
|
|
if line == f' {old_db}\n':
|
|
line = f" {new_db}\n"
|
|
if n8n_updated:
|
|
print(f"Set n8n database depends_on: to '{new_db}' "
|
|
f"from '{old_db}' in {compose_file}...")
|
|
f.write(line)
|
|
except Exception as e:
|
|
print(f"Exception: Update n8n database settings in {compose_file}: {e}")
|
|
|
|
def main():
|
|
# Name and file globals
|
|
global name, file
|
|
name = INFO.get('name', 'placeholder')
|
|
file = INFO.get('file', 'placeholder.py')
|
|
|
|
|
|
# Banner
|
|
print(f"""{name} version: {'.'.join(map(str, version))} LLM: {llama}""")
|
|
|
|
# Profile, environment and operation arguments
|
|
global open_webui_all_profiles
|
|
llama_host_profiles = ['ollama', 'llama.cpp']
|
|
ollama_docker_profiles = ['cpu', 'gpu-nvidia', 'gpu-amd']
|
|
llamacpp_docker_profiles = ['cpp-cpu', 'cpp-gpu-nvidia', 'cpp-gpu-amd']
|
|
llama_docker_profiles = ollama_docker_profiles + llamacpp_docker_profiles
|
|
n8n_profiles = ['n8n', 'n8n-all']
|
|
n8n_all_profiles = n8n_profiles + ['ai-all']
|
|
open_webui_utils_profiles = ['open-webui-mcpo', 'open-webui-pipe']
|
|
open_webui_profiles = ['open-webui', 'open-webui-all']
|
|
open_webui_all_profiles = open_webui_profiles + n8n_all_profiles
|
|
agent_all_profiles = open_webui_all_profiles + ['opencode']
|
|
server_profiles = ['supabase', 'flowise', 'searxng', 'langfuse', 'neo4j', 'caddy']
|
|
profiles = agent_all_profiles + open_webui_utils_profiles + server_profiles + \
|
|
llama_docker_profiles + llama_host_profiles
|
|
operations = ['stop', 'stop-llama', 'start', 'pause', 'unpause', 'update', 'install']
|
|
environments = ['private', 'public']
|
|
log_levels = ['OFF', 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']
|
|
parser = argparse.ArgumentParser(
|
|
prog=f'{file}',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
description=textwrap.dedent(f'''\
|
|
{INFO.get("description")}
|
|
|
|
With {file}, you can install, start, stop, pause, update or install
|
|
{name} with specified profile arguments (functional modules) and environment.
|
|
___________________________
|
|
|
|
Command syntax:
|
|
|
|
python {file} [--profile <arguments...>] [--environment <argument>] [--operation <argument>]
|
|
|
|
Example commands:
|
|
|
|
- Install functional modules n8n and opencode...
|
|
...with {llama} running on the Host:
|
|
python {file} --profile n8n opencode
|
|
|
|
...with Ollama CPU running in Docker:
|
|
python {file} --profile n8n opencode cpu
|
|
|
|
...with Llama.cpp AMD GPU in Docker and on production environment:
|
|
python {file} --profile n8n opencode cpp-gpu-amd --environment public
|
|
|
|
- Perform (stop, start, pause, unpause) operation...
|
|
...to stop n8n and opencode:
|
|
python {file} --profile n8n opencode --operation stop
|
|
|
|
...to stop n8n, opencode and {llama} running on the Host:
|
|
python {file} --profile n8n opencode --operation stop-llama
|
|
|
|
- Perform (install, update) operation...
|
|
...to update all modules and restart using Ollama running on the Host:
|
|
python {file} --operation update
|
|
|
|
...to update all modules and restart using Ollama CPU running in Docker:
|
|
python {file} --profile ai-all cpu --operation update
|
|
|
|
...to install all modules and start using Ollama running on the Host:
|
|
python {file} --operation install
|
|
|
|
...to install all modules and start using Llama.cpp Nvidia GPU running in Docker:
|
|
python {file} --profile ai-all cpp-gpu-nvidia --operation install
|
|
|
|
'''),
|
|
epilog=textwrap.dedent(f'''\
|
|
- Title: {INFO.get("title")}
|
|
- File: {INFO.get("file")}
|
|
- Author: {INFO.get("author")}
|
|
- Author URL: {INFO.get("author_url")}
|
|
- Repository: {INFO.get("repository")}
|
|
- Report Issues: {INFO.get("issues")}
|
|
- License: {INFO.get("license")}
|
|
- Copyright: {INFO.get("copyright")}
|
|
'''))
|
|
parser.add_argument('-p', '--profile', type=str.lower, nargs='+', choices=profiles,
|
|
help='Docker Compose Profile arguments for functional modules and llama'
|
|
f'CPU/GPU options (default: open-webui - with {llama} running on Host)')
|
|
parser.add_argument('-e', '--environment', type=str.lower, choices=environments, default='private',
|
|
help='Environment arguments used by Docker Compose to expose '
|
|
'or restrict network communication ports (default: private)')
|
|
parser.add_argument('-o', '--operation', type=str.lower, choices=operations,
|
|
help='Docker container, volumes and image management arguments along '
|
|
f'with argument to stop {llama} running on Host.')
|
|
parser.add_argument('-l', '--log', type=str.upper, choices=log_levels, default='INFO',
|
|
help='Enable stream (console) logging and set log level. File logging is always '
|
|
'enabled at DEBUG and is not affected by this argument (default: INFO)')
|
|
|
|
args = parser.parse_args()
|
|
# Detect operational status and current llama (Ollama/Llama.cpp) configuration
|
|
global llama, llama_cpp
|
|
status = None
|
|
llama_cpp = False
|
|
env_file = os.path.join(".env")
|
|
if os.path.exists('.operation'):
|
|
with open('.operation', 'r') as f:
|
|
op_array = f.readline().split(':')
|
|
status = op_array[0].strip() if op_array else None
|
|
if len(op_array) > 1:
|
|
llama_cpp = op_array[1].strip() == 'llama.cpp'
|
|
elif os.path.exists(env_file):
|
|
lpv = dotenv.get_key(env_file, 'LLAMA_PATH')
|
|
llama_cpp = lpv and os.path.basename(lpv).lower().startswith('llama-server')
|
|
llama = "Llama.cpp" if llama_cpp else "Ollama"
|
|
|
|
|
|
# Detect platform
|
|
global system
|
|
system = platform.system()
|
|
if system == "Windows":
|
|
print("""Detected Windows platform...""")
|
|
elif system == "Darwin": # macOS
|
|
print("""Detected macOS platform...""")
|
|
else: # Linux and other Unix-like systems
|
|
print("""Detected Linux/Unix platform...""")
|
|
|
|
# Check prerequisites
|
|
if not check_prerequisites():
|
|
sys.exit(1)
|
|
|
|
# Load working environment variables
|
|
mod_env_vars = {}
|
|
env_vars = get_dotenv_vars()
|
|
if not env_vars:
|
|
sys.exit(1)
|
|
|
|
# Detect default profile - no arguments specified
|
|
default_profile = False if args.profile else True
|
|
args.profile = [] if default_profile else args.profile
|
|
|
|
# Process llama (Ollama/Llama.cpp) status checks
|
|
llama_arg = "cpu"
|
|
llama_in_host = default_profile or not \
|
|
any(p for p in args.profile if p in llama_docker_profiles)
|
|
if llama_in_host:
|
|
global llama_found, llama_app, llama_exe, attempted_launch
|
|
attempted_launch = False
|
|
llama_found = False
|
|
llama_path = normalize_path(env_vars.get('LLAMA_PATH'))
|
|
llama_cpp = any(p for p in args.profile if p == 'llama.cpp')
|
|
llama = "Llama.cpp" if llama_cpp else "Ollama"
|
|
if llama_path:
|
|
llama_exe = os.path.normpath(llama_path)
|
|
llama_app = os.path.basename(llama_exe)
|
|
llama_found = os.path.exists(llama_exe)
|
|
if not llama_found:
|
|
llama_app = "llama-server" if llama_cpp else "ollama"
|
|
if system == "Windows":
|
|
llama_app = "".join([llama_app, '.exe'])
|
|
llama_dir = os.path.join("llama.cpp", "bin") if llama_cpp else "Ollama"
|
|
for llama_sub in ['~\\AppData\\Local\\Programs', os.getcwd()]:
|
|
llama_exe = normalize_path(os.path.join(llama_sub, llama_dir, llama_app))
|
|
if os.path.exists(llama_exe):
|
|
llama_found = True
|
|
break
|
|
else: # Unix-based systems (Linux, macOS)
|
|
for llama_path in ['/bin', '/usr/local/bin', '/usr/bin']:
|
|
llama_exe = os.path.join(llama_path, llama_app)
|
|
if os.path.exists(llama_exe):
|
|
llama_found = True
|
|
break
|
|
mod_env_vars.update({'LLAMA_PATH': llama_exe})
|
|
# Check if llama exe (llama-server, ollama) matches profile argument (llama.cpp, ollama)
|
|
if (llama_cpp and not llama_app.lower().startswith('llama-server')) or \
|
|
(not llama_cpp and not llama_app.lower().startswith('ollama')):
|
|
llama_cpp = llama_app.lower().startswith('llama-server')
|
|
llama = "Llama.cpp" if llama_cpp else "Ollama"
|
|
llama_mismatch = "ollama" if llama_cpp else "llama.cpp"
|
|
print(f"""Notice: The executable '{llama_app}' did not match the '{llama}'
|
|
profile argument - argument updated to '{llama.lower()}'...""")
|
|
args.profile.remove(llama_mismatch) if llama_mismatch in args.profile else None
|
|
args.profile.extend([llama.lower()]) if llama_cpp else None
|
|
llama_host = "host.docker.internal"
|
|
llama_host_var = llama_host if llama_cpp else llama_host + ":${OLLAMA_PORT}"
|
|
check_llama_process(args.operation, env_vars)
|
|
else:
|
|
llama_cpp = any(p for p in args.profile if p in llamacpp_docker_profiles)
|
|
llama = "Llama.cpp" if llama_cpp else "Ollama"
|
|
llama_host_var = "0.0.0.0" if llama_cpp else "ollama:${OLLAMA_PORT}"
|
|
# Check if more than one llama CPU/GPU argument specified, use first argument
|
|
if any(p for p in args.profile if p in llama_docker_profiles):
|
|
first_argument = False
|
|
for profile_arg in llama_docker_profiles:
|
|
if not first_argument:
|
|
if any(p for p in args.profile if p == profile_arg):
|
|
print(f"""{name} will use {llama} CPU/GPU profile argument '{profile_arg}'...""")
|
|
first_argument = True
|
|
else:
|
|
args.profile.remove(profile_arg) if profile_arg in args.profile else None
|
|
# Check if any llama host profile arguments specified and remove if found
|
|
for profile_arg in llama_host_profiles:
|
|
if any(p for p in args.profile if p == profile_arg):
|
|
print(f"""A llama in host profile argument cannot be specified with
|
|
llama CPU/GPU profile argument - removing '{profile_arg}'...""")
|
|
args.profile.remove(profile_arg)
|
|
# Assemble .env updates, set respective keys in .env file and reload .env vars
|
|
llama_host_env = "LLAMA_ARG_HOST" if llama_cpp else "OLLAMA_HOST"
|
|
oai_base_url_var = "${LLAMACPP_HOST}" if llama_cpp else "${OLLAMA_HOST}"
|
|
mod_env_vars.update({llama_host_env: llama_host_var, 'OPENAI_API_BASE_URL': oai_base_url_var})
|
|
for env, var in mod_env_vars.items():
|
|
set_dotenv_var(env_file, env, var, None)
|
|
env_vars = get_dotenv_vars(env_file, True)
|
|
|
|
# Process operation argument
|
|
build = False
|
|
if args.operation:
|
|
if args.operation == 'stop-llama':
|
|
args.operation = "stop"
|
|
if args.operation == status:
|
|
if status == 'stop':
|
|
insert = "Stopped"
|
|
else:
|
|
insert = "Paused" if status == 'pause' else "Started"
|
|
print(f"""{name} is already {insert} - exiting...""")
|
|
sys.exit(0)
|
|
elif args.operation == 'unpause' and not status == 'pause':
|
|
print(f"""{name} cannot unpause as it is not paused - exiting...""")
|
|
sys.exit(0)
|
|
if default_profile:
|
|
args.profile = ['ai-all']
|
|
build = args.operation in ['update', 'install']
|
|
if build:
|
|
install = args.operation == 'install'
|
|
if args.operation == 'update':
|
|
user_confirm = input(textwrap.dedent(f"""\
|
|
Performing an {name} update can impact its integrity.
|
|
Consider backing up your data to enable rollback.
|
|
[Type 'Got-It' to continue]: """))
|
|
if len(user_confirm) == 0 or user_confirm.lower() != 'got-it':
|
|
print(f"""Received [{user_confirm}].""") if user_confirm else None
|
|
print(f"""{name} update was not confirmed - exiting...""")
|
|
sys.exit(0)
|
|
args.operation = 'pull'
|
|
insert = "Installing" if install == 'install' else "Updating"
|
|
if default_profile:
|
|
print(f"""{insert} all container images including {llama}...""")
|
|
llama_arg = "cpp-cpu" if llama_cpp else "cpu"
|
|
args.profile.extend([llama_arg])
|
|
else:
|
|
print(f"""{insert} container images for {args.profile}...""")
|
|
docker_compose_include(True, True, False)
|
|
destroy_ai_suite(args.profile, install)
|
|
operate_ai_suite(args.operation, args.profile, args.environment, env_vars)
|
|
if not build:
|
|
sys.exit(0)
|
|
os.remove('.operation') if os.path.exists('.operation') else None
|
|
|
|
# Manually set default profile argument
|
|
if default_profile:
|
|
if build:
|
|
args.profile.remove(llama_arg) if llama_arg in args.profile else None
|
|
else:
|
|
args.profile = ['open-webui']
|
|
|
|
# Setup Supabase
|
|
if any(p for p in args.profile if p == 'supabase'):
|
|
if not any(p for p in args.profile if p in n8n_all_profiles):
|
|
print(f"""Profile argument 'supabase' requires argument in {n8n_all_profiles}
|
|
- removing 'supabase'...""")
|
|
args.profile.remove('supabase')
|
|
supabase = \
|
|
any(p for p in args.profile if p in ['supabase', 'ai-all'])
|
|
if supabase:
|
|
args.profile.remove('supabase') if 'supabase' in args.profile else None
|
|
clone_supabase_repo()
|
|
convert_supabase_pooler_line_endings()
|
|
|
|
if any(p for p in args.profile if p in n8n_all_profiles):
|
|
env_vars['POSTGRES_HOST'] = "db" if supabase else "postgres"
|
|
configure_n8n_database_settings(supabase)
|
|
|
|
if supabase:
|
|
prepare_supabase_env(env_vars)
|
|
elif 'langfuse' in args.profile:
|
|
print("""Profile argument 'langfuse' requires Supabase - removing 'langfuse'...'""")
|
|
args.profile.remove('langfuse')
|
|
|
|
# Generate SearXNG secret key and check docker-compose.yml
|
|
if any(p for p in args.profile if p in ['searxng', 'ai-all']):
|
|
generate_searxng_secret_key()
|
|
check_and_fix_docker_compose_for_searxng()
|
|
|
|
# Setup Open WebUI Functions and Tools Filesystem repos
|
|
open_webui = \
|
|
any(p for p in args.profile if p in open_webui_all_profiles)
|
|
if open_webui:
|
|
clone_open_webui_functions_repos()
|
|
clone_open_webui_tools_filesystem_repo()
|
|
prepare_open_webui_tools_filesystem_env(env_vars)
|
|
|
|
# Add or remove Supabase and Filesystem include compose.yml in docker-compose.yml
|
|
docker_compose_include(supabase, open_webui, True)
|
|
|
|
# Stop and remove AI-Suite containers
|
|
if not build:
|
|
destroy_ai_suite(args.profile, False)
|
|
|
|
# Load environment variables
|
|
load_dotenv_vars(env_vars)
|
|
|
|
# Start Supabase first
|
|
if supabase:
|
|
start_supabase(args.environment, build)
|
|
# Give Supabase some time to initialize
|
|
print("""Waiting for Supabase to initialize...""")
|
|
time.sleep(10)
|
|
|
|
# Start Open WebUI Tools Filesystem
|
|
if open_webui:
|
|
start_open_webui_tools_filesystem(args.environment, build)
|
|
time.sleep(1)
|
|
|
|
# Unset Compose ignore orphans variable
|
|
env = "COMPOSE_IGNORE_ORPHANS"
|
|
if env in os.environ:
|
|
del os.environ[env]
|
|
|
|
# Check if open-webui-mcpo specified with required profile arguments, else remove open-webui-mcpo
|
|
if any(p for p in args.profile if p == 'open-webui-mcpo'):
|
|
if not any(p for p in args.profile if p in open_webui_all_profiles):
|
|
print(f"""Profile argument 'open-webui-mcpo' requires argument in
|
|
{open_webui_all_profiles} - removing 'open-webui-mcpo'...""")
|
|
args.profile.remove('open-webui-mcpo')
|
|
|
|
# Check if open-webui-pipe specified with required profile arguments, else remove open-webui-pipe
|
|
if any(p for p in args.profile if p == 'open-webui-pipe'):
|
|
if not any(p for p in args.profile if p in open_webui_all_profiles):
|
|
print(f"""Profile argument 'open-webui-pipe' requires argument in
|
|
{open_webui_all_profiles} - removing 'open-webui-pipe'...""")
|
|
args.profile.remove('open-webui-pipe')
|
|
|
|
# Check if profile arguments n8n and open-webui specified, remove redundant open-webui
|
|
if any(p for p in args.profile if p == 'n8n'):
|
|
if any(p for p in args.profile if p == 'open-webui'):
|
|
print("""Profiles arguments 'n8n' and 'open-webui' detected - removing 'open-webui'...""")
|
|
args.profile.remove('open-webui')
|
|
|
|
# Check if more than one Ollama CPU/GPU argument specified, use first argument
|
|
if any(p for p in args.profile if p in ollama_docker_profiles):
|
|
first_argument = False
|
|
for profile_arg in ollama_docker_profiles:
|
|
if not first_argument:
|
|
if any(p for p in args.profile if p == profile_arg):
|
|
print(f"""{name} will use Ollama CPU/GPU profile argument '{profile_arg}'...""")
|
|
first_argument = True
|
|
else:
|
|
args.profile.remove(profile_arg) if profile_arg in args.profile else None
|
|
|
|
# Then start the AI-Suite services
|
|
start_ai_suite(args.profile, args.environment, build)
|
|
|
|
with open('.operation', 'w') as f:
|
|
f.write('start' + ':' + llama.lower())
|
|
|
|
if __name__ == "__main__":
|
|
main()
|