mirror of
https://github.com/trevorsandy/ai-suite.git
synced 2026-09-22 17:45:15 +02:00
2246 lines
106 KiB
Python
2246 lines
106 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Trevor SANDY
|
|
Last Update February 12, 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
|
|
|
|
Portions of 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 getpass
|
|
import gzip
|
|
import logging
|
|
import pathlib
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import textwrap
|
|
import time
|
|
|
|
# Info attributes
|
|
INFO = {
|
|
"name" : "AI-Suite",
|
|
"version" : (0, 5, 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"
|
|
}
|
|
|
|
# Logging
|
|
class Formatter(logging.Formatter):
|
|
"""A class that formats colored logs using Select Graphic Rendition parameters."""
|
|
FORMATS = {
|
|
logging.NOTSET: "%(prefix)s%(msg)s%(suffix)s",
|
|
logging.DEBUG: "%(name)-8s: %(levelname)-8s %(lineno)d: %(prefix)s%(message)s%(suffix)s"
|
|
}
|
|
|
|
RED = 31
|
|
RED_BG = 41 # Red background (White foreground)
|
|
GREEN = 32
|
|
YELLOW = 33
|
|
BLUE = 34
|
|
MAGENTA = 35
|
|
CYAN = 36
|
|
WHITE = 37
|
|
COLOR = {'msg': WHITE, 'level': WHITE, 'name': BLUE}
|
|
LOG_LEVEL_COLOR = {
|
|
logging.CRITICAL: {'msg': RED_BG, 'level': RED_BG, 'name': BLUE} ,
|
|
logging.ERROR : {'msg': RED, 'level': RED, 'name': BLUE} ,
|
|
logging.WARNING : {'msg': YELLOW, 'level': YELLOW, 'name': BLUE} ,
|
|
logging.INFO : {'msg': CYAN, 'level': CYAN, 'name': BLUE} ,
|
|
logging.DEBUG : {'msg': WHITE, 'level': WHITE, 'name': BLUE}
|
|
}
|
|
|
|
def __init__(self, fmt: str):
|
|
super().__init__()
|
|
self.FORMATS[logging.NOTSET] = fmt
|
|
|
|
@staticmethod
|
|
def suffix():
|
|
"""Return Select Graphic Rendition parameters reset"""
|
|
return '\033[0m'
|
|
|
|
@staticmethod
|
|
def prefix(color, bright=False, bold=False, faint=False, italic=False, underline=False):
|
|
"""Return Select Graphic Rendition Control Sequence Introducer parameters"""
|
|
# Resolve format conflicts
|
|
faint = False if faint and bright or bold else faint
|
|
# Set CSI parameters
|
|
codes = []
|
|
color = color if isinstance(color, int) else 0 # black
|
|
if bright:
|
|
color += 60
|
|
if bold:
|
|
codes.append('1;')
|
|
if faint:
|
|
codes.append('2;')
|
|
if italic:
|
|
codes.append('3;')
|
|
if underline:
|
|
codes.append('4;')
|
|
codes.append(str(color))
|
|
rendition = ''.join(codes) if codes else color
|
|
return ('\033[{0}m').format(rendition)
|
|
|
|
@staticmethod
|
|
def style(level:int | None=None, color:int | None=None, **kwargs):
|
|
"""Return Select Graphic Rendition style parameters
|
|
kwargs (level): bright:bool, bold:bool, faint:bool, italic:bool, underline:bool,
|
|
emoji:str
|
|
kwargs (record): name_color:int, name_prefix:str, level_name_color:int,
|
|
level_name_prefix:str + kwargs (level)
|
|
kwargs (prefix): header_prefix:str, header:str, prefix:str, msg:str + kwargs (level)
|
|
"""
|
|
d = {'bright': False, 'bold': False, 'faint': False, 'italic': False, 'underline': False, 'emoji': '',
|
|
'name_color': None, 'name_prefix': '', 'level_name_color': None, 'level_name_prefix': '',
|
|
'header_prefix': '', 'header': '', 'header_suffix': '', 'msg_prefix': '', 'msg': ''}
|
|
d.update(kwargs)
|
|
bright, bold, faint, italic, underline, emoji, \
|
|
name_color, name_prefix, level_name_color, level_name_prefix, \
|
|
header_prefix, header, header_suffix, msg_prefix, msg = \
|
|
d['bright'], d['bold'], d['faint'], d['italic'], d['underline'], d['emoji'], \
|
|
d['name_color'], d['name_prefix'], d['level_name_color'], d['level_name_prefix'], \
|
|
d['header_prefix'], d['header'], d['header_suffix'], d['msg_prefix'], d['msg']
|
|
# Resolve format conflicts
|
|
faint = False if faint and bright or bold else faint
|
|
if isinstance(level, int) and level not in [10, 20, 30, 40, 50]:
|
|
color = level
|
|
level = logging.INFO
|
|
# Construct a SGR dictionary with the specified arguments
|
|
suffix = Formatter.suffix()
|
|
no_msg_prefix = header and not msg
|
|
emoji = "🐛" if not emoji and level == logging.DEBUG else emoji
|
|
emoji = emoji + " " if emoji else ""
|
|
level = logging.INFO if not level else level
|
|
name = True if name_prefix or name_color else False
|
|
level_name = True if level_name_prefix or level_name_color else False
|
|
if not color:
|
|
color = Formatter.LOG_LEVEL_COLOR.get(level, Formatter.COLOR)['msg']
|
|
if header:
|
|
if not header_prefix:
|
|
header_prefix = Formatter.prefix(color, bright, True, faint, italic, underline)
|
|
header_suffix = suffix + " " if not no_msg_prefix else ""
|
|
if not msg_prefix and not no_msg_prefix:
|
|
msg_prefix = Formatter.prefix(color, bright, bold, faint, italic, underline)
|
|
prefix = ("{0}{1}{2}{3}{4}{5}").format(
|
|
emoji, header_prefix, header, header_suffix, msg_prefix, msg)
|
|
style = {'prefix': prefix, 'suffix': suffix}
|
|
if name:
|
|
if not name_prefix:
|
|
name_prefix = Formatter.prefix(name_color, bright, bold, faint, True, underline)
|
|
style.update({'name_prefix': name_prefix})
|
|
if level_name:
|
|
if not level_name_prefix:
|
|
bold = True if level in [logging.ERROR, logging.CRITICAL, logging.DEBUG] else bold
|
|
underline = True if level in [logging.WARNING] else underline
|
|
level_name_prefix = Formatter.prefix(level_name_color, bright, bold, faint, italic, underline)
|
|
style.update({'level_name_prefix': level_name_prefix})
|
|
# These (msg\header) constitute the stream message so set purge_msg to purge
|
|
# the log file record msg\message when applying the stream format.
|
|
if msg or header:
|
|
style.update({'purge_msg': 'True'})
|
|
# Return the SGR dictionary
|
|
return style
|
|
|
|
def format(self, record):
|
|
"""Format log record attributes with color or emojie prefix, and reset suffix"""
|
|
# Save record
|
|
saved_record = record
|
|
# Get log level color dictionary
|
|
color = self.LOG_LEVEL_COLOR.get(record.levelno, self.COLOR)
|
|
# Get SGR reset parameter
|
|
suffix = self.suffix()
|
|
# Apply name attribute SGR parameters
|
|
name = record.name
|
|
name_prefix = None
|
|
if hasattr(record, 'name_prefix'):
|
|
name_prefix = getattr(record, 'name_prefix')
|
|
if not name_prefix:
|
|
name_prefix = self.prefix(color['name'], italic=True)
|
|
record.name = ('{0}{1}{2}').format(name_prefix, name, suffix)
|
|
# Apply level name attribute SGR parameters
|
|
levelname = record.levelname
|
|
levelname_prefix = None
|
|
if hasattr(record, 'level_name_prefix'):
|
|
levelname_prefix = getattr(record, 'level_name_prefix')
|
|
if not levelname_prefix:
|
|
bold = record.levelno in [logging.ERROR, logging.CRITICAL, logging.DEBUG]
|
|
underline = record.levelno in [logging.WARNING]
|
|
levelname_prefix = self.prefix(color['level'], bold=bold, underline=underline)
|
|
record.levelname = ('{0}{1}{2}').format(levelname_prefix, levelname, suffix)
|
|
# Apply msg attribute SGR parameters
|
|
if not hasattr(record, 'prefix'):
|
|
italic = record.levelno in [logging.CRITICAL, logging.DEBUG]
|
|
faint = record.levelno in [logging.INFO]
|
|
record.prefix = self.prefix(color['msg'], italic=italic, faint=faint)
|
|
if not hasattr(record, 'suffix'):
|
|
record.suffix = suffix
|
|
# When purge_msg is present, message\msg is designated for the log file.
|
|
# The stream message is in the prefix attribute, so purge message\msg.
|
|
if hasattr(record, 'purge_msg'):
|
|
record.message = ''
|
|
record.msg = ''
|
|
# Format record
|
|
format = self.FORMATS.get(record.levelno, self.FORMATS[logging.NOTSET])
|
|
formatter = logging.Formatter(format)
|
|
formatted = formatter.format(record)
|
|
# Restore record
|
|
record = saved_record
|
|
# Return formatted record
|
|
return formatted
|
|
|
|
# File logging
|
|
LFH = logging.FileHandler(f'{str(INFO.get("name")).lower()}.log', 'a', 'utf-8')
|
|
LFHF = logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s', '%m-%d %H:%M')
|
|
LFH.setFormatter(LFHF)
|
|
LFH.setLevel(logging.DEBUG)
|
|
|
|
# Stream (Console) logging
|
|
LSH = logging.StreamHandler()
|
|
LSHF = Formatter('%(name)-8s: %(levelname)-8s %(prefix)s%(message)s%(suffix)s')
|
|
LSH.setFormatter(LSHF)
|
|
LSH.setLevel(logging.NOTSET)
|
|
|
|
|
|
def run_command(cmd, cwd=None):
|
|
"""Run a shell command and print it."""
|
|
raw_msg = " ".join([log_run_cmd, " ".join(cmd)])
|
|
log.info(raw_msg, extra=LSHF.style(header=log_run_cmd, msg=" ".join(cmd)))
|
|
try:
|
|
completed = subprocess.run(cmd, cwd=cwd, check=True)
|
|
if completed.returncode != 0:
|
|
log.error(f"Command: {completed.stderr}")
|
|
except Exception as e:
|
|
log.error(f"Exception: {e}.")
|
|
|
|
def launch_llama_process(args, llama_log):
|
|
"""Launch Ollama/LLaMA.cpp server on the host"""
|
|
log_file = "".join(['>', llama_log, ' 2>&1'])
|
|
if system == "Windows":
|
|
win = "".join(['/c,"', llama_exe])
|
|
cmd = ['powershell', '-Command', 'Start-Process cmd -Args', win, args,
|
|
"".join([log_file, '"']), '-WindowStyle Hidden']
|
|
else: # Unix-based systems (Linux, macOS)
|
|
cmd = [llama_exe, args, log_file]
|
|
raw_msg = " ".join([log_run_cmd, " ".join(cmd)])
|
|
log.info(raw_msg, extra=LSHF.style(header=log_run_cmd, msg=" ".join(cmd)))
|
|
try:
|
|
completed = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
if completed.returncode != 0:
|
|
log.error(f"Command: {llama} process: {completed.stderr}")
|
|
except Exception as e:
|
|
log.error(f"Exception: {llama} process: {e} - assuming {llama} did not start.")
|
|
global attempted_launch
|
|
attempted_launch = True
|
|
log.info(f"Waiting for {llama} on host to initialize...", extra=log_bright)
|
|
wait_with_progress(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_DEFAULT_MODEL', '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:
|
|
log.info(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'
|
|
response = None
|
|
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:
|
|
log.info(f"Using {llama} model: {best_match}...")
|
|
return env_vars.get(llama_cpp_models[best_match_key])
|
|
else:
|
|
response = f"Unknown model '{model_name}', download model manually - Models:"
|
|
proceed = False
|
|
else: # User elected not to proceed
|
|
response = "Download the model manually and update your .env file - Models:"
|
|
if not proceed:
|
|
llama_server_args = env_vars.get('LLAMACPP_SERVER_ARGS')
|
|
if operation == 'install':
|
|
log.critical(response)
|
|
else:
|
|
log.info("Notice: " + response, extra=LSHF.style(header=log_notice, msg=response))
|
|
command_prefix = LSHF.prefix(LSHF.BLUE)
|
|
command_model_prefix = LSHF.prefix(LSHF.BLUE, bold=True)
|
|
llama_app_prefix = LSHF.prefix(LSHF.GREEN, bold=True)
|
|
llama_server_args_prefix = LSHF.prefix(LSHF.GREEN)
|
|
model_id_prefix = LSHF.prefix(LSHF.GREEN, italic=True)
|
|
suffix = LSHF.suffix()
|
|
for model_key, model_value in llama_cpp_models.items():
|
|
model = env_vars.get(model_key)
|
|
model_id = env_vars.get(model_value)
|
|
if not model_id:
|
|
continue
|
|
raw_msg = (
|
|
"{0} Command: {1} {2} {3}").format(
|
|
model, llama_app, llama_server_args, model_id)
|
|
emoji = '🔗' if model_id.startswith('https://') else '🚀'
|
|
model_command_prefix = (
|
|
"{0}•{1} "
|
|
"{2} "
|
|
"{3}{4}{5} "
|
|
"{6}command:{7}").format(
|
|
command_prefix, suffix, emoji,
|
|
command_model_prefix, model, suffix,
|
|
command_prefix, suffix)
|
|
model_prefix = (
|
|
"{0} "
|
|
"{1}{2}{3} "
|
|
"{4}{5}{6} "
|
|
"{7}{8}").format(
|
|
model_command_prefix,
|
|
llama_app_prefix, llama_app, suffix,
|
|
llama_server_args_prefix, llama_server_args, suffix,
|
|
model_id_prefix, model_id)
|
|
model_style = {'prefix': model_prefix, 'suffix': suffix}
|
|
model_style.update({'purge_msg': 'True'})
|
|
log.info(raw_msg, extra=model_style)
|
|
log.info("Exiting...", extra=log_bright)
|
|
return None
|
|
log.info(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:
|
|
log.info(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]
|
|
raw_msg = " ".join([log_run_cmd, " ".join(cmd)])
|
|
log.info(raw_msg, extra=LSHF.style(header=log_run_cmd, msg=" ".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:
|
|
log.error(f"Exception: {llama} process: {e} - assuming {llama} is not running.")
|
|
|
|
header = "See log for details:"
|
|
stop_llama = operation == 'stop-llama'
|
|
start_llama = not stop_llama and not operation in ['stop', 'pause']
|
|
llama_log_dir = "llama.cpp" if llama_cpp else ""
|
|
llama_log_file = os.path.join(os.getcwd(), llama_log_dir, 'llama_start.log')
|
|
|
|
if llama_running:
|
|
if stop_llama:
|
|
log.info(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"]
|
|
raw_msg = " ".join([log_run_cmd, " ".join(cmd)])
|
|
log.info(raw_msg, extra=LSHF.style(header=log_run_cmd, msg=" ".join(cmd)))
|
|
os.system(" ".join(cmd))
|
|
else:
|
|
if attempted_launch:
|
|
log.info(f"{llama} on host is now running...", extra=LSHF.style(color=LSHF.GREEN))
|
|
log.info(" ".join([header, llama_log_file]),
|
|
extra=LSHF.style(color=LSHF.GREEN, header=header, msg=llama_log_file))
|
|
else:
|
|
log.info(f"{llama} on host is running...", extra=LSHF.style(color=LSHF.GREEN))
|
|
else:
|
|
if attempted_launch:
|
|
log.critical(f"Failed to launch {llama} on host...")
|
|
log.critical("", extra=LSHF.style(color=LSHF.RED_BG, header=header, msg=llama_log_file))
|
|
log.critical("Exiting...")
|
|
sys.exit(1)
|
|
log.info(f"{llama} is not running...", extra=log_bright)
|
|
if start_llama:
|
|
if llama_found:
|
|
log.info(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:
|
|
log.error(f"Models directory {llama_models_dir} does not exist.")
|
|
if llama_server_args:
|
|
llama_args.extend([llama_server_args])
|
|
can_download = True if llama_hf_repo else False
|
|
llama_cpp_configured = can_download
|
|
if not llama_cpp_configured:
|
|
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])
|
|
llama_cpp_configured = True
|
|
if can_download and operation in ['install', 'update']:
|
|
log_insert = "llama_start.log" if llama_on_host else "Docker container log"
|
|
log.info(f"Configured to download {llama} model (this may take a while).")
|
|
log.info(f"Check {log_insert} for download progress...")
|
|
elif not llama_cpp_configured:
|
|
sys.exit(1)
|
|
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, llama_log_file)
|
|
else:
|
|
log.critical(f"The {llama_app} file was not found at {llama_exe}.")
|
|
log.critical(f"If {llama} is installed in a non-standard location, set the LLAMA_PATH")
|
|
log.critical(f"environment variable with its full path (including the {llama} file)")
|
|
log.critical(f"in the .env file and re-run {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"):
|
|
log.info("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:
|
|
log.info("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")
|
|
log.info("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")
|
|
log.info("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")
|
|
log.info("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:
|
|
log.info("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")
|
|
log.info("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:
|
|
log.info("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")
|
|
log.info(f"Writing {docker_compose_path}...")
|
|
try:
|
|
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
|
|
"""))
|
|
except FileNotFoundError:
|
|
log.error(f"Exception: File '{docker_compose_path}' not found.")
|
|
|
|
def prepare_opencode_config(env_vars):
|
|
"""Set the default OpenCode model in opencode.jsonc"""
|
|
file_path = os.path.join("opencode", "opencode.jsonc")
|
|
if not os.path.exists(file_path):
|
|
log.error(f"File {file_path} not found.")
|
|
return
|
|
log.info(f"Setting OpenCode {llama} model in {file_path}...")
|
|
llm = llama.strip().lower()
|
|
ollama_model = env_vars.get('OLLAMA_DEFAULT_MODEL')
|
|
llamacpp_model = env_vars.get('LLAMACPP_DEFAULT_MODEL')
|
|
old_model = f"{llm}/{ollama_model}" if llama_cpp else f"{llm}/{llamacpp_model}"
|
|
new_model = f"{llm}/{llamacpp_model}" if llama_cpp else f"{llm}/{ollama_model}"
|
|
try:
|
|
with open(file_path, 'rb') as f:
|
|
content = f.read()
|
|
modified_content = content.replace(old_model.encode(), new_model.encode())
|
|
with open(file_path, 'wb') as f:
|
|
f.write(modified_content)
|
|
except Exception as e:
|
|
log.error(f"Exception: Configuration - {llama} model: {e}")
|
|
|
|
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"
|
|
log.info(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)
|
|
log.info("="*60, extra=LSHF.style(logging.INFO, LSHF.BLUE))
|
|
log.info(f"{name} services 'down' completed.", extra=log_bright)
|
|
|
|
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)
|
|
log.info("Waiting for Supabase to initialize...", extra=log_bright)
|
|
wait_with_progress(10)
|
|
if open_webui:
|
|
start_open_webui_tools_filesystem(environment, False)
|
|
log.info("Waiting for Open WebUI Tool Filesystem to initialize...",
|
|
extra=log_bright)
|
|
wait_with_progress(2)
|
|
start_ai_suite(profile, environment, False)
|
|
display_service_endpoints(profile, supabase, env_vars)
|
|
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"
|
|
log.info(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)
|
|
insert = operation
|
|
if operation == 'pull':
|
|
insert.join(' and prune')
|
|
cmd = ["docker", "image", "prune", "--force"]
|
|
run_command(cmd)
|
|
log.info("="*60, extra=LSHF.style(logging.INFO, LSHF.BLUE))
|
|
log.info(f"{name} services image '{operation}' completed.", extra=log_bright)
|
|
|
|
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)."""
|
|
log.info("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)."""
|
|
log.info("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.
|
|
"""
|
|
log.info(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."""
|
|
log.info("Checking SearXNG settings...")
|
|
# Define paths for SearXNG settings file
|
|
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):
|
|
log.warning(f"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):
|
|
log.info(f"SearXNG settings.yml not found. Creating from {settings_base_path}...")
|
|
try:
|
|
shutil.copyfile(settings_base_path, settings_path)
|
|
log.info(f"Created {settings_path} from {settings_base_path}")
|
|
except Exception as e:
|
|
log.error(f"Exception: Create SearXNG settings.yml: {e}")
|
|
return
|
|
else:
|
|
log.info(f"SearXNG settings.yml already exists at {settings_path}")
|
|
log.info("Generating SearXNG secret key...")
|
|
# Run the appropriate platform command
|
|
try:
|
|
if system == "Windows":
|
|
log.info("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
|
|
log.info("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
|
|
log.info("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)
|
|
log.info("SearXNG secret key generated successfully.", extra=log_bright)
|
|
except Exception as e:
|
|
log.error(f"Exception: Generate SearXNG secret key: {e}.")
|
|
log.info("You may need to manually generate the secret key:", extra=log_bright)
|
|
if system == "Windows":
|
|
log.info(
|
|
"""- Windows (PowerShell):
|
|
$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""",
|
|
extra=log_bright)
|
|
elif system == "Darwin":
|
|
log.info(
|
|
"""- macOS:
|
|
sed -i '' "s|ultrasecretkey|$(openssl rand -hex 32)|g" searxng/settings.yml""", extra=log_bright)
|
|
else:
|
|
log.info(
|
|
"""- Linux:
|
|
sed -i "s|ultrasecretkey|$(openssl rand -hex 32)|g" searxng/settings.yml""", extra=log_bright)
|
|
|
|
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):
|
|
log.error(f"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)
|
|
log.info(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:
|
|
log.info("Found uwsgi.ini inside the SearXNG container - not first run")
|
|
is_first_run = False
|
|
else:
|
|
log.info("uwsgi.ini not found inside the SearXNG container - first run")
|
|
is_first_run = True
|
|
else:
|
|
raw_msg = "No running SearXNG container found - assuming first run"
|
|
log.info(" ".join([log_notice, raw_msg]), extra=LSHF.style(header=log_notice, msg=raw_msg))
|
|
is_first_run = True
|
|
except Exception as e:
|
|
log.error(f"Exception: Check Docker container running: {e} - assuming first run")
|
|
|
|
# Temporarily comment out the cap_drop line on first run
|
|
if is_first_run:
|
|
log.info("First run detected for SearXNG. Temporarily commenting 'cap_drop:' directive...")
|
|
with open(docker_compose_path, 'r+') as f:
|
|
lines = f.readlines()
|
|
f.seek(0)
|
|
f.truncate()
|
|
commented = False
|
|
searxng_found = False
|
|
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
|
|
log.info("SearXNG 'cap_drop:' directive temporarily commented...")
|
|
f.write(line)
|
|
raw_msg = "After the first run completes successfully, uncomment 'cap_drop:' " \
|
|
"in docker-compose.yml for security."
|
|
log.info(" ".join([log_notice, raw_msg]), extra=LSHF.style(header=log_notice, msg=raw_msg))
|
|
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:
|
|
log.info(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:
|
|
log.error(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":
|
|
file_path = "supabase/docker/volumes/pooler/pooler.exs"
|
|
if not os.path.exists(file_path):
|
|
log.error(f"Pooler file not found at {file_path}")
|
|
return
|
|
log.info("Converting supavisor pooler line endings...")
|
|
CR_LF = b'\r\n'
|
|
LF = b'\n'
|
|
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):
|
|
log.error(f"Docker Compose file '{compose_file}' not found - include skipped...")
|
|
return
|
|
if supabase and not os.path.exists(supabase_compose_file):
|
|
if verbose:
|
|
log.warning(f"Include file '{supabase_compose_file}' not found.")
|
|
supabase = False
|
|
if filesystem and not os.path.exists(filesystem_compose_file):
|
|
if verbose:
|
|
log.warning(f"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"
|
|
log.info(
|
|
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:
|
|
log.info(f"Adding 'include:' element to {compose_file}...")
|
|
content = "\n" + content
|
|
elif compose_include in content and verbose:
|
|
if verbose:
|
|
log.info(f"Removing 'include:' element from {compose_file}...")
|
|
|
|
if supabase and supabase_include not in content:
|
|
if verbose:
|
|
log.info(f"Adding include file ./{supabase_compose_file}...")
|
|
content = supabase_include + content
|
|
elif not supabase and supabase_include in content:
|
|
if verbose:
|
|
log.info(f"Removing include file ./{supabase_compose_file}...")
|
|
content = content.replace(supabase_include, "")
|
|
|
|
if filesystem and filesystem_include not in content:
|
|
if verbose:
|
|
log.info(f"Adding include file ./{filesystem_compose_file}...")
|
|
content = filesystem_include + content
|
|
elif not filesystem and filesystem_include in content:
|
|
if verbose:
|
|
log.info(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:
|
|
log.error(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:
|
|
log.critical("Missing required tools: [{}].".format(", ".join(missing_tools)))
|
|
log.critical("Install them before continuing...")
|
|
return False
|
|
try:
|
|
subprocess.run(
|
|
["docker", "info"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except subprocess.CalledProcessError:
|
|
log.critical("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"):
|
|
log.warning("The .env file was not found - creating from .env.example template...")
|
|
shutil.copy('.env.example', '.env')
|
|
log.critical("⚠️ IMPORTANT: Edit .env file with secure passwords and keys - exiting...")
|
|
else:
|
|
log.critical("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:
|
|
log.critical("YOUR .env FILE CONTAINS DEFAULT VALUES THAT NEED TO BE CHANGED!")
|
|
for secret in unset_secrets:
|
|
log.critical(f"⚠️ CHANGE THIS VALUE ⚠️: {secret}")
|
|
log.critical("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:
|
|
log.info("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)
|
|
log.info("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:
|
|
log.error("The .env file path to be written is not defined!")
|
|
return
|
|
if env_vars is None:
|
|
log.error("The env_vars dictionary to be written is empty!")
|
|
return
|
|
log.info(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:
|
|
log.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
|
|
raw_msg = f"Value for {env} removed..."
|
|
log.info(" ".join([log_notice, raw_msg]), extra=LSHF.style(header=log_notice, msg=raw_msg))
|
|
f.write(line)
|
|
except FileNotFoundError:
|
|
log.error(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])
|
|
log.info(f"Set '{env}' to '{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:"
|
|
log.info(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'"
|
|
log.info(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:
|
|
lines = f.readlines()
|
|
f.seek(0)
|
|
f.truncate()
|
|
n8n_updated = False
|
|
n8n_update = False
|
|
old_db = "postgres:" if supabase else "db:"
|
|
new_db = "db:" if supabase else "postgres:"
|
|
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:
|
|
log.info(f"Set n8n database depends_on: to '{new_db}' "
|
|
f"from '{old_db}' in {compose_file}...")
|
|
f.write(line)
|
|
except Exception as e:
|
|
log.error(f"Exception: Update n8n database settings in {compose_file}: {e}")
|
|
|
|
def docker_container_is_running(container):
|
|
""":return: True if container name found in output check, else False."""
|
|
cmd = " ".join(['docker', 'ps', '--format', '"{{.Names}}"', '--filter',
|
|
f'name=^/{container}$'])
|
|
try:
|
|
bytes = subprocess.check_output(cmd, shell=True)
|
|
running = bytes.find(container.encode()) != -1
|
|
if log.root.level == logging.DEBUG:
|
|
color = LSHF.WHITE if running else LSHF.RED
|
|
style = LSHF.style(logging.INFO, color)
|
|
insert = ('is', 'running.') if running else ('not', 'running!')
|
|
raw_msg = " ".join([container, insert[0], insert[1]])
|
|
log.debug("Container {}".format(raw_msg), extra=style)
|
|
return running
|
|
except subprocess.CalledProcessError:
|
|
return False
|
|
|
|
def docker_object_exists(object, name):
|
|
"""Confirm Docker volume or container exist"""
|
|
if object not in ['container', 'volume']:
|
|
log.error(f"Invalid object: {object}, expected container or volume.")
|
|
return False
|
|
if not name:
|
|
log.error(f"Object {object} name was not specified.")
|
|
return False
|
|
cmd = ['docker', object, 'inspect', '--format', '"{{ .Name }}"', name]
|
|
try:
|
|
stdout = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout
|
|
return stdout.find(name) != 1
|
|
except subprocess.CalledProcessError as e:
|
|
log.error(e.output.decode())
|
|
return False
|
|
|
|
def docker_volume_data(operation):
|
|
"""Backup or restore named volume mount data to or from backup file"""
|
|
if not operation:
|
|
operation = "backup-data"
|
|
elif not operation in ['backup-data', 'restore-data']:
|
|
log.error(f"Invalid operation: {operation}, expected backup-data or restore-data.")
|
|
return
|
|
|
|
data_volumes = [
|
|
('n8n_node_data', '/home/node/.n8n', 'n8n'),
|
|
('neo4j_data', '/data', 'neo4j'),
|
|
('neo4j_config_data', '/config', 'neo4j'),
|
|
('ollama_data', '/root/.ollama', 'ollama'),
|
|
('opencode_data', '/root/.config/opencode', 'opencode'),
|
|
('open_webui_data', '/app/backend/data', 'open-webui'),
|
|
('open_webui_pipelines_data', '/app/pipelines', 'open-webui-pipelines'),
|
|
('postgres_data', '/var/lib/postgresql/data', 'postgres'),
|
|
('qdrant_data', '/qdrant/storage', 'qdrqnt'),
|
|
('redis_valkey_data', '/data', 'redis'),
|
|
('langfuse_clickhouse_data', '/var/lib/clickhouse', 'clickhouse'),
|
|
('langfuse_minio_data', '/data', 'minio'),
|
|
('llamacpp_data', '/root/.cache', 'llamacpp'),
|
|
('caddy_data', '/data', 'caddy'),
|
|
('caddy_config_data', '/config', 'caddy'),
|
|
('db-config', '/etc/postgresql-custom', 'supabase-db')
|
|
]
|
|
restore_data = operation == 'restore-data'
|
|
backup_dir = os.path.join(os.getcwd(), "backup")
|
|
for volume, mount, container in data_volumes:
|
|
file_name = f"{volume}.tar.gz"
|
|
cmd = ["docker", "run", "--rm",
|
|
"--mount", f"source={volume},target={mount}",
|
|
"-v", f"{backup_dir}:/backup", container]
|
|
if restore_data:
|
|
backup_file = os.path.join(backup_dir, file_name)
|
|
if not os.path.exists(backup_file):
|
|
continue
|
|
with gzip.open(backup_file, "rb") as f:
|
|
data = f.read(1)
|
|
if len(data) == 0:
|
|
continue
|
|
cmd.extend(["tar", "-xzvf", f"/backup/{file_name}", "-C", "/"])
|
|
else: # backup data
|
|
if not docker_object_exists('volume', volume):
|
|
continue
|
|
cmd.extend(["tar", "-czvf", f"/backup/{file_name}", mount])
|
|
run_command(cmd)
|
|
|
|
def wait_with_progress(seconds: int, level=logging.INFO, color=None, width=60):
|
|
"""Progress bar for waiting on service to initialize"""
|
|
begin = time.monotonic()
|
|
end = LSHF.suffix()
|
|
colors = LSHF.LOG_LEVEL_COLOR.get(level, LSHF.COLOR)
|
|
level_name = logging.getLevelName(level)
|
|
level_color = colors['level']
|
|
name_color = colors['name']
|
|
fill_color = colors['msg'] if not color else color
|
|
header = ("{}{}{}{}:{} {}{}{} {}").format(
|
|
LSHF.prefix(color=name_color, italic=True), name, end,
|
|
LSHF.prefix(LSHF.WHITE), end,
|
|
LSHF.prefix(level_color), level_name, end, LSHF.prefix(fill_color))
|
|
while True:
|
|
elapsed = time.monotonic() - begin
|
|
progress = min(elapsed, seconds)
|
|
percent = progress / seconds
|
|
filled = int(width * percent)
|
|
bar = '█' * filled + '-' * (width - filled)
|
|
rendition = ("\r{}|{}| {:6.2f}%{}").format(header, bar, percent * 100, end)
|
|
print(rendition, end="", flush=True)
|
|
if elapsed >= seconds:
|
|
break
|
|
time.sleep(0.05) # smooth updates (~20 FPS)
|
|
print() # move to next line when done
|
|
|
|
def display_service_endpoints(profile, supabase, env_vars):
|
|
"""Display AI-Suite installation or operationstatus"""
|
|
url = 'http://localhost'
|
|
# This dictionary holds a list of module end point (Module name, End point) touples
|
|
ai_suite_modules = {
|
|
'n8n' : [('n8n', url + ':5678'),
|
|
('MCP Gateway', url + ':8060/'),
|
|
('QDrant', url + ':6333/dashboard'),
|
|
('Postgres', url + ':5432/'),
|
|
('Supabase', url + ':8000'),
|
|
('Logflare', url + ':4000/dashboard'),
|
|
('Redis', url + ':6379/')],
|
|
'n8n-all' : [('n8n', url + ':5678'),
|
|
('Open webUI', url + ':8080/'),
|
|
('Open webUI Filesystem', url + ':8091/docs'),
|
|
('MCP Gateway', url + ':8060/'),
|
|
('Open webUI MCPO', url + ':8090/'),
|
|
('QDrant', url + ':6333/dashboard'),
|
|
('Postgres', url + ':5432/'),
|
|
('Supabase', url + ':8000'),
|
|
('Logflare', url + ':4000/dashboard'),
|
|
('Redis', url + ':6379/')],
|
|
'opencode' : [('Opencode', './opencode/run_opencode_docker.py'),
|
|
('MCP Gateway', url + ':8060/')],
|
|
'open-webui' : [('Open webUI', url + ':8080/'),
|
|
('MCP Gateway', url + ':8060/'),
|
|
('Open webUI MCPO', url + ':8090/'),
|
|
('Open webUI Filesystem', url + ':8091/docs')],
|
|
'open-webui-mcpo' : [('MCP Gateway', url + ':8060/'),
|
|
('Open webUI MCPO', url + ':8090/')],
|
|
'open-webui-all' : [('Open webUI', url + ':8080/'),
|
|
('MCP Gateway', url + ':8060/'),
|
|
('Open webUI MCPO', url + ':8090/'),
|
|
('Open webUI Filesystem', url + ':8091/docs')],
|
|
'flowise' : [('Flowise', url + ':3001/')],
|
|
'supabase' : [('Supabase', url + ':8000'),
|
|
('Logflare', url + ':4000/dashboard')],
|
|
'langfuse' : [('Langfuse Web', url + ':3000/'),
|
|
('Langfuse Worker', url + ':3030/'),
|
|
('ClickHouse', url + ':8123/'),
|
|
('Postgres', url + ':5432/'),
|
|
('Redis', url + ':6379/'),
|
|
('MinIO', url + ':9001/')],
|
|
'searxng' : [('SearXNG', url + ':8081/')],
|
|
'neo4j' : [('Neo4j', url + ':7473/')],
|
|
'caddy' : [('Caddy', url + ':443/')],
|
|
'ollama' : [('Ollama', url + ':11434/')],
|
|
'cpu' : [('Ollama', url + ':11434/')],
|
|
'gpu-nvidia' : [('Ollama', url + ':11434/')],
|
|
'gpu-amd' : [('Ollama', url + ':11434/')],
|
|
'llama.cpp' : [('LLaMA.cpp' , url + ':8040')],
|
|
'cpp-cpu' : [('LLaMA.cpp' , url + ':8040')],
|
|
'cpp-gpu-nvidia' : [('LLaMA.cpp' , url + ':8040')],
|
|
'cpp-gpu-amd' : [('LLaMA.cpp' , url + ':8040')]
|
|
}
|
|
# This dictionary holds a list of container names enabled for said module
|
|
ai_suite_containers = {
|
|
'n8n' : ['n8n', 'n8n-runner', 'n8n-worker', 'n8n-worker-runner',
|
|
'mcp-gateway', 'qdrant', 'postgres', 'redis'],
|
|
'n8n-all' : ['n8n', 'n8n-runner', 'n8n-worker', 'n8n-worker-runner',
|
|
'mcp-gateway', 'open-webui', 'mcp-gateway', 'open-webui-mcpo',
|
|
'open-webui-filesystem', 'qdrant', 'postgres', 'redis'],
|
|
'opencode' : ['opencode', 'mpc-gateway'],
|
|
'open-webui' : ['open-webui', 'mcp-gateway', 'open-webui-mcpo',
|
|
'open-webui-filesystem'],
|
|
'open-webui-mcpo' : ['mpc-gateway', 'open-webui-mcpo'],
|
|
'open-webui-pipe' : ['open-webui-pipelines'],
|
|
'open-webui-all' : ['open-webui', 'mcp-gateway',
|
|
'open-webui-mcpo', 'open-webui-filesystem', 'open-webui-pipelines'],
|
|
'flowise' : ['flowise'],
|
|
'supabase' : ['supabase-studio', 'supabase-kong', 'supabase-auth',
|
|
'supabase-rest', 'realtime-dev.supabase-realtime',
|
|
'supabase-storage', 'supabase-imgproxy', 'supabase-meta',
|
|
'supabase-edge-functions', 'supabase-analytics', 'supabase-db',
|
|
'supabase-vector', 'supabase-pooler'],
|
|
'langfuse' : ['langfuse-worker', 'langfuse-web', 'clickhouse',
|
|
'postgres', 'minio', 'redis'],
|
|
'searxng' : ['searxng'],
|
|
'neo4j' : ['neo4j'],
|
|
'caddy' : ['caddy'],
|
|
'cpu' : ['ollama'],
|
|
'gpu-nvidia' : ['ollama'],
|
|
'gpu-amd' : ['ollama'],
|
|
'cpp-cpu' : ['llamacpp'],
|
|
'cpp-gpu-nvidia' : ['llamacpp'],
|
|
'cpp-gpu-amd' : ['llamacpp']
|
|
}
|
|
|
|
module_list = []
|
|
container_list = []
|
|
if profile:
|
|
m_name = 0
|
|
module_names = []
|
|
if 'ai-all' in profile:
|
|
for profile_arg, modules in ai_suite_modules.items():
|
|
if llama_cpp:
|
|
if profile_arg in ['ollama', 'cpu', 'gpu-nvidia', 'gpu-amd']:
|
|
continue
|
|
elif profile_arg in ['llama.cpp', 'cpp-cpu', 'cpp-gpu-nvidia', 'cpp-gpu-amd']:
|
|
continue
|
|
for module in modules:
|
|
module_name = module[m_name]
|
|
if module_name in module_names:
|
|
continue
|
|
if profile_arg in ['n8n', 'n8n-all']:
|
|
if supabase:
|
|
if module_name == 'Postgres':
|
|
continue
|
|
elif module_name in ['Supabase', 'Logflare']:
|
|
continue
|
|
module_names.append(module_name)
|
|
module_list.append(module)
|
|
for profile_arg, containers in ai_suite_containers.items():
|
|
if llama_cpp:
|
|
if profile_arg in ['cpu', 'gpu-nvidia', 'gpu-amd']:
|
|
continue
|
|
elif profile_arg in ['cpp-cpu', 'cpp-gpu-nvidia', 'cpp-gpu-amd']:
|
|
continue
|
|
for container in containers:
|
|
if not container or container in container_list:
|
|
continue
|
|
if supabase:
|
|
if profile_arg in ['n8n', 'n8n-all'] and container == 'postgres':
|
|
continue
|
|
elif container.startswith('supabase-') or container.startswith('realtime-dev.'):
|
|
continue
|
|
container_list.append(container)
|
|
else:
|
|
for profile_arg in profile:
|
|
modules = ai_suite_modules.get(profile_arg)
|
|
if modules:
|
|
for module in modules:
|
|
module_name = module[m_name]
|
|
if module_name in module_names:
|
|
continue
|
|
if profile_arg in ['n8n', 'n8n-all']:
|
|
if supabase:
|
|
if module_name == 'Postgres':
|
|
continue
|
|
elif module_name in ['Supabase', 'Logflare']:
|
|
continue
|
|
module_names.append(module_name)
|
|
module_list.append(module)
|
|
containers = ai_suite_containers.get(profile_arg)
|
|
if containers:
|
|
for container in containers:
|
|
if not container or container in container_list:
|
|
continue
|
|
if supabase:
|
|
if profile_arg in ['n8n', 'n8n-all'] and container == 'postgres':
|
|
continue
|
|
elif container.startswith('supabase-') or container.startswith('realtime-dev.'):
|
|
continue
|
|
container_list.append(container)
|
|
|
|
failed_container_list = []
|
|
for container in container_list:
|
|
if not docker_container_is_running(container):
|
|
failed_container_list.append(container)
|
|
|
|
started_ok = len(failed_container_list) == 0
|
|
header = ("{} IS RUNNING").format(name.upper()) if started_ok else \
|
|
("{} STARTUP ENCOUNTERED FAILURES").format(name.upper())
|
|
emoji = '✅' if started_ok else '⚠️'
|
|
color = LSHF.GREEN if started_ok else LSHF.YELLOW
|
|
header_style = {
|
|
'prefix': ("{0} {1}{2}").format(
|
|
emoji, LSHF.prefix(color, bold=True, underline=started_ok), header),
|
|
'suffix': LSHF.suffix()}
|
|
header_style.update({'purge_msg': 'True'})
|
|
info_style = LSHF.style(logging.INFO, color)
|
|
line_style = LSHF.style(logging.INFO, LSHF.BLUE)
|
|
fail_style = LSHF.style(logging.INFO, LSHF.RED)
|
|
module_prefix = LSHF.prefix(color)
|
|
apoint_prefix = LSHF.prefix(LSHF.BLUE)
|
|
|
|
context_size = env_vars.get('LLAMA_ARG_CTX_SIZE') if llama_cpp else \
|
|
env_vars.get('OLLAMA_CONTEXT_LENGTH')
|
|
model_name = env_vars.get('LLAMACPP_DEFAULT_MODEL') if llama_cpp else \
|
|
env_vars.get('OLLAMA_DEFAULT_MODEL')
|
|
projects_path = env_vars.get('PROJECTS_PATH')
|
|
|
|
log.info("")
|
|
log.info("="*60, extra=line_style)
|
|
log.info(header, extra=header_style)
|
|
log.info("="*60, extra=line_style)
|
|
log.info(f"LLM: {llama}", extra=info_style)
|
|
log.info(f"Model: {model_name}", extra=info_style)
|
|
log.info(f"Context Size: {context_size} tokens", extra=info_style)
|
|
log.info(f"Projects Path: {projects_path}", extra=info_style)
|
|
log.info("")
|
|
log.info("Access Points:", extra=info_style)
|
|
for module_name, access_point in module_list:
|
|
emoji = '🚀' if module_name.lower() == 'opencode' else '🔗'
|
|
endpoint_prefix = ("{}• {:23s}{}{} {}{}").format(
|
|
module_prefix, module_name + ":", LSHF.suffix(), emoji,
|
|
apoint_prefix, access_point)
|
|
endpoint_style = {'prefix': endpoint_prefix, 'suffix': LSHF.suffix()}
|
|
endpoint_style.update({'purge_msg':'True'})
|
|
raw_msg = ("• {:23s}{} {}").format(module_name + ":", emoji, access_point)
|
|
log.info(raw_msg, extra=endpoint_style)
|
|
if not started_ok:
|
|
log.info("")
|
|
log.info("These containers are not running:", extra=info_style)
|
|
for container in failed_container_list:
|
|
log.info("❌ {}".format(container), extra=fail_style)
|
|
log.info("="*60, extra=line_style)
|
|
|
|
def configure_ai_suite_access(env_vars:dict):
|
|
"""Setup env_vars for self-hosted AI-Suite with Caddy/Nginx proxy and Authelia
|
|
2FA identity and access management.
|
|
"""
|
|
ac_env_list = []
|
|
public = False
|
|
# AC env
|
|
log.info(f"Auto-configuring {name} access...")
|
|
env_vars.update({'AC': 'true'})
|
|
# AC_SUDO_USER env
|
|
default = env_vars.get('AC_SUDO_USER', getpass.getuser())
|
|
non_root = "WSL non-root" if system == 'Windows' else "non-root"
|
|
response = input(f"Enter a {non_root} sudo user (current user: {default}): ")
|
|
default = response if response else default
|
|
env_vars.update({'AC_SUDO_USER': default})
|
|
# AC_SUDO_PASSWORD env
|
|
password = getpass.getpass(f"Enter sudo password for package install: ")
|
|
if not password:
|
|
log.warning(f"A sudo password prompt will trigger on package install.")
|
|
ac_env_list.append("AC_SUDO_PASSWORD='{}'".format(password))
|
|
# AC_USERNAME env #
|
|
default = env_vars.get('AC_USERNAME', 'ai_suite_user')
|
|
response = input(f"Enter proxy user name (required: {default}): ")
|
|
default = response.strip() if response else default
|
|
env_vars.update({'AC_USERNAME': default})
|
|
# AC_PASSWORD env
|
|
password = getpass.getpass(f"Enter proxy user password (required): ")
|
|
if not password:
|
|
log.error(f"A proxy user password is required. Exiting auto-configure...")
|
|
return []
|
|
ac_env_list.append("AC_PASSWORD='{}'".format(password))
|
|
env_vars.update({'AC_PASSWORD': '*******'})
|
|
# AC_LOG_PATH
|
|
default = env_vars.get('AC_LOG_PATH', os.path.normpath('scripts'))
|
|
env_vars.update({'AC_LOG_PATH': default})
|
|
# AC_LOCAL env
|
|
default = env_vars.get('AC_LOCAL', 'false')
|
|
default = True if str(default).lower() == 'true' else False
|
|
response = input("Is this a local (private) installation? y/n ({}): "
|
|
.format('y' if default else 'n'))
|
|
default = True if response.lower() == 'y' else default
|
|
env_vars.update({'AC_LOCAL': str(default)})
|
|
public = not default
|
|
# AC_DOMAIN env
|
|
default = env_vars.get('AC_DOMAIN', 'ai-suite.fr' if public else 'local.com')
|
|
response = input(f"Enter a domain ({default}): ")
|
|
default = response.strip() if response else default
|
|
env_vars.update({'AC_DOMAIN': default})
|
|
# AC_CONFIRM env
|
|
default = env_vars.get('AC_CONFIRM', 'false')
|
|
default = True if str(default).lower() == 'true' else False
|
|
response = input("Send confirmation email on user registration? y/n ({}): "
|
|
.format('y' if default else 'n'))
|
|
default = True if response.lower() == 'y' else default
|
|
env_vars.update({'AC_CONFIRM': str(default)})
|
|
if default:
|
|
# AC_EXIM_SMTP env
|
|
default = env_vars.get('AC_EXIM_SMTP', 'false')
|
|
default = True if str(default).lower() == 'true' else False
|
|
#response = input("Add Exim SMTP server? y/n ({}): "
|
|
# .format('y' if default else 'n'))
|
|
default = True if response.lower() == 'y' else default
|
|
#env_vars.update({'AC_EXIM_SMTP': str(default)})
|
|
# AC_PROXY env
|
|
default = env_vars.get('AC_PROXY', 'Caddy')
|
|
response = input(f"Enter proxy (Caddy or Nginx: {default}): ")
|
|
if response.strip().lower() not in ['', 'caddy', 'nginx']:
|
|
log.warning(f"Invalid proxy specified: {response}. Using {default}.")
|
|
response = None
|
|
default = response.strip() if response else default
|
|
env_vars.update({'AC_PROXY': default.lower()})
|
|
# AC_WITH_AUTHELIA env
|
|
default = env_vars.get('AC_WITH_AUTHELIA', 'false')
|
|
default = True if str(default).lower() == 'true' else False
|
|
response = input("Include Authelia 2FA (Two Factor Authentication)? y/n ({}): "
|
|
.format('y' if default else 'n'))
|
|
with_authelia = True if response.lower() == 'y' else default
|
|
env_vars.update({'AC_WITH_AUTHELIA': str(with_authelia)})
|
|
if with_authelia:
|
|
# AC_EMAIL env
|
|
default = env_vars.get('AC_EMAIL', 'ai.suite.user@local.com')
|
|
response = input(f"Enter Authelia user email (required: {default}): ")
|
|
default = response.strip() if response else default
|
|
env_vars.update({'AC_EMAIL': default})
|
|
# AC_DISPLAY_NAME env
|
|
default = env_vars.get('AC_DISPLAY_NAME', f'{name} User')
|
|
response = input(f"Enter Authelia user display name (required: {default}): ")
|
|
default = response.strip() if response else default
|
|
env_vars.update({'AC_DISPLAY_NAME': default})
|
|
# AC_WITH_REDIS env
|
|
default = env_vars.get('AC_WITH_REDIS', ('true' if public else 'false'))
|
|
default = True if str(default).lower() == 'true' else False
|
|
response = input("Use Redis with Authelia? y/n ({}: {}): "
|
|
.format('recommended' if public else 'optional',
|
|
'y' if default else 'n'))
|
|
default = True if response.lower() == 'y' else default
|
|
env_vars.update({'AC_WITH_REDIS': str(default)})
|
|
# Generate Auto-configure Env list
|
|
ac_env_prefix = 'AC_'
|
|
ac_env_bool = ['AC', 'AC_LOCAL', 'AC_CONFIRM', 'AC_WITH_AUTHELIA', 'AC_WITH_REDIS']
|
|
for env, var in env_vars.items():
|
|
if not var or not env.startswith(ac_env_prefix):
|
|
continue
|
|
if env == 'AC_PASSWORD':
|
|
continue
|
|
if env in ac_env_bool:
|
|
var = str(var).lower()
|
|
ac_env_var = "{}='{}'".format(env, var)
|
|
ac_env_list.append(ac_env_var)
|
|
# Display Env settings
|
|
header = ("{} Auto-configure Access Env Settings").format(name)
|
|
emoji = '🚀'
|
|
color = LSHF.GREEN
|
|
header_style = {
|
|
'prefix': ("{0} {1}{2}").format(
|
|
emoji, LSHF.prefix(color, bold=True, underline=True), header),
|
|
'suffix': LSHF.suffix()}
|
|
header_style.update({'purge_msg': 'True'})
|
|
line_style = LSHF.style(logging.INFO, LSHF.BLUE)
|
|
env_prefix = LSHF.prefix(color)
|
|
var_prefix = LSHF.prefix(LSHF.BLUE)
|
|
|
|
log.info("")
|
|
log.info("="*60, extra=line_style)
|
|
log.info(header, extra=header_style)
|
|
log.info("="*60, extra=line_style)
|
|
|
|
for env, var in env_vars.items():
|
|
if not env.startswith(ac_env_prefix):
|
|
continue
|
|
emoji = '🌐'
|
|
env_var_prefix = ("{}• {} {:18s}{}={}'{}'").format(
|
|
env_prefix, emoji, env, LSHF.suffix(),
|
|
var_prefix, var)
|
|
env_var_style = {'prefix': env_var_prefix, 'suffix': LSHF.suffix()}
|
|
env_var_style.update({'purge_msg':'True'})
|
|
raw_msg = ("• {} {:18s}='{}'").format(emoji, env, var)
|
|
log.info(raw_msg, extra=env_var_style)
|
|
log.info("="*60, extra=line_style)
|
|
|
|
accept = True
|
|
response = input(f"Are these auto-configure settings ok to continue? y/n: (y)")
|
|
accept = True if response.lower() == 'y' else accept if not response else False
|
|
if accept:
|
|
return ac_env_list
|
|
else:
|
|
log.info(f"Auto-configure settings rejected.")
|
|
return []
|
|
|
|
def run_configure_ai_suite_access_command(ac_env_vars:list):
|
|
"""Configure self-hosted AI-Suite with Caddy/Nginx proxy and Authelia 2FA
|
|
identity and access management.
|
|
"""
|
|
if not ac_env_vars:
|
|
log.error(f"Auto-configure env_var list is empty!")
|
|
return
|
|
ac_script = os.path.normpath(os.path.join(".", "scripts", "auto_config.sh"))
|
|
if not os.path.exists(ac_script):
|
|
log.error(f"Auto-configure script not found at {ac_script}")
|
|
return
|
|
ac_log_path = os.path.normpath(os.path.dirname(ac_script))
|
|
if system == 'Windows':
|
|
ac_log_path = ac_log_path.replace("\\", "/")
|
|
ac_script = ac_script.replace("\\", "/")
|
|
ac_log = "".join(['>', os.path.join(ac_log_path, 'auto_config.log'), ' 2>&1'])
|
|
cmd = []
|
|
if system == "Windows":
|
|
cmd = ["wsl", "-e", "bash", "-c", "env"] + ac_env_vars + ["./" + ac_script]
|
|
else:
|
|
cmd = ["bash", "-c", "env"] + ac_env_vars + [ac_script]
|
|
cmd_msg = []
|
|
for element in cmd:
|
|
if element.split('=')[0].endswith('_PASSWORD'):
|
|
continue
|
|
cmd_msg.append(element)
|
|
raw_msg = " ".join([log_run_cmd, " ".join(cmd_msg)])
|
|
log.info(raw_msg, extra=LSHF.style(header=log_run_cmd, msg=" ".join(cmd_msg)))
|
|
try:
|
|
completed = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
if completed.returncode != 0:
|
|
log.error(f"Command: auto-configure: {completed.stderr}")
|
|
else:
|
|
info_style = LSHF.style(logging.INFO, LSHF.GREEN)
|
|
ac_log_file = os.path.join(ac_log_path, 'auto_config.sh.log')
|
|
log.info(f"See details in run log: {ac_log_file}", extra=info_style)
|
|
except Exception as e:
|
|
log.error(f"Exception: auto-configure: {e}.")
|
|
|
|
def main():
|
|
# Name and file globals
|
|
global name, file
|
|
name = INFO.get('name', 'placeholder')
|
|
file = INFO.get('file', 'placeholder.py')
|
|
|
|
# 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"
|
|
|
|
# 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 + ['no-auto-config']
|
|
managemant_operations = ['stop', 'stop-llama', 'start', 'pause', 'unpause']
|
|
installation_operations = ['update', 'install']
|
|
data_operations = ['backup-data', 'restore-data']
|
|
operations = managemant_operations + installation_operations + data_operations
|
|
environments = ['private', 'public']
|
|
log_levels = ['OFF', 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']
|
|
parser = argparse.ArgumentParser(
|
|
prog=f'{file}',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
usage=textwrap.dedent(f'''\
|
|
usage:
|
|
python PROG [options: help | profile environment operation log]
|
|
|
|
options:
|
|
- help:
|
|
python {file} -h, --help show this help message and exit
|
|
|
|
- profile:
|
|
python {file} -p, --profile <arguments...> specify {name} functional and LLM modules
|
|
|
|
- environment:
|
|
python {file} -e, --environment <argument> specify the type of deployment network
|
|
|
|
- operation:
|
|
python {file} -o, --operation <argument> perform {name} setup or management operations
|
|
|
|
- log:
|
|
python {file} -l, --log <argument>. enable, disable and specify logging levels
|
|
|
|
profile arguments:
|
|
- functional modules:
|
|
open-webui Open WebUI client
|
|
n8n n8n
|
|
opencode OpenCode
|
|
open-webui-mcpo open-webui-pipe Open WebUI pipelines, tools and functions
|
|
flowise Flowise
|
|
supabase Supabase database
|
|
searxng langfuse neo4j management, analytics and monitoring utilities
|
|
caddy Caddy network management
|
|
open-webui-all Open WebUI complete bundle
|
|
n8n-all n8n, Open WebUI and selected utilities bundle
|
|
ai-all full {name} bundle
|
|
no-auto-config do not auto-configure access on install or update
|
|
|
|
- LLM modules:
|
|
cpu gpu-nvidia gpu-amd Ollama CPU/GPU options running in Docker
|
|
cpp-cpu cpp-gpu-nvidia cpp-gpu-amd LLaMA.cpp CPU/GPU options rinning in Docker
|
|
ollama llama.cpp llama options running on the {name} Host
|
|
|
|
environment arguments:
|
|
private public self-hosted network options
|
|
|
|
operation arguments:
|
|
update install installation options
|
|
stop stop-llama start pause unpause operation options
|
|
backup-data restore-data volume mount data options
|
|
|
|
log arguments:
|
|
OFF CRITICAL ERROR WARNING INFO DEBUG console logging options
|
|
|
|
'''),
|
|
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>] [--log <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 update all modules but do not auto-configure access management:
|
|
python {file} --profile ai-all no-auto-config --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
|
|
|
|
- Perform (backup-data, restore-data) operation...
|
|
...to backup volume mount data to backup file:
|
|
python {file} --operation backup-data
|
|
'''),
|
|
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()
|
|
|
|
# Setup logging
|
|
global log, log_bright, log_run_cmd, log_notice
|
|
log_bright = None
|
|
log_run_cmd = "Running command:"
|
|
log_notice = "Notice:"
|
|
log_level = logging.NOTSET
|
|
log_handlers: list[logging.Handler] = [LFH]
|
|
if args.log != 'OFF':
|
|
log_level = getattr(logging, args.log, log_level)
|
|
LSH.setLevel(log_level)
|
|
log_handlers.extend([LSH])
|
|
log_bright = LSHF.style(logging.INFO, bright=True)
|
|
if args.operation == 'install':
|
|
open(f'{name.lower()}.log', 'w').close() if \
|
|
os.path.exists(f'{name.lower()}.log') else None
|
|
logging.basicConfig(handlers=log_handlers, level=log_level)
|
|
log = logging.getLogger(name)
|
|
|
|
# Name and version banner
|
|
version = INFO.get('version', (-1, -1, -1))
|
|
banner = f"""{name} version: {'.'.join(map(str, version))} LLM: {llama}"""
|
|
print(banner) if args.log == 'OFF' else None
|
|
log.info("="*60, extra=LSHF.style(color=LSHF.BLUE))
|
|
log.info(banner, extra=log_bright)
|
|
raw_msg = " ".join(["Command:", " ".join(sys.argv)])
|
|
log.info(raw_msg, extra=LSHF.style(header="Command:", msg=" ".join(sys.argv)))
|
|
log.info("="*60, extra=LSHF.style(color=LSHF.BLUE))
|
|
|
|
# Detect platform
|
|
global system
|
|
system = platform.system()
|
|
if system == "Windows":
|
|
log.info("Detected Windows platform...")
|
|
elif system == "Darwin": # macOS
|
|
log.info("Detected macOS platform...")
|
|
else: # Linux and other Unix-like systems
|
|
log.info("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"
|
|
conflicting_profile_arguments = []
|
|
global llama_on_host
|
|
llama_on_host = default_profile or not \
|
|
any(p for p in args.profile if p in llama_docker_profiles)
|
|
llama_host_env = "LLAMA_ARG_HOST" if llama_cpp else "OLLAMA_HOST"
|
|
if llama_on_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'))
|
|
if any(p for p in args.profile if p == 'llama.cpp'):
|
|
llama_cpp = True
|
|
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"
|
|
log.warning(f"The executable '{llama_app}' did not match the '{llama}' "
|
|
f"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
|
|
# Check if any llama CPU/GPU profile arguments specified and remove if found
|
|
for profile_arg in llama_docker_profiles:
|
|
if any(p for p in args.profile if p == profile_arg):
|
|
conflicting_profile_arguments.append(profile_arg)
|
|
if len(conflicting_profile_arguments):
|
|
log.warning(f"Profile arguments for {llama} CPU/GPU in Docker and {llama} "
|
|
"running on host cannot be specified together...")
|
|
for profile_arg in conflicting_profile_arguments:
|
|
log.warning(f"Removing '{profile_arg}'...")
|
|
args.profile.remove(profile_arg)
|
|
llama_host = "host.docker.internal"
|
|
llama_host_var = llama_host if llama_cpp else llama_host + ":${OLLAMA_PORT}"
|
|
mod_env_vars.update({llama_host_env: llama_host_var})
|
|
# Load llama environment variables when running llama on host
|
|
log.debug(f"Loading {llama} environment variables....")
|
|
llama_env_prefix = "LLAMA_ARG_" if llama_cpp else "OLLAMA_"
|
|
for env, var in env_vars.items():
|
|
if not var or not env.startswith(llama_env_prefix):
|
|
continue
|
|
log.debug(f" - {env}: {var}", extra=LSHF.style(logging.WARNING))
|
|
os.environ[env] = var
|
|
check_llama = not args.operation in data_operations
|
|
if check_llama:
|
|
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}"
|
|
mod_env_vars.update({llama_host_env: llama_host_var, 'LLAMA_PATH': None})
|
|
# 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):
|
|
log.info(f"{name} will use {llama} 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):
|
|
conflicting_profile_arguments.append(profile_arg)
|
|
if len(conflicting_profile_arguments):
|
|
log.warning(f"Profile arguments for {llama} running on host and {llama} "
|
|
"CPU/GPU in Docker cannot be specified together...")
|
|
for profile_arg in conflicting_profile_arguments:
|
|
log.warning(f"Removing '{profile_arg}'...")
|
|
args.profile.remove(profile_arg)
|
|
# Assemble .env updates, set respective keys in .env file and reload .env vars
|
|
oai_base_url_var = "${LLAMACPP_HOST}" if llama_cpp else "${OLLAMA_HOST}"
|
|
mod_env_vars.update({'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)
|
|
# Check .env interpolation
|
|
debug_style = LSHF.style(logging.WARNING)
|
|
log.debug("DotEnv dictionary updates:")
|
|
log.debug(f" - PROJECTS_PATH: {env_vars['PROJECTS_PATH']}", extra=debug_style)
|
|
log.debug("DotEnv file updates:")
|
|
for env in [llama_host_env, 'OPENAI_API_BASE_URL']:
|
|
log.debug(f" - {env}: {env_vars[env]}", extra=debug_style)
|
|
valid_llama_path = env_vars.get('LLAMA_PATH')
|
|
if valid_llama_path and valid_llama_path.strip():
|
|
log.debug(f" - LLAMA_PATH: {env_vars['LLAMA_PATH']}", extra=debug_style)
|
|
if llama_cpp:
|
|
log.debug(f"DotEnv {llama} file updates:")
|
|
for env in ['LLAMACPP_DEFAULT_MODEL', 'LLAMACPP_MODEL_PATH', 'LLAMA_ARG_HF_REPO']:
|
|
log.debug(f" - {env}: {env_vars[env]}", extra=debug_style)
|
|
|
|
# Auto-configuration
|
|
ac_env_vars = []
|
|
if any(p for p in args.profile if p == 'no-auto-config'):
|
|
ac_env_vars = configure_ai_suite_access(env_vars)
|
|
for env, var in env_vars.items():
|
|
if not env.startswith('AC_'):
|
|
continue
|
|
set_dotenv_var(env_file, env, var, None)
|
|
|
|
# Process operation argument
|
|
install = False
|
|
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"
|
|
log.info(f"{name} is already {insert} - exiting...")
|
|
sys.exit(0)
|
|
elif args.operation == 'unpause' and not status == 'pause':
|
|
log.info(f"{name} cannot unpause as it is not paused - exiting...")
|
|
sys.exit(0)
|
|
elif args.operation in ['backup-data', 'restore-data']:
|
|
docker_volume_data(args.operation)
|
|
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':
|
|
log.info(f"Received [{user_confirm}].") if user_confirm else None
|
|
log.info(f"{name} update was not confirmed - exiting...",
|
|
extra=LSHF.style(logging.INFO, 34, bold=True))
|
|
sys.exit(0)
|
|
args.operation = 'pull'
|
|
insert = "Installing" if install == 'install' else "Updating"
|
|
if default_profile:
|
|
log.info(f"{insert} all container images including {llama}...")
|
|
llama_arg = "cpp-cpu" if llama_cpp else "cpu"
|
|
args.profile.extend([llama_arg])
|
|
else:
|
|
log.info(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 build:
|
|
insert = "Installing" if install else "Updating"
|
|
log.info(f"{insert} '{name}' with profile arguments: {args.profile}...")
|
|
else:
|
|
sys.exit(0)
|
|
else:
|
|
raw_msg = f"Updating '{name}' with profile arguments: {args.profile}..."
|
|
log.info(" ".join([log_notice, raw_msg]), extra=LSHF.style(header=log_notice, msg=raw_msg))
|
|
|
|
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):
|
|
log.warning("Profile argument 'supabase' requires argument in "
|
|
f"{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 ac_env_vars:
|
|
run_configure_ai_suite_access_command(ac_env_vars)
|
|
|
|
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:
|
|
log.warning("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)
|
|
|
|
# Setup OpenCode default model in opencode.jsonc
|
|
opencode = \
|
|
any(p for p in args.profile if p in ['opencode', 'ai-all'])
|
|
if opencode:
|
|
prepare_opencode_config(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
|
|
log.info("Waiting for Supabase to initialize...", extra=log_bright)
|
|
wait_with_progress(10)
|
|
|
|
# Start Open WebUI Tools Filesystem
|
|
if open_webui:
|
|
start_open_webui_tools_filesystem(args.environment, build)
|
|
log.info("Waiting for Open WebUI Tool Filesystem to initialize...",
|
|
extra=log_bright)
|
|
wait_with_progress(2)
|
|
|
|
# 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):
|
|
log.warning("Profile argument 'open-webui-mcpo' requires argument in "
|
|
f"{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):
|
|
log.warning("Profile argument 'open-webui-pipe' requires argument in "
|
|
f"{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'):
|
|
log.info("Profile arguments 'n8n' and 'open-webui' detected "
|
|
"- removing 'open-webui'...")
|
|
args.profile.remove('open-webui')
|
|
|
|
# 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):
|
|
log.info(f"{name} will use {llama} 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)
|
|
display_service_endpoints(args.profile, supabase, env_vars)
|
|
|
|
with open('.operation', 'w') as f:
|
|
f.write('start' + ':' + llama.lower())
|
|
|
|
if __name__ == "__main__":
|
|
main()
|