forked from enderofwings/NexusOS
Reconciles 17 commits of this session's work (self-alteration tools, vendored Curry, slash-command dispatch, Windows toolchain/gate fixes) against origin/main's v1.2.0 sync (Projects/RAG scoping, a new modules/ system for mail and network, in-app updates, the standalone memory microservice folded into an in-process curator, KDE desktop theme overhaul). Nine real conflicts, each resolved by hand after reading both sides' actual diffs rather than picking one side wholesale: - synapse/tools.py, tests/test_tools.py: origin/main's diff here was small and clean (read_file/list_files, two new tests) despite git's diff3 flagging the whole file as one conflict blob -- reset to this branch's version and hand-spliced their addition in at the same points they used, rather than trying to reconcile a false 800-line conflict. Found and fixed a real bug while verifying: _list_files returned backslash-separated paths on Windows, which don't match the forward-slash glob patterns the tool's own schema documents. - synapse/main.py: kept this branch's cue-based standing advertisement of render_preview/run_snippet (independent of any playbook granting them) AND adopted origin/main's fix for routed reference playbooks not bringing their own tools along -- dropping either would have been a real regression, not just a style difference. Also: the standalone memory service (port 8001) is gone upstream, so its dead CORS/kill- target entries were removed; NEXUS_BACKEND_PORT parameterization and the manage_ollama-conditional kill logic (this branch's remote-Ollama support) were kept over origin/main's hardcoded equivalents. - synapse/memory/store.py: kept this branch's _delete_message_vectors helper (already reused elsewhere, batches to stay under SQLite's variable limit) over origin/main's inline duplicate of the same fix. - synapse/nexus_config.py, nexusos_cli/ncp.py: dropped the now-dead memory-service port/service entries; kept NEXUS_BACKEND_PORT env override and the manage_ollama-conditional kill-target list. - CLAUDE.md, README.md: merged both sides' additions, no real conflict. Found and fixed three more issues while independently verifying the merged tree, none of them mine or origin/main's alone -- only visible once both sides actually ran together: - modules/ (the new mail+network package) was never added to pyproject.toml's wheel `packages` list OR the sdist's `include` allowlist, so `from modules.registry import ROUTERS` in main.py would ImportError on any wheel install. Fixed both; bin/check.sh's packaging gate now asserts modules/ actually ships. tests/ test_packaging_deps.py's FIRST_PARTY/SHIPPED_PACKAGES sets were updated to recognize the new package. - tests/test_mail_creds.py's 0600-mode assertions are POSIX-only -- NTFS has no equivalent permission bits, so os.open(path, 0o600) on Windows just creates a normal file and stat.S_IMODE reports 0o666 regardless. Made the assertions platform-aware rather than skip real coverage (the temp-file-cleanup and password round-trip checks in the same test still run on Windows) or paper over a genuine OS limitation with a fake pass. - tests/test_kde_theme.py used bare Path.read_text() in fifteen places; Windows' default locale encoding (cp1252, not UTF-8) can't decode a real UTF-8 byte in the QML it reads, and did fail on one of the fifteen. Fixed all fifteen, not just the one that happened to trip today, since the other fourteen were equally fragile. Verified: full bin/check.sh reports OK end-to-end on this Windows checkout -- pytest (tests + management): 295 passed, 0 failed, 9 skipped; eslint clean; frontend node:test 57/57; PowerShell/shell parse clean; wheel + sdist pass twine check and now correctly carry modules/ (60 files, up from 52 pre-merge). synapse.main:app builds with 74 routes (up from 54 pre-merge, matching the new Projects/mail/ network endpoints).
1786 lines
73 KiB
Python
1786 lines
73 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"
|
|
)
|
|
|
|
# Static capability hint, appended to every system prompt. The live Preview UI
|
|
# is frontend-only (Markdown.jsx); the model reaches it by calling the standing
|
|
# `render_preview` tool (structured markup in, validated fence out) rather than
|
|
# freestyling an empty ```html stub. The tool schema carries the detailed
|
|
# requirements; this preamble just points at it.
|
|
# See synapse/tools.py: keep this short and imperative for the same reason the
|
|
# tool description is — anything narrated here comes back as the model's reply.
|
|
_RENDER_PREAMBLE = (
|
|
"\n\n---\nRender window: when a visual would help, call the `render_preview` "
|
|
f"tool with complete {_tools._lang_prose()} markup, then paste the returned "
|
|
"`fence` into your reply. The chat UI renders it live in a sandbox — inline "
|
|
"CSS/JS, no network.\n"
|
|
)
|
|
|
|
# The execution track's hint, on the same terms as the render one: offered only
|
|
# when the tool behind it is, for the same contamination reason. The distinction
|
|
# it has to carry is which track a request belongs to — a model that reaches for
|
|
# run_snippet to "preview" an HTML page gets a compile error, and one that
|
|
# reaches for render_preview to run a C program gets a plain code block.
|
|
_RUN_PREAMBLE = (
|
|
"\n\n---\nCode runner: when the answer depends on what code actually does, call "
|
|
f"the `run_snippet` tool with a complete {_tools.code_run.lang_prose()} program, "
|
|
"then paste the returned `fence` into your reply. It really runs, in a throwaway "
|
|
"directory with no network and a few seconds of CPU. Describe only the output it "
|
|
"returned.\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 as e:
|
|
_synapse_trace(f"⚠ auto model selection failed, falling back to default: {e}\n")
|
|
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 as e:
|
|
_synapse_trace(f"⚠ title generation failed: {e}\n")
|
|
return None
|
|
|
|
|
|
from .memory.store import store, MemoryItem
|
|
from .playbooks.store import playbook_store
|
|
from .curry_store import curry_db # noqa: F401 - import triggers Curry's own preload at startup
|
|
from .search import needs_web_search, web_search
|
|
from . import slash_commands as _slash_commands
|
|
|
|
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)
|
|
async def _slash_command_stream(
|
|
slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError",
|
|
conversation_id: str,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Dispatch a parsed slash-command and stream its result the same shape a
|
|
normal reply streams in — a single content chunk, `event: done`, nothing
|
|
else. No model call, no tool-loop, no approval round-trip: see
|
|
slash_commands.py for why that's the deliberate design here."""
|
|
if isinstance(slash, _slash_commands.SlashCommandError):
|
|
yield f"event: error\ndata: {_json.dumps({'detail': slash.text})}\n\n"
|
|
return
|
|
|
|
if slash.tool not in _tools.REGISTRY:
|
|
yield (
|
|
"event: error\ndata: "
|
|
f"{_json.dumps({'detail': f'unknown tool: {slash.tool}'})}\n\n"
|
|
)
|
|
return
|
|
|
|
yield f"event: status\ndata: {_json.dumps({'tool': slash.tool})}\n\n"
|
|
raw_result = await _tools.dispatch(slash.tool, slash.args)
|
|
|
|
# Tools that emit a fence (curry_*, edit_*, run_snippet) carry it as
|
|
# `"fence"` in their JSON result — reuse it verbatim so the existing
|
|
# nexus-run/nexus-edit renderers pick it up with no new frontend code.
|
|
# Anything else is shown as pretty-printed JSON.
|
|
content = raw_result
|
|
try:
|
|
parsed = _json.loads(raw_result)
|
|
if isinstance(parsed, dict) and isinstance(parsed.get("fence"), str):
|
|
content = parsed["fence"]
|
|
else:
|
|
content = _json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
store.add_message(conversation_id, "assistant", content)
|
|
yield f"data: {_json.dumps(content)}\n\n"
|
|
yield "event: done\ndata: {}\n\n"
|
|
|
|
|
|
# -------------------------
|
|
@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", "")
|
|
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
|
|
|
if not message:
|
|
raise HTTPException(status_code=400, detail="Missing 'message'")
|
|
|
|
# Direct tool invocation: /tool_name(arg=val, ...). A human typing this
|
|
# IS the approval, so it skips model selection, RAG/playbook context
|
|
# assembly, and the ask-policy round-trip entirely — see
|
|
# slash_commands.py for what it does and does not bypass.
|
|
_slash = _slash_commands.parse_slash_command(message)
|
|
if _slash is not None:
|
|
store.create_conversation(conversation_id, "")
|
|
store.add_message(conversation_id, "user", message)
|
|
_slash_inner = _slash_command_stream(_slash, conversation_id)
|
|
|
|
async def _slash_guarded() -> AsyncGenerator[str, None]:
|
|
try:
|
|
async for _chunk in _slash_inner:
|
|
yield _chunk
|
|
finally:
|
|
_CHAT_INFLIGHT.release()
|
|
|
|
_chat_slot_held = False
|
|
return StreamingResponse(_slash_guarded(), media_type="text/event-stream")
|
|
|
|
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", {})
|
|
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)
|
|
|
|
# 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
|
|
|
|
# Capability hint, on the same condition as the tool it points at (see
|
|
# the standing_schemas call below). It used to be unconditional, and a
|
|
# small model asked to summarise LRU caches answered that "the LRU cache
|
|
# is implemented using a tool called render_preview... renders it live in
|
|
# a sandbox" — this text, recited as fact. A hint for a tool that isn't
|
|
# being offered is pure contamination.
|
|
# Read once here rather than at the tools block below: the run-track
|
|
# hint and the run-track schema have to agree about whether the tool is
|
|
# on offer, and a second lookup is a second thing to keep in step.
|
|
_policy = app_settings.get("action_tool_policy", "off")
|
|
allow_actions = _policy != "off"
|
|
|
|
if _tools.wants_render_preview(message):
|
|
system_prompt = (system_prompt + _RENDER_PREAMBLE) if system_prompt else _RENDER_PREAMBLE.lstrip()
|
|
if allow_actions and _tools.wants_code_run(message):
|
|
system_prompt = (system_prompt + _RUN_PREAMBLE) if system_prompt else _RUN_PREAMBLE.lstrip()
|
|
|
|
# ── 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
|
|
|
|
# Tools: playbook allowlist (main playbook AND 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), plus
|
|
# render_preview/run_snippet on a cue even when no playbook grants them
|
|
# (always advertising render_preview forced a non-stream tool round on
|
|
# every chat and felt like "stuck thinking"). _policy/allow_actions were
|
|
# already computed above, in step with the capability-hint injection.
|
|
_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 [])]
|
|
))
|
|
schemas_by_name: dict = {}
|
|
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
|
|
for s in _tools.standing_schemas():
|
|
schemas_by_name[s["function"]["name"]] = s
|
|
# run_snippet rides the same cue mechanism but stays behind the action
|
|
# gate: it executes code on this machine. With the policy "off", "run
|
|
# this" gets an explanation and a code block, never a subprocess.
|
|
if allow_actions and _tools.wants_code_run(message):
|
|
for s in _tools.run_schemas():
|
|
schemas_by_name[s["function"]["name"]] = s
|
|
for s in _tools.schemas_for(_pb_tools, allow_actions):
|
|
schemas_by_name[s["function"]["name"]] = s
|
|
schemas = list(schemas_by_name.values())
|
|
if schemas:
|
|
metadata["tools"] = schemas
|
|
metadata["action_tool_policy"] = _policy
|
|
metadata["conversation_id"] = conversation_id
|
|
_granted = [
|
|
n for n in schemas_by_name
|
|
if not _tools.is_action(n) 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 as e:
|
|
_synapse_trace(f"⚠ conversation titling step failed: {e}\n")
|
|
|
|
# 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. Thin wrapper over the shared,
|
|
mergeable implementation in playbook_manager — this call site always does a
|
|
full replace (merge=False), matching the frontend form's behavior of always
|
|
submitting a complete object."""
|
|
try:
|
|
return playbook_manager.persist_playbook(dict(playbook_dict), merge=False)
|
|
except (ValueError, RuntimeError) as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
|
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(settings.assets_dir)
|
|
_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 = Path(_os.path.realpath(path))
|
|
allowed = any(
|
|
real == root or root in real.parents
|
|
for root in (Path(r).resolve() for r in _ALLOWED_ICON_ROOTS)
|
|
)
|
|
if not allowed:
|
|
raise HTTPException(status_code=403, detail="Path not allowed")
|
|
if not real.is_file():
|
|
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 = settings.web_dist_dir
|
|
if _DIST.is_dir():
|
|
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="ui")
|
|
|
|
# -------------------------
|
|
# End of file
|
|
# -------------------------
|