Compare commits

3 Commits

Author SHA1 Message Date
b03592f8c1 Make optional RealESRGAN wheel prebuild opt-in
All checks were successful
Container Image / build-and-push (push) Successful in 3m37s
2026-07-15 22:21:08 +02:00
aa1293323e Mark WilkensXL build versions
Some checks failed
Container Image / build-and-push (push) Has been cancelled
2026-07-15 22:06:24 +02:00
caffdaa38c Add manifest-based addon manager 2026-07-08 14:36:41 +02:00
17 changed files with 994 additions and 27 deletions

View File

@@ -1,14 +1,3 @@
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale.
FROM python:3.14-slim AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM python:3.14-slim
# System deps. tmux is required by Cookbook for background downloads/serves.
@@ -81,14 +70,19 @@ RUN pip install --no-cache-dir -r requirements.txt \
# lib installed above; see the apt note near the top of this stage.
RUN pip install --no-cache-dir python-magic==0.4.27
# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
# pulled only when realesrgan is actually installed). With these dists already
# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
# wheels instead of rebuilding the sdists that fail on Python 3.14.
COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
&& rm -rf /tmp/odysseus-wheels
# Optional: pre-install patched basicsr/gfpgan/facexlib wheels for Real-ESRGAN.
# These are only needed by the optional Gallery/Cookbook upscaling path. Keep
# the default image build focused on core Odysseus so CI and Dockge updates do
# not block on this expensive optional build step.
ARG PREBUILD_REALESRGAN_WHEELS=false
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN if [ "${PREBUILD_REALESRGAN_WHEELS}" = "true" ]; then \
bash /usr/local/bin/build-realesrgan-wheels.sh /tmp/odysseus-wheels \
&& pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
&& rm -rf /tmp/odysseus-wheels; \
else \
echo "Skipping optional Real-ESRGAN wheel prebuild."; \
fi
# Copy app code
COPY . .

23
app.py
View File

@@ -661,6 +661,10 @@ app.include_router(setup_session_routes(session_manager, session_config, webhook
from routes.admin_wipe_routes import setup_admin_wipe_routes
app.include_router(setup_admin_wipe_routes(session_manager))
# Addons / manifest modloader
from routes.addon_routes import setup_addon_routes
app.include_router(setup_addon_routes(auth_manager))
# Memory
from routes.memory.memory_routes import setup_memory_routes
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)
@@ -920,8 +924,23 @@ async def serve_login(request: Request):
@app.get("/api/version")
async def get_version():
from core.constants import APP_VERSION
return {"version": APP_VERSION}
from core.constants import (
APP_VERSION,
BUILD_FLAVOR,
BUILD_REVISION,
BUILD_VERSION,
FULL_VERSION,
UPSTREAM_VERSION,
)
return {
"version": BUILD_VERSION,
"upstream_version": UPSTREAM_VERSION,
"app_version": APP_VERSION,
"build_flavor": BUILD_FLAVOR,
"build_revision": BUILD_REVISION,
"build_version": BUILD_VERSION,
"full_version": FULL_VERSION,
}
@app.get("/api/health")
async def health_check() -> Dict[str, str]:

48
docs/wilkensxl-build.md Normal file
View File

@@ -0,0 +1,48 @@
# WilkensXL build versioning
Odysseus keeps the upstream application version separate from the local
WilkensXL build version.
- `APP_VERSION` and `UPSTREAM_VERSION` track the upstream release.
- `BUILD_VERSION` and `FULL_VERSION` identify this custom image.
- `/api/version` returns both values so Dockge, logs, and the UI can show which
build is running.
The default custom build is:
```text
1.0.1-wilkensxl.2
```
The value can be overridden at runtime with:
- `ODYSSEUS_BUILD_FLAVOR`
- `ODYSSEUS_BUILD_REVISION`
- `ODYSSEUS_BUILD_VERSION`
## Update workflow
When upstream publishes a new Odysseus version:
1. Merge or rebase the upstream changes into the WilkensXL branch.
2. Keep the addon loader small and stable.
3. Put local behavior changes into addons wherever possible.
4. Run the addon, integration, readiness, and version metadata tests.
5. Bump `BUILD_REVISION` or set `ODYSSEUS_BUILD_VERSION` for the new image.
The loader does not remove all porting work, but it reduces it. Most local
behavior should live behind addon manifests, so future upstream updates only
need the loader compatibility checked plus any addons that touch changed
internals.
## Optional image extras
The Docker image skips the optional Real-ESRGAN wheel prebuild by default. That
keeps normal CI and Dockge updates from blocking on optional Gallery upscaling
dependencies.
To include the prebuilt Real-ESRGAN helper wheels in a custom image, build with:
```text
--build-arg PREBUILD_REALESRGAN_WHEELS=true
```

104
routes/addon_routes.py Normal file
View File

@@ -0,0 +1,104 @@
"""Admin API for Odysseus manifest addons."""
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from src.addons import (
addon_template,
delete_addon,
get_addon,
get_enabled_addon_contributions,
list_addons,
save_addon_manifest,
set_addon_enabled,
)
class AddonManifestRequest(BaseModel):
manifest: dict = Field(default_factory=dict)
class AddonEnabledRequest(BaseModel):
enabled: bool
def setup_addon_routes(auth_manager) -> APIRouter:
router = APIRouter(prefix="/api/addons", tags=["addons"])
def _require_admin(request: Request) -> str:
token = request.cookies.get("odysseus_session")
user = auth_manager.get_username_for_token(token)
if not user:
raise HTTPException(401, "Not authenticated")
if not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
return user
@router.get("")
async def list_all(request: Request):
_require_admin(request)
return {
"addons": list_addons(),
"enabled_contributions": get_enabled_addon_contributions(),
}
@router.get("/template")
async def get_template(request: Request, addon_id: str = "my-addon"):
_require_admin(request)
try:
return {"manifest": addon_template(addon_id)}
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("")
async def create_or_update(body: AddonManifestRequest, request: Request):
_require_admin(request)
try:
addon = save_addon_manifest(body.manifest)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "addon": addon}
@router.get("/{addon_id}")
async def get_one(addon_id: str, request: Request):
_require_admin(request)
try:
addon = get_addon(addon_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not addon:
raise HTTPException(404, "Addon not found")
return {"addon": addon}
@router.patch("/{addon_id}")
async def update_one(addon_id: str, body: AddonManifestRequest, request: Request):
_require_admin(request)
manifest = dict(body.manifest or {})
manifest["id"] = addon_id
try:
addon = save_addon_manifest(manifest)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "addon": addon}
@router.post("/{addon_id}/enabled")
async def set_enabled(addon_id: str, body: AddonEnabledRequest, request: Request):
_require_admin(request)
try:
addon = set_addon_enabled(addon_id, body.enabled)
except KeyError as exc:
raise HTTPException(404, "Addon not found") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "addon": addon}
@router.delete("/{addon_id}")
async def delete_one(addon_id: str, request: Request):
_require_admin(request)
try:
deleted = delete_addon(addon_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": deleted}
return router

280
src/addons.py Normal file
View File

@@ -0,0 +1,280 @@
"""Manifest-based Addon loader for Odysseus.
Addons are intentionally declarative in this first layer. They can describe
capabilities and contribute prompt/UI metadata, but Odysseus does not execute
arbitrary addon code automatically.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from src.constants import ADDONS_DIR, ADDONS_FILE
ADDON_SCHEMA_VERSION = 1
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{1,63}$")
_ALLOWED_CONTRIBUTIONS = {
"agent_prompt",
"tool_hint",
"settings_panel",
"ui_panel",
"api_proxy",
"skill_template",
}
def _atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def _safe_addon_id(value: Any) -> str:
raw = str(value or "").strip().lower()
if "/" in raw or "\\" in raw or ".." in raw:
raise ValueError("Addon id must not contain path separators")
addon_id = raw
addon_id = re.sub(r"[^a-z0-9_.-]+", "-", addon_id).strip(".-_")
if not addon_id:
addon_id = f"addon-{uuid.uuid4().hex[:8]}"
if not _ID_RE.match(addon_id):
raise ValueError("Addon id must be 2-64 chars and use a-z, 0-9, dot, dash, or underscore")
return addon_id
def _addon_dir(addon_id: str) -> Path:
safe_id = _safe_addon_id(addon_id)
base = Path(ADDONS_DIR).resolve()
path = (base / safe_id).resolve()
if base not in path.parents and path != base:
raise ValueError("Invalid addon path")
return path
def _default_manifest(addon_id: str) -> Dict[str, Any]:
return {
"schema_version": ADDON_SCHEMA_VERSION,
"id": addon_id,
"name": addon_id.replace("-", " ").replace("_", " ").title(),
"version": "0.1.0",
"description": "",
"author": "",
"enabled": False,
"entrypoints": {},
"contributions": [],
"permissions": [],
}
def _normalize_contribution(item: Any) -> Optional[Dict[str, Any]]:
if not isinstance(item, dict):
return None
kind = str(item.get("type") or item.get("kind") or "").strip()
if kind not in _ALLOWED_CONTRIBUTIONS:
return None
normalized = dict(item)
normalized["type"] = kind
if "id" in normalized:
normalized["id"] = str(normalized["id"])[:80]
return normalized
def normalize_manifest(manifest: Dict[str, Any], *, existing_enabled: Optional[bool] = None) -> Dict[str, Any]:
if not isinstance(manifest, dict):
raise ValueError("Addon manifest must be a JSON object")
if not manifest.get("id") and not manifest.get("name"):
raise ValueError("Addon manifest requires id or name")
addon_id = _safe_addon_id(manifest.get("id") or manifest.get("name"))
normalized = _default_manifest(addon_id)
normalized.update({
"schema_version": int(manifest.get("schema_version") or ADDON_SCHEMA_VERSION),
"id": addon_id,
"name": str(manifest.get("name") or normalized["name"])[:120],
"version": str(manifest.get("version") or "0.1.0")[:40],
"description": str(manifest.get("description") or "")[:1000],
"author": str(manifest.get("author") or "")[:120],
"enabled": bool(existing_enabled if existing_enabled is not None else manifest.get("enabled", False)),
"entrypoints": manifest.get("entrypoints") if isinstance(manifest.get("entrypoints"), dict) else {},
"permissions": manifest.get("permissions") if isinstance(manifest.get("permissions"), list) else [],
})
contributions = manifest.get("contributions") if isinstance(manifest.get("contributions"), list) else []
normalized["contributions"] = [
c for c in (_normalize_contribution(item) for item in contributions) if c is not None
]
return normalized
def _load_registry() -> Dict[str, Dict[str, Any]]:
if not os.path.exists(ADDONS_FILE):
return {}
try:
with open(ADDONS_FILE, "r", encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError):
return {}
items = raw.get("addons", raw) if isinstance(raw, dict) else raw
if not isinstance(items, list):
return {}
registry: Dict[str, Dict[str, Any]] = {}
for item in items:
try:
addon = normalize_manifest(item)
registry[addon["id"]] = addon
except Exception:
continue
return registry
def _save_registry(registry: Dict[str, Dict[str, Any]]) -> None:
_atomic_write_json(ADDONS_FILE, {"addons": list(registry.values())}, indent=2)
def scan_addons() -> List[Dict[str, Any]]:
"""Load addon manifests from disk and merge stored enabled state."""
os.makedirs(ADDONS_DIR, exist_ok=True)
registry = _load_registry()
changed = False
for child in sorted(Path(ADDONS_DIR).iterdir()):
if not child.is_dir():
continue
manifest_path = child / "addon.json"
if not manifest_path.exists():
continue
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
addon_id = _safe_addon_id(manifest.get("id") or child.name)
enabled = registry.get(addon_id, {}).get("enabled")
addon = normalize_manifest({**manifest, "id": addon_id}, existing_enabled=enabled)
addon["path"] = str(manifest_path)
registry[addon_id] = addon
changed = True
except Exception as exc:
registry[child.name] = {
**_default_manifest(child.name),
"name": child.name,
"enabled": False,
"error": str(exc),
"path": str(manifest_path),
}
changed = True
if changed:
_save_registry(registry)
return list(registry.values())
def list_addons() -> List[Dict[str, Any]]:
return sorted(scan_addons(), key=lambda item: item.get("name", "").lower())
def get_addon(addon_id: str) -> Optional[Dict[str, Any]]:
safe_id = _safe_addon_id(addon_id)
for item in list_addons():
if item.get("id") == safe_id:
return item
return None
def save_addon_manifest(manifest: Dict[str, Any]) -> Dict[str, Any]:
registry = _load_registry()
if not manifest.get("id") and not manifest.get("name"):
raise ValueError("Addon manifest requires id or name")
existing = registry.get(_safe_addon_id(manifest.get("id") or manifest.get("name")))
addon = normalize_manifest(manifest, existing_enabled=existing.get("enabled") if existing else None)
addon_dir = _addon_dir(addon["id"])
addon_dir.mkdir(parents=True, exist_ok=True)
manifest_path = addon_dir / "addon.json"
_atomic_write_json(str(manifest_path), addon, indent=2)
addon["path"] = str(manifest_path)
registry[addon["id"]] = addon
_save_registry(registry)
return addon
def set_addon_enabled(addon_id: str, enabled: bool) -> Dict[str, Any]:
registry = _load_registry()
addon = get_addon(addon_id)
if not addon:
raise KeyError(addon_id)
addon["enabled"] = bool(enabled)
registry[addon["id"]] = addon
_save_registry(registry)
manifest_path = _addon_dir(addon["id"]) / "addon.json"
if manifest_path.exists():
persisted = dict(addon)
persisted.pop("path", None)
_atomic_write_json(str(manifest_path), persisted, indent=2)
return addon
def delete_addon(addon_id: str) -> bool:
safe_id = _safe_addon_id(addon_id)
registry = _load_registry()
existed = safe_id in registry
registry.pop(safe_id, None)
_save_registry(registry)
addon_dir = _addon_dir(safe_id)
if addon_dir.exists():
shutil.rmtree(addon_dir)
existed = True
return existed
def addon_template(addon_id: str = "my-addon") -> Dict[str, Any]:
safe_id = _safe_addon_id(addon_id)
manifest = _default_manifest(safe_id)
manifest.update({
"name": safe_id.replace("-", " ").title(),
"description": "Describe what this addon changes or adds.",
"contributions": [
{
"type": "agent_prompt",
"id": f"{safe_id}.guidance",
"text": "Short, specific guidance the agent may use when this addon is enabled.",
},
{
"type": "settings_panel",
"id": f"{safe_id}.settings",
"title": "Addon Settings",
"description": "Describe configurable options for this addon.",
},
],
})
return manifest
def get_enabled_addon_contributions() -> List[Dict[str, Any]]:
contributions: List[Dict[str, Any]] = []
for addon in list_addons():
if not addon.get("enabled") or addon.get("error"):
continue
for contribution in addon.get("contributions", []):
item = dict(contribution)
item["addon_id"] = addon["id"]
item["addon_name"] = addon.get("name") or addon["id"]
contributions.append(item)
return contributions
def get_addons_prompt() -> str:
lines: List[str] = []
for item in get_enabled_addon_contributions():
if item.get("type") not in {"agent_prompt", "tool_hint", "skill_template"}:
continue
text = str(item.get("text") or item.get("description") or "").strip()
if not text:
continue
lines.append(f"- {item.get('addon_name')} ({item.get('type')}): {text[:1200]}")
if not lines:
return ""
return "Enabled Odysseus addons may contribute these untrusted instructions or hints:\n" + "\n".join(lines)

View File

@@ -2005,6 +2005,18 @@ def _build_system_prompt(
except Exception as _integ_err:
logger.debug(f"Integration prompt injection skipped: {_integ_err}")
try:
from src.addons import get_addons_prompt
_addons_prompt = get_addons_prompt()
if _addons_prompt:
_integ_message = untrusted_context_message(
"integrations and addons",
((_integ_message or {}).get("content", "") + "\n\n" + _addons_prompt).strip()
if _integ_message else _addons_prompt,
)
except Exception as _addon_err:
logger.debug(f"Addon prompt injection skipped: {_addon_err}")
# MCP tool descriptions — sourced from external servers, must not be in system role.
if mcp_mgr:
try:

View File

@@ -5,6 +5,14 @@ import os
from src.runtime_paths import get_app_root, get_default_data_dir
APP_VERSION = "1.0.1"
UPSTREAM_VERSION = APP_VERSION
BUILD_FLAVOR = os.getenv("ODYSSEUS_BUILD_FLAVOR", "wilkensxl")
BUILD_REVISION = os.getenv("ODYSSEUS_BUILD_REVISION", "2")
BUILD_VERSION = os.getenv(
"ODYSSEUS_BUILD_VERSION",
f"{APP_VERSION}-{BUILD_FLAVOR}.{BUILD_REVISION}" if BUILD_FLAVOR else APP_VERSION,
)
FULL_VERSION = BUILD_VERSION
# Base paths
BASE_DIR = os.path.join(get_app_root(), "")
@@ -27,6 +35,7 @@ AUTH_FILE = os.path.join(DATA_DIR, "auth.json")
USER_PREFS_FILE = os.path.join(DATA_DIR, "user_prefs.json")
PRESETS_FILE = os.path.join(DATA_DIR, "presets.json")
INTEGRATIONS_FILE = os.path.join(DATA_DIR, "integrations.json")
ADDONS_FILE = os.path.join(DATA_DIR, "addons.json")
CONTACTS_FILE = os.path.join(DATA_DIR, "contacts.json")
APP_KEY_FILE = os.path.join(DATA_DIR, ".app_key")
EMBEDDING_ENDPOINT_FILE = os.path.join(DATA_DIR, "embedding_endpoint.json")
@@ -51,6 +60,7 @@ GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images")
TTS_CACHE_DIR = os.path.join(DATA_DIR, "tts_cache")
EMAIL_URGENCY_CACHE_DIR = os.path.join(DATA_DIR, "email_urgency_cache")
SKILLS_DIR = os.path.join(DATA_DIR, "skills")
ADDONS_DIR = os.path.join(DATA_DIR, "addons")
GALLERY_DIR = os.path.join(DATA_DIR, "gallery")
GALLERY_UPLOADS_DIR = os.path.join(DATA_DIR, "gallery_uploads")
MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors")

View File

@@ -19,7 +19,13 @@ def check_readiness() -> Dict[str, object]:
``local_first`` is informational — a remote database is a valid deployment, so
it never fails readiness, it only reports whether storage stays on this host.
"""
from core.constants import APP_VERSION, DATA_DIR
from core.constants import (
APP_VERSION,
BUILD_FLAVOR,
BUILD_VERSION,
DATA_DIR,
UPSTREAM_VERSION,
)
from core.database import DATABASE_URL, engine
from sqlalchemy import text as sql_text
@@ -55,7 +61,11 @@ def check_readiness() -> Dict[str, object]:
ready = all(bool(c.get("ok")) for c in checks.values())
return {
"ready": ready,
"version": APP_VERSION,
"version": BUILD_VERSION,
"upstream_version": UPSTREAM_VERSION,
"app_version": APP_VERSION,
"build_flavor": BUILD_FLAVOR,
"build_version": BUILD_VERSION,
"checks": checks,
"timestamp": datetime.utcnow().isoformat(),
}

View File

@@ -994,7 +994,10 @@
el.textContent = 'Type /setup, then choose Local models or API.';
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
var fullVersion = d.full_version || d.build_version || d.version;
if (fullVersion) window._appVersion = fullVersion;
if (d.upstream_version) window._upstreamVersion = d.upstream_version;
if (d.build_flavor) window._buildFlavor = d.build_flavor;
}).catch(function(){});
})();
</script>
@@ -1405,6 +1408,10 @@
</button>
<div class="settings-sidebar-divider admin-only"></div>
<div class="settings-sidebar-label admin-only">Admin</div>
<button class="settings-nav-item admin-only" data-settings-tab="addons">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3 6 6 .9-4.5 4.4 1.1 6.2L12 16.6 6.4 19.5l1.1-6.2L3 8.9 9 8z"/></svg>
<span>Addons</span>
</button>
<button class="settings-nav-item admin-only" data-settings-tab="tools">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<span>Agent Tools</span>
@@ -2262,6 +2269,35 @@
</div>
<!-- ═══ TOOLS TAB ═══ -->
<!-- ADDONS TAB -->
<div data-settings-panel="addons" class="hidden">
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M12 2l3 6 6 .9-4.5 4.4 1.1 6.2L12 16.6 6.4 19.5l1.1-6.2L3 8.9 9 8z"/></svg>
Addons
<span style="flex:1"></span>
<button class="admin-btn-sm" id="addons-refresh-btn" title="Rescan addons">Refresh</button>
</h2>
<div class="admin-toggle-sub" style="margin-bottom:12px">Manifest-based extensions loaded from <code>data/addons/&lt;id&gt;/addon.json</code>. Addons are declarative by default; Odysseus does not execute arbitrary addon code automatically.</div>
<div id="addons-list" class="addons-list"><div class="admin-empty">Loading addons...</div></div>
</div>
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/></svg>
Addon Builder
</h2>
<div class="admin-toggle-sub" style="margin-bottom:10px">Create or edit an addon manifest. This is the stable contract future addon code can build on.</div>
<div class="addon-builder-row">
<input id="addon-template-id" class="settings-select" type="text" value="my-addon" aria-label="Addon id" />
<button class="admin-btn-add" id="addon-template-btn">Load Template</button>
<button class="admin-btn-add" id="addon-save-btn">Save Addon</button>
<button class="admin-btn-sm" id="addon-clear-btn">Clear</button>
</div>
<textarea id="addon-manifest-editor" class="addon-manifest-editor" spellcheck="false" placeholder="{&#10; &quot;id&quot;: &quot;my-addon&quot;,&#10; &quot;name&quot;: &quot;My Addon&quot;&#10;}"></textarea>
<div id="addons-msg" class="addon-msg"></div>
</div>
</div>
<div data-settings-panel="tools" class="hidden">
<div class="admin-card" style="margin-bottom:12px;">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Agent</h2>

198
static/js/addons.js Normal file
View File

@@ -0,0 +1,198 @@
// static/js/addons.js - Addon manager for manifest-based Odysseus addons
import uiModule from './ui.js';
let initialized = false;
let currentManifest = null;
function el(id) { return document.getElementById(id); }
function esc(value) {
return uiModule && typeof uiModule.esc === 'function'
? uiModule.esc(String(value ?? ''))
: String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function api(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
return data;
}
function contributionSummary(addon) {
const items = Array.isArray(addon.contributions) ? addon.contributions : [];
if (!items.length) return '<span class="addon-muted">No contributions</span>';
const counts = new Map();
items.forEach(item => counts.set(item.type || 'unknown', (counts.get(item.type || 'unknown') || 0) + 1));
return Array.from(counts.entries())
.map(([type, count]) => `<span class="addon-chip">${esc(type)}${count > 1 ? ` x${count}` : ''}</span>`)
.join('');
}
function renderAddons(addons) {
const list = el('addons-list');
if (!list) return;
if (!addons.length) {
list.innerHTML = '<div class="admin-empty">No addons installed yet.</div>';
return;
}
list.innerHTML = addons.map(addon => `
<div class="addon-card" data-addon-id="${esc(addon.id)}">
<div class="addon-card-main">
<div class="addon-card-title">
<span class="addon-status ${addon.enabled ? 'enabled' : ''}"></span>
<strong>${esc(addon.name || addon.id)}</strong>
<span class="addon-version">${esc(addon.version || '0.1.0')}</span>
</div>
<div class="addon-desc">${esc(addon.description || 'No description')}</div>
<div class="addon-meta">
<span>${esc(addon.id)}</span>
${addon.author ? `<span>${esc(addon.author)}</span>` : ''}
${addon.error ? `<span class="addon-error">${esc(addon.error)}</span>` : ''}
</div>
<div class="addon-contribs">${contributionSummary(addon)}</div>
</div>
<div class="addon-card-actions">
<button class="admin-btn-sm addon-edit-btn" data-addon-id="${esc(addon.id)}">Edit</button>
<button class="admin-btn-sm addon-toggle-btn" data-addon-id="${esc(addon.id)}" data-enabled="${addon.enabled ? '0' : '1'}">${addon.enabled ? 'Disable' : 'Enable'}</button>
<button class="admin-btn-sm danger addon-delete-btn" data-addon-id="${esc(addon.id)}">Delete</button>
</div>
</div>
`).join('');
}
function setMessage(message, kind = '') {
const node = el('addons-msg');
if (!node) return;
node.textContent = message || '';
node.className = kind ? `addon-msg ${kind}` : 'addon-msg';
}
function setEditorManifest(manifest) {
currentManifest = manifest || null;
const editor = el('addon-manifest-editor');
if (!editor) return;
editor.value = JSON.stringify(manifest || {}, null, 2);
}
async function loadAddons() {
const list = el('addons-list');
if (list) list.innerHTML = '<div class="admin-empty">Loading addons...</div>';
try {
const data = await api('/api/addons');
renderAddons(Array.isArray(data.addons) ? data.addons : []);
setMessage('');
} catch (err) {
if (list) list.innerHTML = `<div class="admin-empty addon-error">${esc(err.message)}</div>`;
}
}
async function loadTemplate() {
const idInput = el('addon-template-id');
const addonId = (idInput && idInput.value.trim()) || 'my-addon';
const data = await api(`/api/addons/template?addon_id=${encodeURIComponent(addonId)}`);
setEditorManifest(data.manifest);
setMessage('Template loaded. Edit the manifest and save it.', 'ok');
}
async function saveManifest() {
const editor = el('addon-manifest-editor');
if (!editor) return;
let manifest;
try {
manifest = JSON.parse(editor.value || '{}');
} catch (err) {
setMessage(`Invalid JSON: ${err.message}`, 'error');
return;
}
try {
const data = await api('/api/addons', {
method: 'POST',
body: JSON.stringify({ manifest }),
});
setEditorManifest(data.addon);
setMessage('Addon saved.', 'ok');
await loadAddons();
} catch (err) {
setMessage(err.message, 'error');
}
}
async function editAddon(addonId) {
try {
const data = await api(`/api/addons/${encodeURIComponent(addonId)}`);
const addon = { ...(data.addon || {}) };
delete addon.path;
setEditorManifest(addon);
setMessage(`Editing ${addon.id}.`, 'ok');
} catch (err) {
setMessage(err.message, 'error');
}
}
async function toggleAddon(addonId, enabled) {
try {
await api(`/api/addons/${encodeURIComponent(addonId)}/enabled`, {
method: 'POST',
body: JSON.stringify({ enabled }),
});
setMessage(enabled ? 'Addon enabled.' : 'Addon disabled.', 'ok');
await loadAddons();
} catch (err) {
setMessage(err.message, 'error');
}
}
async function deleteAddon(addonId) {
if (!confirm(`Delete addon "${addonId}"?`)) return;
try {
await api(`/api/addons/${encodeURIComponent(addonId)}`, { method: 'DELETE' });
if (currentManifest && currentManifest.id === addonId) setEditorManifest(null);
setMessage('Addon deleted.', 'ok');
await loadAddons();
} catch (err) {
setMessage(err.message, 'error');
}
}
function bindEvents() {
const refresh = el('addons-refresh-btn');
if (refresh) refresh.addEventListener('click', loadAddons);
const template = el('addon-template-btn');
if (template) template.addEventListener('click', () => loadTemplate().catch(err => setMessage(err.message, 'error')));
const save = el('addon-save-btn');
if (save) save.addEventListener('click', saveManifest);
const clear = el('addon-clear-btn');
if (clear) clear.addEventListener('click', () => {
setEditorManifest(null);
setMessage('');
});
const list = el('addons-list');
if (list) {
list.addEventListener('click', (event) => {
const edit = event.target.closest('.addon-edit-btn');
const toggle = event.target.closest('.addon-toggle-btn');
const del = event.target.closest('.addon-delete-btn');
if (edit) editAddon(edit.dataset.addonId);
if (toggle) toggleAddon(toggle.dataset.addonId, toggle.dataset.enabled === '1');
if (del) deleteAddon(del.dataset.addonId);
});
}
}
export function init() {
if (initialized) return;
initialized = true;
bindEvents();
}
export function open() {
init();
loadAddons();
}
export default { init, open };

View File

@@ -9,6 +9,7 @@ import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
import addonsModule from './addons.js';
let initialized = false;
let modalEl = null;
@@ -41,6 +42,7 @@ function initTabs() {
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
if (tab === 'ai') refreshAiModelEndpoints();
if (tab === 'addons') addonsModule.open();
});
});
}
@@ -2322,6 +2324,7 @@ function initAll() {
initClose();
initOpenPromptModalLink();
initOpacityToggle();
addonsModule.init();
initialized = true;
initDefaultChat();
initTeacherModel();
@@ -5731,6 +5734,7 @@ export function open(tab) {
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
syncAppearanceOpacity(activeTab === 'appearance');
if (activeTab === 'ai') refreshAiModelEndpoints();
if (activeTab === 'addons') addonsModule.open();
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
window.adminModule._initData();
}

View File

@@ -305,7 +305,11 @@
const vr = await fetch('/api/version');
if (vr.ok) {
const vd = await vr.json();
document.getElementById('version-label').textContent = 'v' + vd.version;
const version = vd.full_version || vd.build_version || vd.version;
const upstream = vd.upstream_version && vd.upstream_version !== version
? ' (upstream ' + vd.upstream_version + ')'
: '';
document.getElementById('version-label').textContent = version ? 'v' + version + upstream : '';
}
} catch(e) {}

View File

@@ -40439,6 +40439,126 @@ body.theme-frosted .modal {
color: var(--fg, #9cdef2);
}
/* Addon manager */
.addons-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.addon-card {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: color-mix(in srgb, var(--panel) 86%, var(--bg));
}
.addon-card-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.addon-card-title {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
font-size: 13px;
}
.addon-card-title strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.addon-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--fg) 25%, transparent);
flex-shrink: 0;
}
.addon-status.enabled {
background: var(--accent, #50fa7b);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #50fa7b) 14%, transparent);
}
.addon-version,
.addon-muted,
.addon-meta {
color: color-mix(in srgb, var(--fg) 55%, transparent);
font-size: 11px;
}
.addon-desc {
font-size: 12px;
line-height: 1.35;
color: color-mix(in srgb, var(--fg) 78%, transparent);
}
.addon-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
font-family: var(--mono, monospace);
}
.addon-error,
.addon-msg.error {
color: var(--red, #ff5555);
}
.addon-contribs {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.addon-chip {
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 10px;
color: color-mix(in srgb, var(--fg) 74%, transparent);
}
.addon-card-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
flex-shrink: 0;
}
.addon-builder-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 8px;
}
.addon-builder-row input {
min-width: 180px;
max-width: 260px;
}
.addon-manifest-editor {
width: 100%;
min-height: 300px;
resize: vertical;
box-sizing: border-box;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font-family: var(--mono, Consolas, monospace);
font-size: 12px;
line-height: 1.45;
}
.addon-msg {
min-height: 18px;
margin-top: 7px;
font-size: 11px;
color: color-mix(in srgb, var(--fg) 58%, transparent);
}
.addon-msg.ok {
color: var(--accent, #50fa7b);
}
/* The model-comparison grid hard-codes 2-4 equal columns with no phone
breakpoint that stacks them, so at 390px two models render ~178px columns and
four render ~88px columns. Each column is a full scrolling chat (code blocks,

View File

@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v344';
const CACHE_NAME = 'odysseus-v345';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags
@@ -44,6 +44,7 @@ const PRECACHE = [
'/static/js/theme.js',
'/static/js/censor.js',
'/static/js/settings.js',
'/static/js/addons.js',
'/static/js/admin.js',
'/static/js/init.js',
'/static/js/slashCommands.js',

103
tests/test_addons.py Normal file
View File

@@ -0,0 +1,103 @@
import asyncio
import json
from types import SimpleNamespace
import pytest
from src import addons
@pytest.fixture
def addon_store(tmp_path, monkeypatch):
monkeypatch.setattr(addons, "ADDONS_DIR", str(tmp_path / "addons"))
monkeypatch.setattr(addons, "ADDONS_FILE", str(tmp_path / "addons.json"))
return tmp_path
def test_addon_template_is_valid(addon_store):
manifest = addons.addon_template("demo-addon")
saved = addons.save_addon_manifest(manifest)
assert saved["id"] == "demo-addon"
assert saved["enabled"] is False
assert saved["contributions"][0]["type"] == "agent_prompt"
assert addons.get_addon("demo-addon")["name"] == "Demo Addon"
def test_enable_addon_exposes_prompt_contribution(addon_store):
addons.save_addon_manifest({
"id": "prompt-addon",
"name": "Prompt Addon",
"contributions": [
{"type": "agent_prompt", "text": "Prefer compact summaries."},
{"type": "unknown", "text": "ignored"},
],
})
assert addons.get_addons_prompt() == ""
addons.set_addon_enabled("prompt-addon", True)
prompt = addons.get_addons_prompt()
assert "Prompt Addon" in prompt
assert "Prefer compact summaries." in prompt
assert "unknown" not in prompt
def test_addon_id_rejects_path_traversal(addon_store):
with pytest.raises(ValueError):
addons.save_addon_manifest({"id": "../bad", "name": "Bad"})
@pytest.fixture
def addon_routes(addon_store):
fastapi = pytest.importorskip("fastapi")
from routes.addon_routes import setup_addon_routes
class _AuthManager:
def get_username_for_token(self, token):
return "admin" if token == "session-token" else None
def is_admin(self, user):
return user == "admin"
router = setup_addon_routes(_AuthManager())
def endpoint(path, method):
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"{method} {path} route not registered")
return endpoint, fastapi.HTTPException
class _Request(SimpleNamespace):
def __init__(self, token="session-token"):
super().__init__(cookies={"odysseus_session": token})
def test_addon_routes_require_admin(addon_routes):
endpoint, http_exception = addon_routes
list_all = endpoint("/api/addons", "GET")
with pytest.raises(http_exception) as exc:
asyncio.run(list_all(_Request(token="bad")))
assert exc.value.status_code == 401
def test_addon_routes_create_and_enable(addon_routes):
endpoint, _ = addon_routes
create = endpoint("/api/addons", "POST")
set_enabled = endpoint("/api/addons/{addon_id}/enabled", "POST")
list_all = endpoint("/api/addons", "GET")
body = SimpleNamespace(manifest={"id": "route-addon", "name": "Route Addon"})
result = asyncio.run(create(body, _Request()))
assert result["addon"]["id"] == "route-addon"
enabled_body = SimpleNamespace(enabled=True)
asyncio.run(set_enabled("route-addon", enabled_body, _Request()))
listed = asyncio.run(list_all(_Request()))
assert listed["addons"][0]["enabled"] is True
assert json.loads((addons._addon_dir("route-addon") / "addon.json").read_text())["enabled"] is True

View File

@@ -6,7 +6,20 @@ from src.readiness import check_readiness
def test_readiness_reports_core_subsystems():
result = check_readiness()
assert {"ready", "version", "checks", "timestamp"}.issubset(result.keys())
assert {
"ready",
"version",
"upstream_version",
"app_version",
"build_flavor",
"build_version",
"checks",
"timestamp",
}.issubset(result.keys())
assert result["upstream_version"] == "1.0.1"
assert result["app_version"] == result["upstream_version"]
assert result["build_flavor"] == "wilkensxl"
assert result["version"] == result["build_version"]
checks = result["checks"]
for name in ("database", "data_dir", "local_first"):
assert name in checks, f"missing check: {name}"

View File

@@ -0,0 +1,11 @@
"""Tests for WilkensXL build metadata."""
from src.constants import APP_VERSION, BUILD_FLAVOR, BUILD_VERSION, FULL_VERSION, UPSTREAM_VERSION
def test_custom_build_version_is_distinct_from_upstream_version():
assert APP_VERSION == "1.0.1"
assert UPSTREAM_VERSION == APP_VERSION
assert BUILD_FLAVOR == "wilkensxl"
assert BUILD_VERSION.startswith(f"{APP_VERSION}-wilkensxl.")
assert FULL_VERSION == BUILD_VERSION