Initial commit: NexusOS project + desktop theme baseline
Captures the known-good state after theme consolidation and consistency reconciliation: - NexusOS GTK/xfwm4/icon theme assets (assets/themes, management/Mint-Y-Nexus) - Tightened right-click menus, visible separators, no menu icons - assets/themes/install-theme.sh: idempotent restore of all wiring (symlinks, xfconf xsettings+xfwm4, GTK 3/4 settings.ini) - .gitignore excludes venv/ollama/models/runtime/db Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+728
@@ -0,0 +1,728 @@
|
||||
# main.py
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio as _asyncio
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Body
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from .nexus_config import settings
|
||||
from .chat import generate_chat_response, stream_chat_response
|
||||
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
||||
from .playbook_manager import PlaybookManager
|
||||
|
||||
def _render_memory_block(facts) -> str:
|
||||
"""Render memory items as grouped ## Section / - bullet markdown."""
|
||||
from collections import defaultdict
|
||||
sections: dict = defaultdict(list)
|
||||
for item in facts:
|
||||
sections[item.section or "General"].append(item.text)
|
||||
parts = []
|
||||
for section, lines in sections.items():
|
||||
parts.append(f"## {section}\n" + "\n".join(f" - {line}" for line in lines))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
async def _auto_select_model() -> str:
|
||||
try:
|
||||
s = store.get_settings()
|
||||
if s.get("model"):
|
||||
return s["model"]
|
||||
return await get_ollama_manager().select_best_model()
|
||||
except Exception:
|
||||
return getattr(settings, "default_model", None) or "mistral"
|
||||
|
||||
from .memory.store import store, MemoryItem
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
|
||||
MEMORY_SERVICE = "http://localhost:8001"
|
||||
|
||||
app = FastAPI(title="Synapse Backend", version="1.0")
|
||||
|
||||
# Alias for startup scripts
|
||||
sio_app = app
|
||||
|
||||
# --- CORS ---
|
||||
# allow_credentials=True is incompatible with allow_origins=["*"] — browsers
|
||||
# reject such responses. Since this is a local-only service with no cookies/auth,
|
||||
# wildcard origins without credentials is correct.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- GLOBALS ---
|
||||
playbooks = PlaybookManager()
|
||||
ollama = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Startup: Initialize Ollama service
|
||||
# -------------------------
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
global ollama
|
||||
try:
|
||||
ollama = await initialize_ollama_async()
|
||||
print("[Synapse] Ollama service is running.")
|
||||
except Exception as e:
|
||||
# Start in degraded mode — chat endpoints will return errors until Ollama
|
||||
# is brought up, but the backend itself stays alive so the control panel
|
||||
# and other endpoints remain reachable.
|
||||
print(f"[Synapse] WARNING: Ollama unavailable at startup: {e}")
|
||||
from .ollama_manager import OllamaManager
|
||||
ollama = OllamaManager()
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Root
|
||||
# -------------------------
|
||||
@app.get("/")
|
||||
async def root():
|
||||
try:
|
||||
status = ollama.get_status() if (ollama is not None and hasattr(ollama, "get_status")) else None
|
||||
except Exception:
|
||||
status = None
|
||||
return {"status": "online", "ollama": status}
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Chat (non-streaming)
|
||||
# -------------------------
|
||||
@app.post("/chat")
|
||||
async def chat_endpoint(payload: Dict[str, Any]):
|
||||
try:
|
||||
message = payload.get("message", "")
|
||||
app_settings = store.get_settings()
|
||||
model = payload.get("model") or await _auto_select_model()
|
||||
context = payload.get("context", {})
|
||||
history = payload.get("history", [])
|
||||
temperature = payload.get("temperature", app_settings.get("temperature"))
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
||||
|
||||
rendered_message = playbooks.render_prompt(message)
|
||||
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
||||
|
||||
memory_facts = store.all()
|
||||
if memory_facts:
|
||||
facts_block = _render_memory_block(memory_facts)
|
||||
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + facts_block) if system_prompt else facts_block
|
||||
|
||||
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature}
|
||||
|
||||
result = await generate_chat_response(
|
||||
user_message=rendered_message, metadata=metadata, history=history
|
||||
)
|
||||
return {"response": result.get("response", ""), "model": model}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Chat (streaming)
|
||||
# -------------------------
|
||||
@app.post("/chat/stream")
|
||||
async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
try:
|
||||
message = payload.get("message", "")
|
||||
app_settings = store.get_settings()
|
||||
model = payload.get("model") or await _auto_select_model()
|
||||
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"))
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
||||
|
||||
rendered_message = playbooks.render_prompt(message)
|
||||
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
||||
|
||||
# Append reference playbooks to the system prompt
|
||||
context_pbs = playbooks.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
|
||||
|
||||
# Inject persistent memory facts about Jon
|
||||
memory_facts = store.all()
|
||||
if memory_facts:
|
||||
facts_block = _render_memory_block(memory_facts)
|
||||
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + 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.
|
||||
past_context = store.search_conversations(message, 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
|
||||
|
||||
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature}
|
||||
|
||||
# Persist conversation and user message before streaming
|
||||
store.create_conversation(conversation_id)
|
||||
store.add_message(conversation_id, "user", rendered_message)
|
||||
|
||||
async def event_stream() -> AsyncGenerator[str, None]:
|
||||
response_chunks: list[str] = []
|
||||
meta: dict = {}
|
||||
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
|
||||
response_chunks.append(chunk)
|
||||
yield f"data: {chunk}\n\n"
|
||||
# Persist the completed assistant response with model and token stats
|
||||
if response_chunks:
|
||||
store.add_message(
|
||||
conversation_id, "assistant", "".join(response_chunks),
|
||||
model=meta.get("model") or model,
|
||||
tokens=meta.get("tokens"),
|
||||
)
|
||||
except Exception as e:
|
||||
yield f"event: error\ndata: {_json.dumps({'detail': str(e)})}\n\n"
|
||||
return
|
||||
|
||||
# Ask the memory service curator to evaluate this exchange
|
||||
if response_chunks:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=25.0) as _mc:
|
||||
r = await _mc.post(
|
||||
f"{MEMORY_SERVICE}/memories/extract",
|
||||
json={
|
||||
"user_message": rendered_message,
|
||||
"assistant_response": "".join(response_chunks),
|
||||
},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
if data.get("saved"):
|
||||
mem_result = {"section": data["section"], "text": data["text"]}
|
||||
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# -------------------------
|
||||
# Playbook Execution
|
||||
# -------------------------
|
||||
@app.post("/playbook/run")
|
||||
async def run_playbook(payload: Dict[str, Any]):
|
||||
name = payload.get("name")
|
||||
variables = payload.get("variables", {})
|
||||
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Missing playbook name")
|
||||
|
||||
try:
|
||||
result = playbooks.render_prompt(name, variables=variables)
|
||||
return {"result": result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Settings
|
||||
# -------------------------
|
||||
@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)
|
||||
return store.get_settings()
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Memory
|
||||
# -------------------------
|
||||
@app.get("/memory")
|
||||
async def get_memory():
|
||||
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags} for m in store.all()]}
|
||||
|
||||
|
||||
@app.post("/memory")
|
||||
async def add_memory(payload: Dict[str, Any] = Body(...)):
|
||||
text = (payload.get("text") or "").strip()
|
||||
if not text:
|
||||
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", []),
|
||||
)
|
||||
store.add(item)
|
||||
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
|
||||
|
||||
|
||||
@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).strip(),
|
||||
tags=payload.get("tags", existing.tags),
|
||||
)
|
||||
store.update(updated)
|
||||
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Models
|
||||
# -------------------------
|
||||
@app.get("/models")
|
||||
async def get_models():
|
||||
try:
|
||||
mgr = get_ollama_manager()
|
||||
models = await mgr.list_models()
|
||||
selected = await mgr.select_best_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")
|
||||
|
||||
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:
|
||||
if ollama is not None and hasattr(ollama, "get_status"):
|
||||
status = ollama.get_status()
|
||||
else:
|
||||
status = None
|
||||
return {"status": status}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# -------------------------
|
||||
# Ollama Start
|
||||
# -------------------------
|
||||
@app.post("/ollama/start")
|
||||
async def ollama_start_endpoint():
|
||||
try:
|
||||
global ollama
|
||||
if ollama is None:
|
||||
ollama = initialize_ollama()
|
||||
|
||||
# Try to start if it has a start method
|
||||
if hasattr(ollama, "start"):
|
||||
ollama.start()
|
||||
|
||||
is_running = ollama.is_running() if hasattr(ollama, "is_running") else True
|
||||
return {"status": "started" if is_running else "failed", "running": is_running}
|
||||
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))
|
||||
|
||||
# -------------------------
|
||||
# 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", []),
|
||||
}
|
||||
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", []),
|
||||
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,
|
||||
}
|
||||
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 []),
|
||||
}
|
||||
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))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Unified Search
|
||||
# -------------------------
|
||||
@app.get("/search")
|
||||
async def search_endpoint(q: Optional[str] = None):
|
||||
"""Search conversations and playbooks by keyword."""
|
||||
if not q or not q.strip():
|
||||
raise HTTPException(status_code=400, detail="Missing query parameter 'q'")
|
||||
try:
|
||||
conversations = store.search_conversations(q, limit=5)
|
||||
playbook_hits = playbook_store.search_playbooks(q)
|
||||
return {
|
||||
"query": q,
|
||||
"conversations": conversations,
|
||||
"playbooks": [
|
||||
{"id": p.id, "title": p.title, "goal": p.goal, "tags": p.tags}
|
||||
for p in playbook_hits
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Conversations
|
||||
# -------------------------
|
||||
@app.get("/conversations")
|
||||
async def get_conversations(q: Optional[str] = None):
|
||||
try:
|
||||
conversations = store.all_conversations()
|
||||
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,
|
||||
}
|
||||
for c in conversations
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@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,
|
||||
"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.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))
|
||||
|
||||
# -------------------------
|
||||
# End of file
|
||||
# -------------------------
|
||||
Reference in New Issue
Block a user