Simplify Athena stack and recovery

This commit is contained in:
Mikei386
2026-08-25 22:27:26 +02:00
parent c4851305d1
commit 069da8b4f0
70 changed files with 887 additions and 5806 deletions
@@ -29,7 +29,7 @@ class Filter:
"unraid": "server:mcp:mua-readonly-local",
"unraid_admin": "server:mcp:mua",
"navidrome": "server:mcp:navidrome-local",
"platform": "server:mcp:athena-platform",
"platform": "server:mcp:athena-operator-local",
"operator": "server:mcp:athena-operator-local",
"web": "server:mcp:web-general-local",
}
@@ -300,8 +300,7 @@ class Filter:
(
r"\bathena\b", r"\bmikeai\b", r"\bki[- ]host\b",
r"\bai[- ]profile[- ]router\b", r"\bprofil[- ]router\b",
r"\brecovery[- ](?:koffer|bundle|skript)\b",
r"\b(?:disaster|bare metal)[- ]recovery\b",
r"\b(?:backup|restore|wiederherstellung|neuinstallation)\b",
r"\b(?:installations?|reinstall|setup)[- ]skript\b",
r"\bplattform(?:wissen|dokumentation)?\b",
),
+35 -478
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env bash
# Synchronise OpenWebUI functions and MCPs from versioned sources.
set -Eeuo pipefail
umask 077
CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui}
VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data}
FILTER_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/filters" && pwd)
ACTION_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/actions" && pwd)
MUA_MCP_ENV_FILE=${MUA_MCP_ENV_FILE:-/etc/mike-ai/mua-mcp.env}
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
FILTER_DIR="$ROOT/platform/openwebui/filters"
ACTION_DIR="$ROOT/platform/openwebui/actions"
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
for file in reasoning_default_off.py thinking.py auto_tool_selector.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do
[[ -s $FILTER_DIR/$file ]] || die "Filterdatei fehlt: $file"
done
[[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt: quick_actions.py"
[[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt."
volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$VOLUME")
db=$volume_path/webui.db
@@ -24,136 +25,67 @@ if [[ $(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)
was_running=true
docker stop "$CONTAINER" >/dev/null
fi
restart_on_exit() {
if [[ $was_running == true ]]; then
docker start "$CONTAINER" >/dev/null 2>&1 || true
fi
}
restart_on_exit() { [[ $was_running == false ]] || docker start "$CONTAINER" >/dev/null 2>&1 || true; }
trap restart_on_exit EXIT
stamp=$(date +%Y%m%d-%H%M%S)
backup=$volume_path/webui.db.before-filter-install-$stamp
backup=$volume_path/webui.db.before-managed-sync-$stamp
cp -a "$db" "$backup"
rm -f "$volume_path/webui.db-wal" "$volume_path/webui.db-shm"
navidrome_enabled=false
[[ -s /etc/mike-ai/navidrome-mcp.env ]] && navidrome_enabled=true
deemix_enabled=false
[[ -s /etc/mike-ai/deemix-mcp.env ]] && deemix_enabled=true
github_enabled=false
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
github_enabled=true
fi
python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" "$navidrome_enabled" "$github_enabled" "$deemix_enabled" "$MUA_MCP_ENV_FILE" <<'PY'
python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" <<'PY'
import json
import copy
import pathlib
import sqlite3
import sys
import time
(
db, filter_dir, action_dir, requested_owner, navidrome_enabled_raw,
github_enabled_raw, deemix_enabled_raw, mua_mcp_env_file,
) = sys.argv[1:]
navidrome_enabled = navidrome_enabled_raw.lower() == "true"
github_enabled = github_enabled_raw.lower() == "true"
deemix_enabled = deemix_enabled_raw.lower() == "true"
db, filter_dir, action_dir, owner = sys.argv[1:]
con = sqlite3.connect(db)
columns = {row[1] for row in con.execute("pragma table_info(function)")}
required = {
"id", "user_id", "name", "type", "content", "meta", "valves",
"is_active", "is_global", "updated_at", "created_at",
}
required = {"id", "user_id", "name", "type", "content", "meta", "valves", "is_active", "is_global", "updated_at", "created_at"}
if not required <= columns:
raise SystemExit("Unbekanntes OpenWebUI-Function-Schema; keine Änderung vorgenommen.")
config_columns = {row[1] for row in con.execute("pragma table_info(config)")}
if not {"key", "value", "updated_at"} <= config_columns:
raise SystemExit("Unbekanntes OpenWebUI-Config-Schema; keine Änderung vorgenommen.")
owner = requested_owner
raise SystemExit("Unknown OpenWebUI function schema")
if not owner:
existing = con.execute(
"select user_id from function where id in (?, ?, ?, ?, ?, ?, ?, ?) order by id limit 1",
(
"reasoning_default_off", "thinking", "auto_tool_selector", "stability_guard",
"secret_redaction", "spoken_tool_status", "local_performance_metrics",
"quick_actions",
),
).fetchone()
existing = con.execute("select user_id from function where id='reasoning_default_off'").fetchone()
if existing:
owner = existing[0]
if not owner:
admins = con.execute("select id from user where role='admin'").fetchall()
if len(admins) != 1:
raise SystemExit(
"Filter-Eigentümer ist nicht eindeutig; OPENWEBUI_FILTER_OWNER_ID setzen."
)
raise SystemExit("OPENWEBUI_FILTER_OWNER_ID is not unambiguous")
owner = admins[0][0]
now = int(time.time())
functions = [
("reasoning_default_off", "Reasoning Default Off", "filter", 10, filter_dir, ""),
("thinking", "Thinking", "filter", 20, filter_dir, ""),
(
"auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir,
"Hält die allgemeine Websuche verfügbar und ergänzt automatisch alle fachlich passenden MCP-Domänen. "
"Die Auswahl ist Komfort und keine Schranke oder Freigabe für schreibende Aktionen.",
),
("auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir, "Keeps general web search available and selects relevant MCP domains."),
("stability_guard", "MikeAI Stability Guard", "filter", 30, filter_dir, ""),
("secret_redaction", "MikeAI Secret Redaction", "filter", 40, filter_dir, ""),
(
"spoken_tool_status", "MikeAI Spoken Tool Status", "filter", 80, filter_dir,
"Spielt bei aktiver automatischer Sprachausgabe einmalig eine kurze lokale Ansage, "
"sobald ein echtes Werkzeug gestartet wird.",
),
("spoken_tool_status", "MikeAI Spoken Tool Status", "filter", 80, filter_dir, "Plays one short local status phrase when a real tool starts."),
("local_performance_metrics", "MikeAI Local Performance Metrics", "filter", 90, filter_dir, ""),
(
"quick_actions", "MikeAI Quick Actions", "action", 100, action_dir,
"Lokale, geprüfte Aktionen für Zusammenfassung, Diagnose, Quellen und Markdown.",
),
("quick_actions", "MikeAI Quick Actions", "action", 100, action_dir, "Local actions for summaries, diagnosis, sources and Markdown."),
]
with con:
for function_id, name, function_type, priority, source_dir, description in functions:
content = pathlib.Path(source_dir, f"{function_id}.py").read_text()
for function_id, name, kind, priority, source_dir, description in functions:
content = pathlib.Path(source_dir, function_id + ".py").read_text()
con.execute(
"""
insert into function
(id,user_id,name,type,content,meta,valves,is_active,is_global,updated_at,created_at)
values (?,?,?,?,?,?,?,?,?,?,?)
on conflict(id) do update set
name=excluded.name,
type=excluded.type,
content=excluded.content,
meta=excluded.meta,
valves=excluded.valves,
is_active=excluded.is_active,
is_global=excluded.is_global,
updated_at=excluded.updated_at
""",
(
function_id, owner, name, function_type, content,
json.dumps({"description": description}),
json.dumps({"priority": priority}), True, True, now, now,
),
"""insert into function
(id,user_id,name,type,content,meta,valves,is_active,is_global,updated_at,created_at)
values (?,?,?,?,?,?,?,?,?,?,?)
on conflict(id) do update set name=excluded.name,type=excluded.type,
content=excluded.content,meta=excluded.meta,valves=excluded.valves,
is_active=excluded.is_active,is_global=excluded.is_global,updated_at=excluded.updated_at""",
(function_id, owner, name, kind, content, json.dumps({"description": description}),
json.dumps({"priority": priority}), True, True, now, now),
)
con.execute(
"""
insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set
value=excluded.value,
updated_at=excluded.updated_at
""",
("task.follow_up.enable", "false", now),
)
for key, value in (
("task.follow_up.enable", False),
("audio.tts.engine", "openai"),
("audio.tts.model", "piper"),
("audio.tts.voice", "alloy"),
("audio.tts.openai.api_base_url", "http://router:8081/v1"),
# Reproduce the working keyless native web search after a fresh install
# or database restore. No query or result content is stored here.
("web.search.enable", True),
("web.search.engine", "duckduckgo"),
("web.search.ddgs_backend", "duckduckgo"),
@@ -162,392 +94,17 @@ with con:
("web.search.confirmation.enable", False),
):
con.execute(
"""
insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set
value=excluded.value,
updated_at=excluded.updated_at
""",
"""insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set value=excluded.value,updated_at=excluded.updated_at""",
(key, json.dumps(value), now),
)
# Improve model-side tool selection without touching URLs, credentials,
# access grants or enable flags from a restored Open WebUI database.
row = con.execute(
"select value from config where key=?",
("tool_server.connections",),
).fetchone()
if row:
connections = json.loads(row[0])
if not isinstance(connections, list):
raise SystemExit("Unbekanntes Format in tool_server.connections.")
before_count = len(connections)
connections = [
connection for connection in connections
if not (
isinstance(connection, dict)
and (
str((connection.get("info") or {}).get("id", "")).lower()
in {"athena-terminal-local", "unraid-readonly-local", "web-local"}
or "mike-ai-mcp-athena-terminal" in str(connection.get("url", "")).lower()
or "mike-ai-mcp-unraid-official" in str(connection.get("url", "")).lower()
or "mike-ai-mcp-web" in str(connection.get("url", "")).lower()
)
)
]
descriptions = {
"web-general-local": (
"Allgemeines Web (TinySearch)",
"Breite, portable Websuche und Seitenabruf für beliebige öffentliche Websites. "
"In OpenWebUI ist native search_web/fetch_url standardmäßig verfügbar; dieser "
"Upstream-MCP ist die portable Alternative für Hermes, Pi und manuelle Nutzung. "
"Kurze, gezielte Resultate anfordern und niemals private Dateiinhalte senden.",
),
"homeassistant-local": (
"Home Assistant (lokal)",
"Für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards, "
"HA-Diagnose und freigegebene YAML-Dateien. YAML-Lesen ist begrenzt; Änderungen "
"benötigen serverseitige Vorschau, explizite Freigabe, Sicherung und Validierung. "
"Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche.",
),
"arr-local": (
"Sonarr und Radarr (lokal)",
"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über "
"konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen "
"Vorschau und Freigabe.",
),
"mua-readonly-local": (
"MUA · Unraid-Diagnose (read-only)",
"Automatisch verwendbarer, serverseitig in Open WebUI auf reine Lese- und "
"Diagnosewerkzeuge begrenzter MUA-Zugang. Für Containerbestand, Logs, System, "
"Storage, Shares, kompakte Datei-/Medieninventare und Netzwerkstatus. "
"Für Bibliotheksprüfungen unraid_files_inventory statt wiederholter "
"ls/find-Aufrufe verwenden. Keine Start/Stop-, Installations-, "
"Änderungs- oder freie Shell-Funktion. Bei einer ausdrücklich verlangten "
"Änderung stellt die automatische Auswahl zusätzlich MUA-Verwaltung bereit.",
),
"mua": (
"MUA (Unraid-Verwaltung)",
"Nur für vom Benutzer in der aktuellen Nachricht ausdrücklich verlangte "
"Unraid-Verwaltungsaktionen. Gemeinsam mit MUA read-only als geordnete "
"Kette verwenden: Zustand prüfen, engste Änderung ausführen, Ergebnis "
"read-only verifizieren. Für mehrere bestätigte Image-Updates immer den "
"gebündelten, zustandserhaltenden Updateablauf verwenden.",
),
"navidrome-local": (
"Navidrome (Musikbibliothek)",
"Nur für die persönliche Navidrome-Musikbibliothek: Titel, Alben, Künstler, "
"Playlists, Favoriten und Hörverlauf. Nicht für Sonarr/Radarr, allgemeine "
"Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs "
"nur bei Musikaufgaben aktivieren.",
),
"deemix-local": (
"Deemix (bestehende Unraid-Instanz)",
"Durchsucht Deezer über die vorhandene Deemix-Instanz auf Unraid und "
"verwaltet deren Download-Queue. Keine zweite Deemix-Instanz. Schreibende "
"Queue-Aktionen nur auf ausdrücklichen Benutzerauftrag; Status und Suche "
"sind read-only.",
),
"github-local": (
"GitHub Repository (offiziell, read-only)",
"Für Repository-Suche, echte Datei-Inhalte und gezielte "
"Code-Suche auf GitHub. Bei Fragen zu Implementierung, README, API-Routen oder "
"Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, "
"Pull Requests, Actions, rekursiven Komplettbäume oder Schreibzugriffe. Bei einem "
"konkreten Repository zuerst README beziehungsweise Wurzel einmal lesen, danach "
"höchstens drei gezielte Code-Suchen und nur relevante Trefferdateien öffnen.",
),
"athena-operator-local": (
"Athena Operator",
"Zentrale Bedienebene für Athenas KI-Plattform: MCPs entwickeln und deployen, "
"Docker-Dienste verwalten, Modelle laden und testen, Profile/OpenWebUI ändern, "
"prüfen, dokumentieren, versionieren und Recovery erzeugen. Enthält außerdem ein "
"breites, ausgabebegrenztes Terminal als Ausweg für neue Aufgaben einschließlich "
"Docker, Git, HTTP und SSH zu konfigurierten Zielsystemen. Stromversorgung sowie "
"Athenas SSH, LAN, WireGuard, Firewall, Boot, Kernel, Mounts und Partitionen bleiben "
"blockiert, damit der entfernte Host erreichbar bleibt.",
),
}
changed = len(connections) != before_count
for connection in connections:
if not isinstance(connection, dict):
continue
info = connection.get("info")
if not isinstance(info, dict):
info = {}
connection["info"] = info
identity = str(info.get("id", "")).lower()
url = str(connection.get("url", "")).lower()
if identity in descriptions:
match = identity
elif "192.168.1.2:3002" in url:
match = "mua"
elif "tinysearch:8000" in url:
match = "web-general-local"
elif "mike-ai-mcp-homeassistant" in url:
match = "homeassistant-local"
elif "mike-ai-mcp-arr" in url:
match = "arr-local"
elif "mike-ai-mcp-navidrome" in url:
match = "navidrome-local"
elif "mike-ai-mcp-github" in url:
match = "github-local"
elif "mike-ai-mcp-deemix" in url:
match = "deemix-local"
elif "mike-ai-mcp-athena-operator" in url:
match = "athena-operator-local"
else:
continue
name, description = descriptions[match]
if info.get("name") != name or info.get("description") != description:
info["name"] = name
info["description"] = description
changed = True
if match == "github-local":
bounded_config = dict(connection.get("config") or {})
bounded_config["enable"] = True
bounded_config["function_name_filter_list"] = (
"search_repositories,get_file_contents,search_code"
)
bounded_config.setdefault("access_grants", [])
if connection.get("config") != bounded_config:
connection["config"] = bounded_config
changed = True
# Clone the existing authenticated MUA connection into a second
# OpenWebUI connection whose exposed function list is strictly
# read-only. The bearer value remains in the database and is neither
# printed nor copied into Git. Automatic routing uses only this clone;
# the original MUA connection remains available for deliberate admin.
mua_source = next(
(
connection for connection in connections
if isinstance(connection, dict)
and str((connection.get("info") or {}).get("id", "")).lower() == "mua"
),
None,
)
if mua_source is None and pathlib.Path(mua_mcp_env_file).is_file():
mua_env = {}
for raw_line in pathlib.Path(mua_mcp_env_file).read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
mua_env[key.strip()] = value.strip().strip('"').strip("'")
mua_url = mua_env.get("MUA_MCP_URL", "")
mua_token = mua_env.get("MUA_MCP_BEARER_TOKEN", "")
if mua_url and mua_token:
name, description = descriptions["mua"]
mua_source = {
"url": mua_url,
"path": "",
"type": "mcp",
"auth_type": "bearer",
"headers": None,
"key": mua_token,
"config": {"enable": True, "access_grants": []},
"info": {"id": "mua", "name": name, "description": description},
}
connections.append(mua_source)
changed = True
if mua_source is not None:
readonly_functions = ",".join((
"unraid_docker_list", "unraid_docker_inspect", "unraid_docker_logs",
"unraid_docker_analyze_logs", "unraid_docker_processes",
"unraid_docker_stats", "unraid_docker_info",
"unraid_docker_update_status", "unraid_ca_search",
"unraid_network_inventory", "unraid_network_list",
"unraid_network_inspect", "unraid_network_host_state",
"unraid_network_audit_tcp", "unraid_network_lan_probe",
"unraid_system_health", "unraid_storage_status",
"unraid_disk_health", "unraid_notifications_list",
"unraid_shares_list", "unraid_share_inspect",
"unraid_files_inventory",
"unraid_system_connection_test", "unraid_system_shell_readonly",
))
readonly = copy.deepcopy(mua_source)
readonly["config"] = {
"enable": True,
"function_name_filter_list": readonly_functions,
"access_grants": [],
}
name, description = descriptions["mua-readonly-local"]
readonly["info"] = {
"id": "mua-readonly-local",
"name": name,
"description": description,
}
existing_index = next(
(
index for index, connection in enumerate(connections)
if isinstance(connection, dict)
and str((connection.get("info") or {}).get("id", "")).lower()
== "mua-readonly-local"
),
None,
)
if existing_index is None:
connections.append(readonly)
changed = True
elif connections[existing_index] != readonly:
connections[existing_index] = readonly
changed = True
if navidrome_enabled and not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-navidrome:3000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "navidrome-local"
)
for connection in connections
):
name, description = descriptions["navidrome-local"]
connections.append(
{
"url": "http://mike-ai-mcp-navidrome:3000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "navidrome-local",
"name": name,
"description": description,
},
}
)
changed = True
if deemix_enabled and not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-deemix:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "deemix-local"
)
for connection in connections
):
name, description = descriptions["deemix-local"]
connections.append(
{
"url": "http://mike-ai-mcp-deemix:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {"id": "deemix-local", "name": name, "description": description},
}
)
changed = True
if not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower() == "http://tinysearch:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "web-general-local"
)
for connection in connections
):
name, description = descriptions["web-general-local"]
connections.append(
{
"url": "http://tinysearch:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "web-general-local",
"name": name,
"description": description,
},
}
)
changed = True
if not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-athena-operator:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "athena-operator-local"
)
for connection in connections
):
name, description = descriptions["athena-operator-local"]
connections.append(
{
"url": "http://mike-ai-mcp-athena-operator:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "athena-operator-local",
"name": name,
"description": description,
},
}
)
changed = True
if github_enabled and not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-github:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "github-local"
)
for connection in connections
):
name, description = descriptions["github-local"]
connections.append(
{
"url": "http://mike-ai-mcp-github:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "github-local",
"name": name,
"description": description,
},
}
)
changed = True
if changed:
con.execute(
"""
insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set
value=excluded.value,
updated_at=excluded.updated_at
""",
(
"tool_server.connections",
json.dumps(connections, ensure_ascii=False),
now,
),
)
print(
"OpenWebUI konfiguriert: Default Off=10, Thinking=20, Auto Tool Selector=25, "
"Stability Guard=30, Secret Redaction=40, Spoken Tool Status=80, Local Metrics=90, "
"Quick Actions=100, Folgefragen=aus, Piper-TTS=aktiv, Werkzeugwahl=optimiert"
)
con.close()
PY
python3 "$ROOT/platform/mcp/sync-clients.py" \
--registry "$ROOT/config/mcp-registry.json" \
--openwebui-db "$db"
if [[ $was_running == true ]]; then
docker start "$CONTAINER" >/dev/null
deadline=$((SECONDS + 180))
@@ -557,4 +114,4 @@ if [[ $was_running == true ]]; then
done
fi
trap - EXIT
printf 'Datenbanksicherung: %s\n' "$backup"
printf 'OPENWEBUI_SYNC_OK backup=%s\n' "$backup"