Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1682 lines
67 KiB
Python
1682 lines
67 KiB
Python
# main.py
|
|
from __future__ import annotations
|
|
|
|
import asyncio as _asyncio
|
|
import contextlib as _contextlib
|
|
import json as _json
|
|
import secrets as _secrets
|
|
import uuid as _uuid
|
|
from collections import Counter as _Counter
|
|
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
import os as _os
|
|
import platform as _platform
|
|
import sys as _sys
|
|
from pathlib import Path
|
|
from fastapi import FastAPI, HTTPException, Body, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
|
|
|
|
from .nexus_config import (
|
|
settings, VERSION, DEFAULT_CHAT_MODEL, ALLOWED_HOSTS, ALLOWED_ORIGINS,
|
|
MAX_REQUEST_BYTES, MAX_UPLOAD_BYTES, MAX_PDF_PAGES,
|
|
MAX_CONCURRENT_CHATS, MAX_CONCURRENT_UPLOADS, model_pull_allowed,
|
|
)
|
|
from .chat import generate_chat_response, stream_chat_response, _synapse_trace
|
|
from . import chat as _chat
|
|
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
|
from . import frontend_manager as _frontend_manager
|
|
from . import playbook_manager
|
|
from . import tools as _tools
|
|
from .memory.curator import extract_for_conversation
|
|
|
|
def _render_memory_block(facts) -> str:
|
|
"""Render memory items as grouped ## Section / - bullet markdown.
|
|
Leading-space indent on a fact's text is preserved so nested bullets stay nested.
|
|
|
|
Behavioural "Instructions" entries are skipped — they belong in the playbook,
|
|
not the "what you know about the user" facts block. Injecting imperative directives
|
|
("provide full rewrites", "include file paths") as facts pushes a small model
|
|
to reformat/organise the user's input instead of conversing."""
|
|
from collections import defaultdict
|
|
sections: dict = defaultdict(list)
|
|
for item in facts:
|
|
if (item.section or "").strip().lower() == "instructions":
|
|
continue
|
|
sections[item.section or "General"].append(item.text)
|
|
parts = []
|
|
for section, lines in sections.items():
|
|
rendered = []
|
|
for line in lines:
|
|
stripped = line.lstrip(" ")
|
|
indent = len(line) - len(stripped)
|
|
rendered.append(" " * indent + f" - {stripped}")
|
|
parts.append(f"## {section}\n" + "\n".join(rendered))
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
# Header for the injected memory facts. No anti-recite restriction: NexusOS is a
|
|
# memory-first assistant, so facts are framed as freely usable knowledge about
|
|
# the user. (A minimal header is kept so the facts have context; drop it entirely
|
|
# to inject the raw facts block with no framing.)
|
|
_MEMORY_PREAMBLE = (
|
|
"\n\n---\nThe user is the person you are talking to right now — every user turn in "
|
|
"this conversation is his. The facts below are about him, written in the third "
|
|
"person only because that is how they are stored; address him as \"you\", never "
|
|
"discuss him as an absent third party. Use them freely and naturally to inform "
|
|
"and personalize your replies:\n\n"
|
|
)
|
|
|
|
|
|
_CODING_KEYWORDS = frozenset({
|
|
"code", "coding", "function", "class", "method", "variable", "bug", "error",
|
|
"debug", "fix", "refactor", "script", "program", "syntax", "compile", "import",
|
|
"module", "library", "algorithm", "loop", "array", "string", "integer", "boolean",
|
|
"return", "def", "const", "let", "var", "test", "api", "endpoint", "database",
|
|
"query", "sql", "bash", "terminal", "command", "package", "dependency",
|
|
"python", "javascript", "typescript", "rust", "golang", "java", "html", "css",
|
|
".py", ".js", ".ts", ".jsx", ".tsx", ".sh", ".json", ".yaml", ".sql", ".css",
|
|
})
|
|
|
|
def _detect_intent(message: str) -> str:
|
|
lower = message.lower()
|
|
return "code" if any(kw in lower for kw in _CODING_KEYWORDS) else "chat"
|
|
|
|
|
|
import re as _re
|
|
|
|
def _route_playbooks(message: str, candidates: list) -> list:
|
|
"""Return the reference playbook(s) whose tags appear directly in the user's
|
|
message. Returns [] when nothing matches, so casual chat doesn't drag in a
|
|
specialist playbook.
|
|
|
|
Dropped an old memory-fallback tier that, when the message matched no tags,
|
|
scored playbook tags against the user's WHOLE memory corpus. Because memory
|
|
permanently mentions e.g. the home network, that injected the Home Network
|
|
playbook (~1.7k chars) into unrelated chats. Direct references like "my truck"
|
|
already match here ('truck' is a Ford tag), so the fallback was mostly noise.
|
|
"""
|
|
if not candidates or not message:
|
|
return candidates
|
|
|
|
msg_tokens = set(_re.findall(r'\b\w+\b', message.lower()))
|
|
scores = [(sum(1 for tag in pb.tags if tag.lower() in msg_tokens), pb) for pb in candidates]
|
|
best = max(s for s, _ in scores)
|
|
if best > 0:
|
|
return [pb for s, pb in scores if s == best]
|
|
return []
|
|
|
|
async def _auto_select_model(message: str = "") -> str:
|
|
"""A pinned settings.model wins; otherwise pick the preferred installed model
|
|
for the detected intent. The preference lists live in one place now —
|
|
ollama_manager._MODEL_PREFERENCE, via select_best_model(intent)."""
|
|
try:
|
|
s = store.get_settings()
|
|
if s.get("model"):
|
|
return s["model"]
|
|
intent = _detect_intent(message) if message else "chat"
|
|
# Auto-mode remap: a configured model for this intent fires first;
|
|
# otherwise fall back to the built-in preference list.
|
|
remap = s.get(f"auto_{intent}_model")
|
|
if remap:
|
|
return remap
|
|
return await get_ollama_manager().select_best_model(intent)
|
|
except Exception:
|
|
return DEFAULT_CHAT_MODEL
|
|
|
|
|
|
|
|
_TITLE_SYSTEM_PROMPT = (
|
|
"You generate a short, descriptive title for a chat conversation based on the "
|
|
"user's first message. Reply with ONLY the title: 3 to 6 words, no quotes, no "
|
|
"trailing punctuation, no preamble. Use plain text in title case. The title "
|
|
"names the TOPIC — never echo the user's question or phrase it as a question.\n\n"
|
|
"Examples:\n"
|
|
"Message: can you help me fix a bug in my python script?\n"
|
|
"Title: Python Script Bug Fix\n"
|
|
"Message: what's a good recipe for sourdough bread?\n"
|
|
"Title: Sourdough Bread Recipe\n"
|
|
"Message: i want to try out your memory, ask me questions about myself\n"
|
|
"Title: Testing Memory Recall"
|
|
)
|
|
|
|
|
|
async def _generate_conversation_title(first_message: str, model: str) -> Optional[str]:
|
|
"""Ask the LLM for a concise title. Titling is a background 'curation' task,
|
|
so it runs on the CURATOR model (mistral) rather than the chat model: the 7B
|
|
follows the terse title format better than the 3B chat model, and it's already
|
|
warm in RAM. Crucially we pass the curator's own num_gpu (CPU) so we hit that
|
|
warm CPU-resident instance — same memory pool, no reload, and the GPU chat
|
|
model is never disturbed. `model` is only a fallback if no curator is set.
|
|
Best-effort: returns None on any failure so titling never breaks the chat."""
|
|
snippet = first_message.strip()[:1000]
|
|
if not snippet:
|
|
return None
|
|
try:
|
|
s = store.get_settings()
|
|
title_model = s.get("memory_model") or model
|
|
num_gpu = await get_ollama_manager().resolve_num_gpu(s.get("memory_gpu_offload", 0), title_model)
|
|
result = await generate_chat_response(
|
|
user_message=snippet,
|
|
metadata={"system": _TITLE_SYSTEM_PROMPT, "model": title_model,
|
|
"temperature": 0.2, "num_gpu": num_gpu},
|
|
timeout=30,
|
|
)
|
|
title = (result.get("response") or "").strip()
|
|
# Strip stray quotes/wrapping the model sometimes adds, collapse whitespace.
|
|
title = title.strip().strip('"').strip("'").splitlines()[0].strip()
|
|
# Drop a "Title:" prefix the model may echo from the examples, and strip
|
|
# trailing punctuation the prompt forbids but small models still add.
|
|
if title.lower().startswith("title:"):
|
|
title = title[len("title:"):].strip()
|
|
title = " ".join(title.split()).rstrip("?.!,;:")
|
|
if not title:
|
|
return None
|
|
return title[:120]
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
from .memory.store import store, MemoryItem
|
|
from .playbooks.store import playbook_store, PlaybookItem
|
|
from .search import needs_web_search, web_search
|
|
|
|
|
|
app = FastAPI(title="Synapse Backend", version=VERSION)
|
|
|
|
# Alias for startup scripts
|
|
sio_app = app
|
|
|
|
# --- Local-access guard ---
|
|
# These APIs are unauthenticated and meant for this machine only. Two layers:
|
|
# 1. TrustedHostMiddleware rejects a foreign Host header, which is what defeats
|
|
# DNS-rebinding — the browser treats a rebound attacker domain as
|
|
# same-origin, so CORS alone can't stop it.
|
|
# 2. CORS is scoped to known local origins (not "*"), so a malicious page can't
|
|
# read responses cross-origin from the victim's browser.
|
|
# NEXUS_ALLOWED_HOSTS=* / a custom NEXUS_ALLOWED_ORIGINS opts into LAN exposure,
|
|
# and should only be used once real authentication is in front of these apps.
|
|
if "*" not in ALLOWED_HOSTS:
|
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=ALLOWED_ORIGINS,
|
|
allow_credentials=False,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# --- Modules ---
|
|
# Installed feature modules (mail, and whatever comes next) live under the
|
|
# repo-root modules/ package and mount their own APIRouter here.
|
|
from modules.registry import ROUTERS as _MODULE_ROUTERS
|
|
for _module_router in _MODULE_ROUTERS:
|
|
app.include_router(_module_router)
|
|
|
|
|
|
# --- Request-size cap ---
|
|
# Reject oversized bodies before they are buffered/decoded (a base64 upload or a
|
|
# giant chat payload could otherwise exhaust memory). Header-based: covers the
|
|
# realistic clients (browsers/httpx always send Content-Length for JSON). A
|
|
# chunked request with no length still reaches the handler, where the upload path
|
|
# enforces its own decoded-byte cap as a backstop.
|
|
@app.middleware("http")
|
|
async def _limit_request_body(request: Request, call_next):
|
|
cl = request.headers.get("content-length")
|
|
if cl:
|
|
try:
|
|
if int(cl) > MAX_REQUEST_BYTES:
|
|
return JSONResponse(
|
|
status_code=413,
|
|
content={"detail": f"request body too large (max {MAX_REQUEST_BYTES} bytes)"},
|
|
)
|
|
except ValueError:
|
|
pass
|
|
return await call_next(request)
|
|
|
|
|
|
class _InFlightLimiter:
|
|
"""Bounds concurrent in-flight ops for one expensive entry point so a single
|
|
caller can't fan out unlimited inference/embedding work. The server runs on
|
|
one asyncio loop, so a plain counter is safe — there's no await between the
|
|
check and the increment. Over the limit -> HTTP 429."""
|
|
|
|
def __init__(self, limit: int, label: str):
|
|
self._limit = max(1, int(limit))
|
|
self._label = label
|
|
self._n = 0
|
|
|
|
def acquire(self) -> None:
|
|
if self._n >= self._limit:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"{self._label} is busy ({self._limit} concurrent max) — retry shortly",
|
|
)
|
|
self._n += 1
|
|
|
|
def release(self) -> None:
|
|
if self._n > 0:
|
|
self._n -= 1
|
|
|
|
@_contextlib.contextmanager
|
|
def slot(self):
|
|
self.acquire()
|
|
try:
|
|
yield
|
|
finally:
|
|
self.release()
|
|
|
|
|
|
_CHAT_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_CHATS, "chat")
|
|
_UPLOAD_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_UPLOADS, "document ingest")
|
|
|
|
# --- GLOBALS ---
|
|
ollama = None
|
|
|
|
|
|
# -------------------------
|
|
# Startup: Initialize Ollama service
|
|
# -------------------------
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
global ollama
|
|
# Manual AI control: build the manager but DON'T launch Ollama or warm a model.
|
|
# Backend serves logs/playbooks/memory/models with the AI off; the user brings
|
|
# it up from the UI (POST /ollama/start). get_ollama_manager() is the same
|
|
# singleton the rest of the app uses, so no divergent instance. is_running()
|
|
# probes the HTTP API, so an already-running Ollama (e.g. the Windows service)
|
|
# still reports as running.
|
|
ollama = get_ollama_manager()
|
|
ollama.keep_alive = store.get_settings().get("keep_alive") or ollama.keep_alive
|
|
print("[Synapse] Ready. Ollama not auto-started (manual control).")
|
|
|
|
|
|
async def _ollama_status_async() -> Optional[str]:
|
|
"""to_thread, not a direct call: get_status() does blocking IO (a sync httpx
|
|
request and, once, a subprocess). Awaiting it inline stalls the whole event
|
|
loop for its duration - and both callers below are polled continuously by
|
|
the UI, so an inline call froze the entire server in lockstep with its own
|
|
health check."""
|
|
if ollama is None or not hasattr(ollama, "get_status"):
|
|
return None
|
|
return await _asyncio.to_thread(ollama.get_status)
|
|
|
|
|
|
# -------------------------
|
|
# Status
|
|
# -------------------------
|
|
# Was GET / — moved so the built frontend (mounted at / below) owns the root.
|
|
@app.get("/status")
|
|
async def root():
|
|
try:
|
|
status = await _ollama_status_async()
|
|
except Exception:
|
|
status = None
|
|
return {"status": "online", "version": VERSION, "ollama": status, "platform": _platform.system().lower()}
|
|
|
|
|
|
# -------------------------
|
|
# Update check
|
|
# -------------------------
|
|
# The update mechanism is git: `git pull` + rebuild, i.e. `python bin/sync.py
|
|
# restore` (./install.sh on Linux). So "is a newer build out there" is just
|
|
# "how many commits is origin/main ahead of HEAD" — no version server needed.
|
|
def _git(*args: str, timeout: int = 30) -> str:
|
|
import subprocess
|
|
out = subprocess.run(
|
|
["git", *args], cwd=str(settings.project_root),
|
|
capture_output=True, text=True, timeout=timeout,
|
|
)
|
|
if out.returncode:
|
|
raise RuntimeError((out.stderr or out.stdout).strip() or "git failed")
|
|
return out.stdout.strip()
|
|
|
|
|
|
def _check_update() -> Dict[str, Any]:
|
|
_git("fetch", "--quiet", "origin", timeout=60)
|
|
behind = int(_git("rev-list", "--count", "HEAD..origin/main") or 0)
|
|
remote_version = VERSION
|
|
if behind:
|
|
with _contextlib.suppress(Exception):
|
|
remote_version = _git("show", "origin/main:VERSION").strip() or VERSION
|
|
return {
|
|
"version": VERSION,
|
|
"remote_version": remote_version,
|
|
"behind": behind,
|
|
"latest": _git("log", "-1", "--format=%h %s", "origin/main") if behind else "",
|
|
}
|
|
|
|
|
|
@app.get("/update/check")
|
|
async def update_check():
|
|
"""Compare this checkout against origin/main. Network call + subprocess, so
|
|
it runs off the event loop like the Ollama health check does."""
|
|
try:
|
|
return await _asyncio.to_thread(_check_update)
|
|
except Exception as e:
|
|
return {"version": VERSION, "behind": 0, "error": str(e)[:300]}
|
|
|
|
|
|
_update_running = False
|
|
|
|
|
|
@app.post("/update/apply")
|
|
async def update_apply():
|
|
"""Run `ncp upgrade` (git pull, rebuild, restart) fully detached.
|
|
|
|
It stops this very process, so it cannot be a child of it: a child would be
|
|
killed halfway through its own upgrade. Output goes to runtime/logs/update.log
|
|
because the UI cannot read /logs while the backend is down - that file is the
|
|
only record if the restart fails.
|
|
"""
|
|
global _update_running
|
|
import subprocess
|
|
if _update_running:
|
|
return {"started": False, "error": "An update is already running."}
|
|
log_path = settings.logs_dir / "update.log"
|
|
kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0)}
|
|
if _os.name == "nt" else {"start_new_session": True})
|
|
try:
|
|
log = open(log_path, "wb")
|
|
subprocess.Popen(
|
|
[_sys.executable, str(settings.project_root / "management" / "ncp.py"), "upgrade"],
|
|
cwd=str(settings.project_root), stdout=log, stderr=subprocess.STDOUT,
|
|
stdin=subprocess.DEVNULL, **kwargs,
|
|
)
|
|
except Exception as e:
|
|
return {"started": False, "error": str(e)[:300]}
|
|
_update_running = True
|
|
return {"started": True, "log": str(log_path)}
|
|
|
|
|
|
# --- Deferred memory extraction ------------------------------------------
|
|
# The curator reads a conversation when it has been quiet for a while, rather
|
|
# than after every exchange. Each new message reschedules, so "idle" means the
|
|
# user actually stopped — which is also the only reliable signal that a
|
|
# half-said fact is now complete.
|
|
_pending_extractions: Dict[str, _asyncio.Task] = {}
|
|
|
|
|
|
def _extract_idle_seconds() -> float:
|
|
try:
|
|
return max(5.0, float(store.get_settings().get("memory_extract_idle", 120)))
|
|
except (TypeError, ValueError):
|
|
return 120.0
|
|
|
|
|
|
async def _extract_after_idle(conversation_id: str, project_id: str, delay: float) -> None:
|
|
try:
|
|
await _asyncio.sleep(delay)
|
|
saved = await extract_for_conversation(conversation_id, project_id)
|
|
if saved:
|
|
_synapse_trace(
|
|
f"\n◆ CURATOR (idle sweep): {len(saved)} fact(s) — "
|
|
+ "; ".join(f"[{i['section']}] {i['text']}" for i in saved) + "\n"
|
|
)
|
|
except _asyncio.CancelledError:
|
|
raise # rescheduled by a newer message; the watermark still has the work
|
|
except Exception as e:
|
|
_synapse_trace(f"\n⚠ idle extraction failed: {e}\n")
|
|
finally:
|
|
_pending_extractions.pop(conversation_id, None)
|
|
|
|
|
|
def _schedule_extraction(conversation_id: str, project_id: str) -> None:
|
|
"""(Re)arm the idle sweep for one conversation."""
|
|
previous = _pending_extractions.pop(conversation_id, None)
|
|
if previous:
|
|
previous.cancel()
|
|
_pending_extractions[conversation_id] = _asyncio.create_task(
|
|
_extract_after_idle(conversation_id, project_id, _extract_idle_seconds())
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def _resume_dropped_extractions() -> None:
|
|
"""Pick up conversations whose sweep was lost to a restart. The watermark
|
|
makes this idempotent, so a conversation already read is skipped without
|
|
ever reaching the model."""
|
|
async def _bg():
|
|
await _asyncio.sleep(5) # let the app finish coming up first
|
|
try:
|
|
stale = store.conversations_awaiting_extraction(_extract_idle_seconds())
|
|
except Exception:
|
|
return
|
|
for cid in stale:
|
|
try:
|
|
# One at a time: Ollama serialises anyway, and a burst here would
|
|
# sit in front of the user's first message of the session.
|
|
await extract_for_conversation(cid, store.conversation_project(cid) or "")
|
|
except Exception:
|
|
continue
|
|
_asyncio.create_task(_bg())
|
|
|
|
|
|
# -------------------------
|
|
# Chat (streaming)
|
|
# -------------------------
|
|
@app.post("/chat/stream")
|
|
async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|
# Bound concurrent chats so a flood can't fan out unlimited model inference.
|
|
# Acquired here (covers the pre-stream RAG/embedding work too) and released
|
|
# when the SSE stream finishes — see the guarded wrapper below.
|
|
_CHAT_INFLIGHT.acquire()
|
|
_chat_slot_held = True
|
|
try:
|
|
message = payload.get("message", "")
|
|
app_settings = store.get_settings()
|
|
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
|
_active_pb = playbook_manager.get_main_playbook()
|
|
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
|
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
|
context = payload.get("context", {})
|
|
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
|
history = payload.get("history", [])
|
|
temperature = payload.get("temperature", app_settings.get("temperature"))
|
|
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
|
|
think = payload.get("think", app_settings.get("think", False))
|
|
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
|
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
|
|
|
if not message:
|
|
raise HTTPException(status_code=400, detail="Missing 'message'")
|
|
|
|
# Resolve the project scope: an existing conversation keeps its bound project;
|
|
# a brand-new one inherits the current workspace (active_project setting).
|
|
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
|
_conv_proj = store.conversation_project(conversation_id)
|
|
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
|
|
|
|
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
|
|
system_prompt = playbook_manager.get_system_prompt() or app_settings.get("system_prompt", "")
|
|
|
|
# Fetch memory facts once — used for both playbook routing and system prompt
|
|
# injection. Global facts ("") always apply; the rest only inside their project.
|
|
memory_facts = [m for m in store.all() if m.project_id in ("", rag_scope)]
|
|
|
|
# Per-project instructions sit right under the playbook: they say how the
|
|
# assistant should behave for this project specifically.
|
|
project_instructions = store.project_instructions(rag_scope)
|
|
if project_instructions:
|
|
separator = "\n\n---\nProject instructions (follow these for this project):\n\n"
|
|
system_prompt = (system_prompt + separator + project_instructions) if system_prompt else project_instructions
|
|
|
|
# Append the best-matching reference playbook(s) to the system prompt
|
|
context_pbs = _route_playbooks(rendered_message, playbook_manager.get_context_playbooks())
|
|
if context_pbs:
|
|
refs = "\n\n".join(
|
|
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
|
|
for pb in context_pbs
|
|
)
|
|
separator = "\n\n---\nReference playbooks (read these as additional context):\n\n"
|
|
system_prompt = (system_prompt + separator + refs) if system_prompt else refs
|
|
if memory_facts:
|
|
facts_block = _render_memory_block(memory_facts)
|
|
system_prompt = (system_prompt + _MEMORY_PREAMBLE + facts_block) if system_prompt else facts_block
|
|
|
|
# Search past conversations for relevant context and inject the top matches.
|
|
# This gives the model memory of prior exchanges without requiring tool-calling support.
|
|
# Semantic recall (embeddings) finds relevant exchanges even without shared
|
|
# keywords; it falls back to lexical substring match if embeddings are down.
|
|
past_context = await store.semantic_search_conversations(
|
|
message, get_ollama_manager().embed, limit=2
|
|
)
|
|
if past_context:
|
|
snippets = []
|
|
for conv in past_context:
|
|
exchange = "\n".join(
|
|
f" {m['role'].upper()}: {m['content']}"
|
|
for m in conv["matches"]
|
|
)
|
|
snippets.append(exchange)
|
|
memory_block = "\n\n".join(snippets)
|
|
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
|
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
|
|
|
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
|
doc_hits = await store.search_documents(
|
|
message, get_ollama_manager().embed,
|
|
limit=app_settings.get("rag_top_k", 3),
|
|
min_score=app_settings.get("rag_min_score", 0.6),
|
|
project_id=rag_scope or None,
|
|
)
|
|
doc_titles: list = []
|
|
if doc_hits:
|
|
doc_block = "\n\n".join(f"[{d['title']}]\n{d['text']}" for d in doc_hits)
|
|
separator = "\n\n---\nRelevant documents (cite as source material):\n\n"
|
|
system_prompt = (system_prompt + separator + doc_block) if system_prompt else doc_block
|
|
doc_titles = list(dict.fromkeys(d["title"] for d in doc_hits)) # unique, order-preserving
|
|
|
|
# Fetch web search results for time-sensitive queries
|
|
search_results = ""
|
|
if needs_web_search(message):
|
|
search_results = await _asyncio.to_thread(web_search, message)
|
|
if search_results:
|
|
separator = "\n\n---\nWeb search results (treat as current information):\n\n"
|
|
system_prompt = (system_prompt + separator + search_results) if system_prompt else search_results
|
|
|
|
# ── MindTrace pre-flight ──────────────────────────────────────────
|
|
_trace_intent = _detect_intent(message) if message else "chat"
|
|
if payload.get("model"):
|
|
_trace_src = "user-override"
|
|
elif store.get_settings().get("model"):
|
|
_trace_src = "settings"
|
|
else:
|
|
_trace_src = f"auto/{_trace_intent}"
|
|
|
|
_synapse_trace(f"\n{'═' * 55}\n")
|
|
_synapse_trace(f"▶ MODEL : {model} [{_trace_src}]\n")
|
|
|
|
if _trace_intent == "code":
|
|
_kws = [kw for kw in _CODING_KEYWORDS if kw in message.lower()][:5]
|
|
_synapse_trace(f" INTENT: code → {', '.join(_kws)}\n")
|
|
else:
|
|
_synapse_trace(f" INTENT: chat\n")
|
|
|
|
_main_pb = playbook_manager.get_main_playbook()
|
|
if _main_pb:
|
|
_synapse_trace(f" PLAYBOOK: {_main_pb.title}\n")
|
|
if _main_pb.goal:
|
|
_synapse_trace(f" goal: {_main_pb.goal[:100]}\n")
|
|
else:
|
|
_synapse_trace(f" PLAYBOOK: none\n")
|
|
|
|
if context_pbs:
|
|
_synapse_trace(f" ROUTED : {', '.join(pb.title for pb in context_pbs)}\n")
|
|
else:
|
|
_synapse_trace(f" ROUTED : none (no tag match)\n")
|
|
|
|
if search_results:
|
|
_synapse_trace(f" SEARCH : {len(search_results)} chars injected\n")
|
|
elif needs_web_search(message):
|
|
_synapse_trace(f" SEARCH : triggered but returned no results\n")
|
|
|
|
if memory_facts:
|
|
_secs = _Counter(f.section or "General" for f in memory_facts)
|
|
_sec_str = " ".join(f"{s}({n})" for s, n in _secs.items())
|
|
_synapse_trace(f" MEMORY : {len(memory_facts)} facts [{_sec_str}]\n")
|
|
else:
|
|
_synapse_trace(f" MEMORY : none\n")
|
|
|
|
if past_context:
|
|
_synapse_trace(f" CONTEXT : {len(past_context)} past conversation match(es) injected\n")
|
|
|
|
if rag_scope:
|
|
_synapse_trace(f" PROJECT : {rag_scope}{' [+instructions]' if project_instructions else ''}\n")
|
|
_synapse_trace(f" SYS LEN : {len(system_prompt)} chars\n")
|
|
_synapse_trace(f"{'─' * 55}\n")
|
|
# ── end MindTrace pre-flight ──────────────────────────────────────
|
|
|
|
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature, "num_gpu": num_gpu, "num_ctx": num_ctx, "think": think}
|
|
|
|
# Vision: base64 images (data: prefix stripped by the client) ride on the user turn.
|
|
images = payload.get("images")
|
|
if images:
|
|
metadata["images"] = images
|
|
|
|
# Tool-using playbook: advertise the allowlisted tools of the active
|
|
# playbook AND of the reference playbooks _route_playbooks picked for
|
|
# this message — a routed playbook's instructions are already in the
|
|
# prompt, so its abilities have to come with them or the model narrates
|
|
# tools it was never given. Action tools follow action_tool_policy:
|
|
# off (withheld) / ask (per-call approval, in the tool loop) / allow.
|
|
_policy = app_settings.get("action_tool_policy", "off")
|
|
_pb_tools = list(dict.fromkeys(
|
|
(getattr(_main_pb, "tools", None) or [] if _main_pb else [])
|
|
+ [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])]
|
|
))
|
|
if _pb_tools:
|
|
allow_actions = _policy != "off"
|
|
schemas = _tools.schemas_for(_pb_tools, allow_actions)
|
|
if schemas:
|
|
metadata["tools"] = schemas
|
|
metadata["action_tool_policy"] = _policy
|
|
metadata["conversation_id"] = conversation_id
|
|
_granted = [t for t in _pb_tools if not _tools.is_action(t) or allow_actions]
|
|
_withheld = [t for t in _pb_tools if _tools.is_action(t) and not allow_actions]
|
|
_synapse_trace(f" TOOLS : {', '.join(_granted)} [actions: {_policy}]\n")
|
|
if _withheld:
|
|
_synapse_trace(f" WITHHELD: {', '.join(_withheld)} (action tools off)\n")
|
|
|
|
# Persist conversation and user message before streaming
|
|
store.create_conversation(conversation_id, rag_scope or "")
|
|
store.add_message(conversation_id, "user", rendered_message)
|
|
|
|
async def event_stream() -> AsyncGenerator[str, None]:
|
|
response_chunks: list[str] = []
|
|
meta: dict = {}
|
|
final_model = model
|
|
|
|
# Tell the client which documents fed this answer (RAG citations).
|
|
if doc_titles:
|
|
yield f"event: sources\ndata: {_json.dumps({'sources': doc_titles})}\n\n"
|
|
|
|
# ── Phase 1: stream primary model response ────────────────────
|
|
try:
|
|
async for chunk in stream_chat_response(
|
|
user_message=rendered_message,
|
|
metadata=metadata,
|
|
history=history,
|
|
):
|
|
if chunk.startswith("__meta__"):
|
|
try:
|
|
meta = _json.loads(chunk[8:])
|
|
except Exception:
|
|
pass
|
|
yield f"event: meta\ndata: {chunk[8:]}\n\n"
|
|
continue
|
|
if chunk.startswith("__status__"):
|
|
yield f"event: status\ndata: {_json.dumps({'tool': chunk[10:]})}\n\n"
|
|
continue
|
|
if chunk.startswith("__approve__"):
|
|
# Loop is paused awaiting the user; forward the pending actions.
|
|
yield f"event: tool_request\ndata: {chunk[11:]}\n\n"
|
|
continue
|
|
response_chunks.append(chunk)
|
|
yield f"data: {_json.dumps(chunk)}\n\n"
|
|
except _asyncio.TimeoutError:
|
|
_tval = store.get_settings().get("timeout", 120)
|
|
yield f"event: error\ndata: {_json.dumps({'detail': f'Model timed out after {_tval}s — try a smaller/faster model'})}\n\n"
|
|
return
|
|
except Exception as e:
|
|
detail = str(e) or type(e).__name__
|
|
yield f"event: error\ndata: {_json.dumps({'detail': detail})}\n\n"
|
|
return
|
|
|
|
# ── Persist completed response ───────────────────────────────
|
|
if response_chunks:
|
|
store.add_message(
|
|
conversation_id, "assistant", "".join(response_chunks),
|
|
model=meta.get("model") or final_model,
|
|
tokens=meta.get("tokens"),
|
|
)
|
|
|
|
# Response is complete — let the client re-enable its input now,
|
|
# so the slow title/memory work below doesn't freeze the UI.
|
|
yield "event: done\ndata: {}\n\n"
|
|
|
|
# Generate an AI title from the opening message. Retried on any turn
|
|
# while still untitled, so an interrupted first stream can recover.
|
|
if response_chunks:
|
|
try:
|
|
conv = store.get_conversation(conversation_id)
|
|
if conv and not conv.title:
|
|
first_user = next(
|
|
(m.content for m in conv.messages if m.role == "user"),
|
|
rendered_message,
|
|
)
|
|
title = await _generate_conversation_title(
|
|
first_user, meta.get("model") or final_model
|
|
)
|
|
if title:
|
|
store.set_conversation_title(conversation_id, title)
|
|
yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n"
|
|
except Exception:
|
|
pass
|
|
|
|
# Hand the conversation to the curator once it goes quiet. Not now:
|
|
# the curator is the chat model, and Ollama runs one request at a
|
|
# time (OLLAMA_NUM_PARALLEL=1), so extracting here would put the
|
|
# next message in a queue behind it.
|
|
if response_chunks:
|
|
_schedule_extraction(conversation_id, rag_scope)
|
|
|
|
# Hold the concurrency slot for the stream's lifetime, then release it
|
|
# exactly once when the generator is exhausted or closed (client
|
|
# disconnect). Ownership passes to the wrapper, so the endpoint's own
|
|
# finally must not also release.
|
|
_inner = event_stream()
|
|
|
|
async def _guarded_stream() -> AsyncGenerator[str, None]:
|
|
try:
|
|
async for _chunk in _inner:
|
|
yield _chunk
|
|
finally:
|
|
_CHAT_INFLIGHT.release()
|
|
|
|
_chat_slot_held = False
|
|
return StreamingResponse(_guarded_stream(), media_type="text/event-stream")
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
if _chat_slot_held:
|
|
_CHAT_INFLIGHT.release()
|
|
|
|
|
|
@app.post("/chat/approve")
|
|
async def chat_approve(payload: Dict[str, Any] = Body(...)):
|
|
"""Resolve a pending per-call tool approval. `decisions` maps tool name ->
|
|
bool; the awaiting chat stream resumes and runs the approved actions.
|
|
|
|
The `token` (issued in the stream's tool_request event) is required: without
|
|
it, anyone who can guess/enumerate a conversation_id could approve another
|
|
client's pending action. Compared in constant time."""
|
|
conversation_id = payload.get("conversation_id") or ""
|
|
token = payload.get("token") or ""
|
|
decisions = payload.get("decisions") or {}
|
|
waiter = _chat.pending_approvals.get(conversation_id)
|
|
if not waiter:
|
|
raise HTTPException(status_code=404, detail="no pending approval for this conversation")
|
|
expected = waiter.get("token") or ""
|
|
if not token or not _secrets.compare_digest(str(token), str(expected)):
|
|
raise HTTPException(status_code=403, detail="invalid or missing approval token")
|
|
waiter["decisions"] = {k: bool(v) for k, v in decisions.items()}
|
|
waiter["event"].set()
|
|
return {"status": "resumed"}
|
|
|
|
# -------------------------
|
|
# Settings
|
|
# -------------------------
|
|
# -------------------------
|
|
# Logs
|
|
# -------------------------
|
|
|
|
# Service log files surfaced in the web UI Logs tab. Fixed dict = no path
|
|
# traversal from the {name} param.
|
|
_LOG_FILES = {
|
|
"backend": settings.runtime_dir / "backend.log",
|
|
"memory": settings.runtime_dir / "memory.log",
|
|
"frontend": settings.runtime_dir / "frontend.log",
|
|
"ollama": settings.logs_dir / "ollama.log",
|
|
"chat": settings.logs_dir / "chat.log",
|
|
}
|
|
|
|
|
|
def _tail(path: Path, n: int) -> str:
|
|
# ponytail: deque reads the whole file, keeps last n lines. Fine at current
|
|
# sizes (<200KB). Switch to seek-from-end if a log ever runs into MBs.
|
|
from collections import deque
|
|
with path.open("r", errors="replace") as f:
|
|
return "".join(deque(f, maxlen=n))
|
|
|
|
|
|
@app.get("/logs")
|
|
def list_logs():
|
|
return {"logs": list(_LOG_FILES.keys())}
|
|
|
|
|
|
@app.get("/logs/{name}")
|
|
def read_log(name: str, lines: int = 400):
|
|
path = _LOG_FILES.get(name)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="unknown log")
|
|
lines = max(1, min(lines, 5000))
|
|
if not path.exists():
|
|
return {"name": name, "content": "", "missing": True}
|
|
return {"name": name, "content": _tail(path, lines)}
|
|
|
|
|
|
@app.get("/settings")
|
|
async def get_settings_endpoint():
|
|
return store.get_settings()
|
|
|
|
|
|
@app.put("/settings")
|
|
async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
|
|
store.update_settings(payload)
|
|
merged = store.get_settings()
|
|
if "keep_alive" in payload:
|
|
get_ollama_manager().keep_alive = merged.get("keep_alive") or None
|
|
return merged
|
|
|
|
|
|
# -------------------------
|
|
# Memory
|
|
# -------------------------
|
|
@app.get("/memory")
|
|
async def get_memory(project: Optional[str] = None):
|
|
"""All facts, or — with ?project=<id> — just that scope's ('' = global)."""
|
|
items = store.all()
|
|
if project is not None:
|
|
items = [m for m in items if m.project_id == project]
|
|
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags,
|
|
"project_id": m.project_id} for m in items]}
|
|
|
|
|
|
@app.post("/memory")
|
|
async def add_memory(payload: Dict[str, Any] = Body(...)):
|
|
text = (payload.get("text") or "").rstrip()
|
|
if not text.strip():
|
|
raise HTTPException(status_code=400, detail="Missing 'text'")
|
|
from .memory.store import MemoryItem
|
|
import uuid as _mem_uuid
|
|
item = MemoryItem(
|
|
id=str(_mem_uuid.uuid4()),
|
|
section=(payload.get("section") or "General").strip(),
|
|
text=text,
|
|
tags=payload.get("tags", []),
|
|
project_id=payload.get("project_id") or "",
|
|
)
|
|
store.add(item)
|
|
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags,
|
|
"project_id": item.project_id}
|
|
|
|
|
|
@app.patch("/memory/{item_id}")
|
|
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
|
|
existing = store.get(item_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
from .memory.store import MemoryItem
|
|
updated = MemoryItem(
|
|
id=item_id,
|
|
section=(payload.get("section") or existing.section or "General").strip(),
|
|
text=(payload.get("text") or existing.text).rstrip(),
|
|
tags=payload.get("tags", existing.tags),
|
|
project_id=payload.get("project_id", existing.project_id),
|
|
)
|
|
store.update(updated)
|
|
return {"id": updated.id, "section": updated.section, "text": updated.text,
|
|
"tags": updated.tags, "project_id": updated.project_id}
|
|
|
|
|
|
@app.delete("/memory/{item_id}")
|
|
async def delete_memory(item_id: str):
|
|
if not store.get(item_id):
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
store.delete(item_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@app.post("/memory/reorder")
|
|
async def reorder_memory(payload: Dict[str, Any] = Body(...)):
|
|
section = (payload.get("section") or "General").strip() or "General"
|
|
ids = payload.get("ids")
|
|
if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids):
|
|
raise HTTPException(status_code=400, detail="ids must be a list of strings")
|
|
ok = store.reorder_section(section, ids)
|
|
return {"ok": ok}
|
|
|
|
|
|
# -------------------------
|
|
# Models
|
|
# -------------------------
|
|
@app.get("/models/recommended")
|
|
async def models_recommended():
|
|
"""Curated models annotated with whether they fit this machine's VRAM/RAM."""
|
|
from . import hardware
|
|
return hardware.recommend()
|
|
|
|
|
|
@app.get("/models")
|
|
async def get_models():
|
|
try:
|
|
mgr = get_ollama_manager()
|
|
# Embedding models (e.g. nomic-embed-text) can't chat — hide from picker.
|
|
models = [m for m in await mgr.list_models() if "embed" not in m.lower()]
|
|
# Report the SAME model the chat path would auto-pick (honors a pin),
|
|
# so the picker's "Auto (…)" label matches what actually answers.
|
|
selected = await _auto_select_model()
|
|
return {"models": models, "selected": selected}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/models/details")
|
|
async def get_model_details():
|
|
"""Return full model objects (name, size, modified_at) from Ollama."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
r = await client.get(f"{settings.ollama_host.rstrip('/')}/api/tags")
|
|
r.raise_for_status()
|
|
return r.json()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {e}")
|
|
|
|
|
|
@app.post("/models/pull")
|
|
async def pull_model(payload: Dict[str, Any] = Body(...)):
|
|
"""Proxy a streaming model pull from Ollama."""
|
|
name = payload.get("name", "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Missing model name")
|
|
if not model_pull_allowed(name):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"model '{name}' is not in NEXUS_MODEL_ALLOWLIST",
|
|
)
|
|
|
|
async def _stream():
|
|
try:
|
|
async with httpx.AsyncClient(timeout=600.0) as client:
|
|
async with client.stream(
|
|
"POST", f"{settings.ollama_host.rstrip('/')}/api/pull",
|
|
json={"name": name},
|
|
) as resp:
|
|
async for line in resp.aiter_lines():
|
|
if line:
|
|
yield line + "\n"
|
|
except Exception as e:
|
|
yield f'{{"error": "{e}"}}\n'
|
|
finally:
|
|
# Bust the model cache so new model is visible immediately
|
|
get_ollama_manager().invalidate_model_cache()
|
|
|
|
return StreamingResponse(_stream(), media_type="application/x-ndjson")
|
|
|
|
|
|
@app.delete("/models/{name:path}")
|
|
async def delete_model(name: str):
|
|
"""Proxy a model deletion to Ollama."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
r = await client.request(
|
|
"DELETE", f"{settings.ollama_host.rstrip('/')}/api/delete",
|
|
json={"name": name},
|
|
)
|
|
if not r.is_success:
|
|
raise HTTPException(status_code=r.status_code, detail=r.text)
|
|
get_ollama_manager().invalidate_model_cache()
|
|
return {"status": "deleted", "name": name}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {e}")
|
|
|
|
|
|
# -------------------------
|
|
# Ollama Status
|
|
# -------------------------
|
|
@app.get("/ollama/status")
|
|
async def ollama_status_endpoint():
|
|
try:
|
|
status = await _ollama_status_async()
|
|
return {"status": status}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# -------------------------
|
|
# Ollama Start
|
|
# -------------------------
|
|
_warm_tasks: set = set()
|
|
|
|
|
|
async def _warm_default_model():
|
|
"""Load the default model into RAM/VRAM so the first chat isn't a cold read.
|
|
Returns the model name. Raises are the caller's to swallow."""
|
|
s = store.get_settings()
|
|
warm_model = s.get("model") or await ollama.select_best_model()
|
|
num_gpu = await ollama.resolve_num_gpu(s.get("gpu_offload", -1), warm_model)
|
|
await ollama.warm(warm_model, num_gpu)
|
|
return warm_model
|
|
|
|
|
|
@app.post("/ollama/start")
|
|
async def ollama_start_endpoint(background: bool = False):
|
|
try:
|
|
global ollama
|
|
if ollama is None:
|
|
ollama = initialize_ollama()
|
|
|
|
# start_async, NOT start: the sync one polls with time.sleep(1) up to 30
|
|
# times, which blocks the event loop — the whole backend (including the
|
|
# UI's 5s status poll) goes dead while Ollama boots, so a slow start
|
|
# looks like a frozen app rather than a slow one.
|
|
if hasattr(ollama, "start_async"):
|
|
await ollama.start_async()
|
|
|
|
is_running = ollama.is_running() if hasattr(ollama, "is_running") else True
|
|
|
|
# Warm the default model so the first chat isn't a cold load.
|
|
# background=False (UI "Start AI"): block until resident, so the button
|
|
# finishes only once the AI can actually answer.
|
|
# background=True (`ncp start`): fire-and-forget so boot returns fast and
|
|
# the model warms concurrently — Ollama serialises the load, so a first
|
|
# chat that arrives mid-warm simply waits on the same load.
|
|
warmed = None
|
|
if is_running:
|
|
if background:
|
|
task = _asyncio.create_task(_warm_default_model())
|
|
_warm_tasks.add(task) # hold a ref (asyncio only weak-refs tasks)
|
|
task.add_done_callback(_warm_tasks.discard)
|
|
warmed = "background"
|
|
else:
|
|
try:
|
|
warmed = await _warm_default_model()
|
|
except Exception:
|
|
pass
|
|
|
|
return {"status": "started" if is_running else "failed", "running": is_running, "warmed": warmed}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# -------------------------
|
|
# Ollama Stop
|
|
# -------------------------
|
|
@app.post("/ollama/stop")
|
|
async def ollama_stop_endpoint():
|
|
try:
|
|
global ollama
|
|
if ollama is not None and hasattr(ollama, "stop"):
|
|
ollama.stop()
|
|
|
|
is_running = ollama.is_running() if (ollama is not None and hasattr(ollama, "is_running")) else False
|
|
return {"status": "stopped" if not is_running else "still_running", "running": is_running}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# -------------------------
|
|
# Vite Dev Server (Settings toggle) - dev-only convenience, not part of the
|
|
# single-process production path. Runs via a thread so npm's own startup time
|
|
# doesn't block the event loop, matching the Ollama start/stop pattern above.
|
|
# -------------------------
|
|
@app.get("/frontend/status")
|
|
async def frontend_status_endpoint():
|
|
return {"running": await _asyncio.to_thread(_frontend_manager.is_running)}
|
|
|
|
|
|
@app.post("/frontend/start")
|
|
async def frontend_start_endpoint():
|
|
try:
|
|
return await _asyncio.to_thread(_frontend_manager.start)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/frontend/stop")
|
|
async def frontend_stop_endpoint():
|
|
try:
|
|
return await _asyncio.to_thread(_frontend_manager.stop)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# -------------------------
|
|
# Playbooks List (existing)
|
|
# -------------------------
|
|
@app.get("/playbooks")
|
|
async def get_playbooks():
|
|
try:
|
|
playbook_list = playbook_store.all_playbooks()
|
|
return {
|
|
"playbooks": [
|
|
{
|
|
"id": p.id,
|
|
"title": p.title,
|
|
"goal": p.goal,
|
|
"instructions": getattr(p, "instructions", ""),
|
|
"tags": getattr(p, "tags", []),
|
|
"tools": getattr(p, "tools", []),
|
|
"model": getattr(p, "model", ""),
|
|
}
|
|
for p in playbook_list
|
|
]
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# -------------------------
|
|
# Playbook retrieve / update / replace (new)
|
|
# -------------------------
|
|
def _find_playbook_by_id(playbook_id: str) -> Tuple[Optional[Any], Optional[str]]:
|
|
"""
|
|
Try common store getters, then fall back to scanning store.all_playbooks().
|
|
Returns (playbook_obj, key) where key is the identifier used by store if applicable.
|
|
"""
|
|
try:
|
|
pb = playbook_store.get_playbook(playbook_id)
|
|
if pb:
|
|
return pb, playbook_id
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
for p in playbook_store.all_playbooks():
|
|
pid = str(getattr(p, "id", None) or "")
|
|
if pid == str(playbook_id):
|
|
return p, pid
|
|
except Exception:
|
|
pass
|
|
|
|
return None, None
|
|
|
|
|
|
def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Persist a playbook dict to the store by converting to PlaybookItem.
|
|
"""
|
|
try:
|
|
# Preserve existing order on update; use provided order (or tail) on create
|
|
existing = playbook_store.get_playbook(str(playbook_dict["id"]))
|
|
order = existing.order if existing else playbook_dict.get("order", len(playbook_store.all_playbooks()))
|
|
|
|
playbook_item = PlaybookItem(
|
|
id=str(playbook_dict["id"]),
|
|
title=playbook_dict.get("title", ""),
|
|
goal=playbook_dict.get("goal", ""),
|
|
instructions=playbook_dict.get("instructions", ""),
|
|
tags=playbook_dict.get("tags", []),
|
|
tools=playbook_dict.get("tools", []),
|
|
model=playbook_dict.get("model", ""),
|
|
order=order
|
|
)
|
|
|
|
playbook_store.add_playbook(playbook_item)
|
|
|
|
# Return as dict
|
|
return {
|
|
"id": playbook_item.id,
|
|
"title": playbook_item.title,
|
|
"goal": playbook_item.goal,
|
|
"instructions": playbook_item.instructions,
|
|
"tags": playbook_item.tags,
|
|
"tools": playbook_item.tools,
|
|
"model": playbook_item.model,
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
|
|
|
@app.post("/playbooks/reorder")
|
|
async def reorder_playbooks(payload: Dict[str, Any] = Body(...)):
|
|
"""
|
|
Accepts {"ids": ["id1", "id2", "id3"]} in the desired order.
|
|
"""
|
|
try:
|
|
ids = payload.get("ids", [])
|
|
if not ids:
|
|
raise HTTPException(status_code=400, detail="Missing ids list")
|
|
playbook_store.reorder_playbooks(ids)
|
|
return {"status": "reordered"}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.post("/playbooks")
|
|
async def create_playbook(payload: Dict[str, Any] = Body(...)):
|
|
try:
|
|
if not payload.get("title") or not payload.get("goal") or not payload.get("instructions"):
|
|
raise HTTPException(status_code=400, detail="Missing required fields: title, goal, instructions")
|
|
payload["id"] = str(_uuid.uuid4())
|
|
payload.setdefault("tags", [])
|
|
created = _persist_playbook(payload)
|
|
return created
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/playbooks/{id}")
|
|
async def get_playbook(id: UUID):
|
|
try:
|
|
pb, _ = _find_playbook_by_id(str(id))
|
|
if not pb:
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
|
|
# Build dict in the exact order you want
|
|
result = {
|
|
"id": getattr(pb, "id", None) or (pb.get("id") if isinstance(pb, dict) else None),
|
|
"title": getattr(pb, "title", None) or (pb.get("title") if isinstance(pb, dict) else None),
|
|
"goal": getattr(pb, "goal", None) or (pb.get("goal") if isinstance(pb, dict) else None),
|
|
"instructions": getattr(pb, "instructions", "") or (pb.get("instructions") if isinstance(pb, dict) else ""),
|
|
"tags": getattr(pb, "tags", []) or (pb.get("tags") if isinstance(pb, dict) else []),
|
|
"tools": getattr(pb, "tools", []) or (pb.get("tools") if isinstance(pb, dict) else []),
|
|
"model": getattr(pb, "model", "") or (pb.get("model") if isinstance(pb, dict) else ""),
|
|
}
|
|
return result
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.patch("/playbooks/{id}")
|
|
async def patch_playbook(id: UUID, payload: Dict[str, Any] = Body(...)):
|
|
"""
|
|
Partial update: accepts a JSON object with fields to update (e.g., {"instructions":"..."}).
|
|
"""
|
|
try:
|
|
pb, key = _find_playbook_by_id(str(id))
|
|
if not pb:
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
|
|
# Normalize existing to dict
|
|
if isinstance(pb, dict):
|
|
existing = dict(pb)
|
|
else:
|
|
existing = {k: getattr(pb, k) for k in ("id", "title", "goal", "instructions", "tags") if hasattr(pb, k)}
|
|
|
|
merged = {**existing, **payload}
|
|
|
|
# Try to persist via store API
|
|
updated = _persist_playbook(merged)
|
|
if not isinstance(updated, dict):
|
|
return {k: getattr(updated, k) for k in ("id", "title", "goal", "instructions", "tags") if hasattr(updated, k)}
|
|
return updated
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.put("/playbooks/{id}")
|
|
async def put_playbook(id: UUID, payload: Dict[str, Any] = Body(...)):
|
|
"""
|
|
Full replace: replace the playbook with the provided payload (payload should include title, goal, instructions, tags).
|
|
"""
|
|
try:
|
|
pb, key = _find_playbook_by_id(str(id))
|
|
if not pb:
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
|
|
payload["id"] = str(id)
|
|
|
|
replaced = _persist_playbook(payload)
|
|
if not isinstance(replaced, dict):
|
|
return {k: getattr(replaced, k) for k in ("id", "title", "goal", "instructions", "tags") if hasattr(replaced, k)}
|
|
return replaced
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.delete("/playbooks/{id}")
|
|
async def delete_playbook_endpoint(id: UUID):
|
|
try:
|
|
pb, _ = _find_playbook_by_id(str(id))
|
|
if not pb:
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
playbook_store.delete_playbook(str(id))
|
|
return {"status": "deleted"}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# -------------------------
|
|
# Speech-to-text (local Whisper)
|
|
# -------------------------
|
|
from . import stt as _stt
|
|
|
|
|
|
@app.get("/stt/status")
|
|
async def stt_status():
|
|
return {"available": _stt.available()}
|
|
|
|
|
|
@app.post("/stt")
|
|
async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
|
|
if not _stt.available():
|
|
raise HTTPException(status_code=503, detail="local STT (faster-whisper) not installed")
|
|
audio = payload.get("audio") or ""
|
|
if not audio:
|
|
raise HTTPException(status_code=400, detail="audio (base64) is required")
|
|
try:
|
|
text = await _asyncio.to_thread(_stt.transcribe_b64, audio)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"transcription failed: {e}")
|
|
return {"text": text}
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
# Projects / workspaces
|
|
# -------------------------
|
|
def _active_project() -> str:
|
|
"""The active project id, or '' for the unscoped 'All' view."""
|
|
return store.get_settings().get("active_project", "") or ""
|
|
|
|
|
|
@app.get("/projects")
|
|
async def list_projects():
|
|
return {"projects": store.list_projects(), "active": _active_project()}
|
|
|
|
|
|
@app.post("/projects")
|
|
async def create_project(payload: Dict[str, Any] = Body(...)):
|
|
name = (payload.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="name is required")
|
|
return store.create_project(name)
|
|
|
|
|
|
@app.patch("/projects/{project_id}")
|
|
async def update_project(project_id: str, payload: Dict[str, Any] = Body(...)):
|
|
"""Set the project's instructions — layered into the system prompt of every
|
|
chat bound to this project."""
|
|
if not store.set_project_instructions(project_id, payload.get("instructions") or ""):
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
return {"id": project_id, "instructions": payload.get("instructions") or ""}
|
|
|
|
|
|
@app.delete("/projects/{project_id}")
|
|
async def delete_project(project_id: str):
|
|
if not store.delete_project(project_id):
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
# If the deleted project was active, fall back to the "All" view.
|
|
if _active_project() == project_id:
|
|
store.update_settings({"active_project": ""})
|
|
return {"status": "deleted"}
|
|
|
|
|
|
# -------------------------
|
|
# Documents (RAG)
|
|
# -------------------------
|
|
@app.get("/documents")
|
|
async def list_documents():
|
|
# Scope to the active project; "" (All) lists everything.
|
|
active = _active_project()
|
|
return {"documents": store.list_documents(active if active else None)}
|
|
|
|
|
|
@app.post("/documents")
|
|
async def add_document(payload: Dict[str, Any] = Body(...)):
|
|
title = (payload.get("title") or "").strip()
|
|
content = (payload.get("content") or "").strip()
|
|
if not title or not content:
|
|
raise HTTPException(status_code=400, detail="title and content are required")
|
|
result = await store.add_document(title, content, get_ollama_manager().embed, _active_project())
|
|
if result["chunks"] == 0:
|
|
raise HTTPException(status_code=400, detail="no text to index")
|
|
return result
|
|
|
|
|
|
def _extract_text(filename: str, data: bytes) -> str:
|
|
"""Pull plain text from an uploaded file by extension. PDF/DOCX use
|
|
pure-Python parsers; anything else is decoded as UTF-8."""
|
|
import io
|
|
name = (filename or "").lower()
|
|
if name.endswith(".pdf"):
|
|
from pypdf import PdfReader
|
|
reader = PdfReader(io.BytesIO(data))
|
|
pages = reader.pages
|
|
if len(pages) > MAX_PDF_PAGES:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"PDF has {len(pages)} pages (max {MAX_PDF_PAGES})",
|
|
)
|
|
return "\n\n".join((page.extract_text() or "") for page in pages)
|
|
if name.endswith(".docx"):
|
|
import docx
|
|
doc = docx.Document(io.BytesIO(data))
|
|
return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
|
return data.decode("utf-8", errors="replace")
|
|
|
|
|
|
@app.post("/documents/upload")
|
|
async def upload_document(payload: Dict[str, Any] = Body(...)):
|
|
"""Ingest a file (pdf/docx/txt/md) sent as base64. Extracts text, then runs
|
|
the same chunk/embed pipeline as a pasted document."""
|
|
import base64
|
|
import os as _os
|
|
filename = (payload.get("filename") or "").strip()
|
|
b64 = payload.get("data") or ""
|
|
if not filename or not b64:
|
|
raise HTTPException(status_code=400, detail="filename and data are required")
|
|
try:
|
|
raw = base64.b64decode(b64)
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="data must be base64")
|
|
# Decoded-byte backstop: the body-size middleware caps the encoded payload,
|
|
# but base64 inflates ~33% and a chunked request has no Content-Length, so
|
|
# enforce the real decoded ceiling here too.
|
|
if len(raw) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"file too large: {len(raw)} bytes (max {MAX_UPLOAD_BYTES})",
|
|
)
|
|
# Bound concurrent ingests: extract + chunk + embed is CPU/model-heavy.
|
|
with _UPLOAD_INFLIGHT.slot():
|
|
try:
|
|
text = _extract_text(filename, raw).strip()
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"could not read {filename}: {e}")
|
|
if not text:
|
|
raise HTTPException(status_code=400, detail="no extractable text in file")
|
|
title = _os.path.splitext(_os.path.basename(filename))[0] or filename
|
|
return await store.add_document(title, text, get_ollama_manager().embed, _active_project())
|
|
|
|
|
|
@app.get("/documents/{doc_id}")
|
|
async def get_document(doc_id: str):
|
|
chunks = store.get_document(doc_id)
|
|
if not chunks:
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
return {"doc_id": doc_id, "chunks": chunks}
|
|
|
|
|
|
@app.delete("/documents/{doc_id}")
|
|
async def delete_document(doc_id: str):
|
|
if not store.delete_document(doc_id):
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
return {"status": "deleted"}
|
|
|
|
|
|
# -------------------------
|
|
# Unified Search
|
|
# -------------------------
|
|
# -------------------------
|
|
# Conversations
|
|
# -------------------------
|
|
@app.get("/conversations")
|
|
async def get_conversations(q: Optional[str] = None, project: Optional[str] = None):
|
|
try:
|
|
conversations = store.all_conversations()
|
|
if project is not None:
|
|
conversations = [c for c in conversations if c.project_id == project]
|
|
if q:
|
|
q_lower = q.lower()
|
|
conversations = [
|
|
c for c in conversations
|
|
if any(q_lower in m.content.lower() for m in c.messages)
|
|
or q_lower in c.preview.lower()
|
|
]
|
|
return {
|
|
"conversations": [
|
|
{
|
|
"id": c.id,
|
|
"timestamp": c.created_at,
|
|
"updated_at": c.updated_at,
|
|
"preview": c.preview,
|
|
"title": c.title,
|
|
"project_id": c.project_id,
|
|
}
|
|
for c in conversations
|
|
]
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/conversations/export")
|
|
async def export_conversations(min_turns: int = 1, conversation_id: Optional[str] = None):
|
|
"""Export conversations as ShareGPT JSONL for fine-tuning.
|
|
|
|
Each line is one conversation:
|
|
{"conversations": [{"from": "human", "value": "..."}, {"from": "gpt", "value": "..."}]}
|
|
|
|
Query params:
|
|
min_turns — minimum user/assistant exchanges to include (default 1)
|
|
conversation_id — export just this one conversation (default: all)
|
|
"""
|
|
from fastapi.responses import Response
|
|
import datetime
|
|
|
|
if conversation_id:
|
|
one = store.get_conversation(conversation_id)
|
|
conversations = [one] if one else []
|
|
else:
|
|
conversations = store.all_conversations()
|
|
lines = []
|
|
|
|
for conv in conversations:
|
|
msgs = [m for m in conv.messages if m.role in ("user", "assistant")]
|
|
if len(msgs) < 2:
|
|
continue
|
|
if sum(1 for m in msgs if m.role == "user") < min_turns:
|
|
continue
|
|
|
|
sharegpt_msgs = [
|
|
{"from": "human" if m.role == "user" else "gpt", "value": m.content}
|
|
for m in msgs
|
|
]
|
|
lines.append(_json.dumps({"conversations": sharegpt_msgs}))
|
|
|
|
date_str = datetime.date.today().isoformat()
|
|
filename = f"nexus-conversations-{date_str}.jsonl"
|
|
return Response(
|
|
content="\n".join(lines),
|
|
media_type="application/x-ndjson",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={filename}",
|
|
"X-Exported-Count": str(len(lines)),
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/conversations/{conversation_id}")
|
|
async def get_conversation(conversation_id: str):
|
|
try:
|
|
conv = store.get_conversation(conversation_id)
|
|
if not conv:
|
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
return {
|
|
"id": conv.id,
|
|
"timestamp": conv.created_at,
|
|
"title": conv.title,
|
|
"messages": [
|
|
{"role": m.role, "content": m.content, "timestamp": m.timestamp, "model": m.model, "tokens": m.tokens}
|
|
for m in conv.messages
|
|
]
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.patch("/conversations/{conversation_id}")
|
|
async def rename_conversation(conversation_id: str, payload: Dict[str, Any] = Body(...)):
|
|
"""Set a conversation's title and/or move it into a project."""
|
|
try:
|
|
conv = store.get_conversation(conversation_id)
|
|
if not conv:
|
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
if "project_id" in payload:
|
|
store.set_conversation_project(conversation_id, payload.get("project_id") or "")
|
|
title = (payload.get("title") or "").strip()
|
|
if title:
|
|
title = " ".join(title.split())[:120]
|
|
store.set_conversation_title(conversation_id, title)
|
|
elif "project_id" not in payload:
|
|
raise HTTPException(status_code=400, detail="Missing 'title'")
|
|
return {"id": conversation_id, "title": title or conv.title}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete("/conversations/{conversation_id}")
|
|
async def delete_conversation(conversation_id: str):
|
|
try:
|
|
conv = store.get_conversation(conversation_id)
|
|
if not conv:
|
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
store.delete_conversation(conversation_id)
|
|
return {"status": "deleted"}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# ── Icon branding routes ──────────────────────────────────────────────────────
|
|
|
|
_REPO_ASSETS = str(Path(__file__).resolve().parents[1] / "assets")
|
|
_ALLOWED_ICON_ROOTS = [
|
|
"/usr/share/icons",
|
|
"/usr/share/pixmaps",
|
|
"/usr/local/share/icons",
|
|
"/opt",
|
|
_os.path.expanduser("~/.local/share/icons"),
|
|
_os.path.expanduser("~/.icons"),
|
|
_REPO_ASSETS,
|
|
]
|
|
|
|
|
|
@app.get("/icons/apps")
|
|
async def list_icon_apps():
|
|
"""Return all installed applications with their icon paths."""
|
|
try:
|
|
from .icons.resolver import scan_apps
|
|
loop = _asyncio.get_event_loop()
|
|
apps = await loop.run_in_executor(None, scan_apps)
|
|
return {"apps": apps}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/icons/image")
|
|
async def get_icon_image(path: str):
|
|
"""Serve an icon file after verifying it's in an allowed root."""
|
|
real = _os.path.realpath(path)
|
|
if not any(real.startswith(r) for r in _ALLOWED_ICON_ROOTS):
|
|
raise HTTPException(status_code=403, detail="Path not allowed")
|
|
if not _os.path.isfile(real):
|
|
raise HTTPException(status_code=404, detail="Icon not found")
|
|
return FileResponse(real)
|
|
|
|
|
|
@app.post("/icons/brand")
|
|
async def brand_app_icon(payload: Dict[str, Any] = Body(...)):
|
|
"""Composite an app icon onto the NexusOS underlay tile."""
|
|
src_path = payload.get("src_path", "")
|
|
output_name = payload.get("output_name", "")
|
|
if not src_path or not output_name:
|
|
raise HTTPException(status_code=400, detail="src_path and output_name required")
|
|
frac = float(payload.get("frac", 0.60))
|
|
round_mask = bool(payload.get("round_mask", False))
|
|
nexus_ring = bool(payload.get("nexus_ring", False))
|
|
reload = bool(payload.get("reload", True))
|
|
category = str(payload.get("category", "apps"))
|
|
try:
|
|
from .icons.compositor import brand_icon
|
|
loop = _asyncio.get_event_loop()
|
|
await loop.run_in_executor(
|
|
None,
|
|
lambda: brand_icon(src_path, output_name, frac, round_mask, nexus_ring, reload, category),
|
|
)
|
|
return {"status": "ok", "output_name": output_name}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=403, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/icons/apply")
|
|
async def apply_icon_cache_route():
|
|
"""Rebuild the GTK icon cache and reload the panel."""
|
|
try:
|
|
from .icons.compositor import apply_icon_cache
|
|
loop = _asyncio.get_event_loop()
|
|
await loop.run_in_executor(None, apply_icon_cache)
|
|
return {"status": "ok"}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# -------------------------
|
|
# Built frontend (single-process mode)
|
|
# -------------------------
|
|
# Serve the Vite build so the backend IS the whole app — one process on :8000
|
|
# serves API + UI, no separate Node/Vite at runtime. Mounted LAST so every API
|
|
# route above wins; the SPA only catches what's left. Dev (no dist) skips this
|
|
# and uses the Vite dev server as before.
|
|
from fastapi.staticfiles import StaticFiles # noqa: E402
|
|
|
|
_DIST = Path(__file__).resolve().parent.parent / "interface" / "web" / "dist"
|
|
if _DIST.is_dir():
|
|
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="ui")
|
|
|
|
# -------------------------
|
|
# End of file
|
|
# -------------------------
|