feat: playbook tools, RAG, vision, voice, chat controls, faster boot
- Tool-using playbooks: read-only tool registry (search_memory, search_history, search_documents, list_models, get_time), per-playbook allowlist, agentic loop, and a "running tool" status indicator. - Document ingest / RAG: documents table + chunker + embed/cosine retrieval reusing the existing stack; Documents page (upload/paste, viewer, delete); top-k chunks injected into the system prompt. - Vision chat: attach images, base64 into /api/chat. - Voice I/O: Web Speech dictation + read-aloud (browser-native, no backend). - Chat controls: stop, regenerate, edit-and-resend; num_ctx knob in Settings. - Per-playbook model override. - History polish: per-conversation + bulk ShareGPT export. - Faster ncp start: UI up first, Ollama warms in the background, with a per-phase timing readout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+112
-19
@@ -19,6 +19,7 @@ from .nexus_config import settings, VERSION, DEFAULT_CHAT_MODEL
|
||||
from .chat import generate_chat_response, stream_chat_response, _synapse_trace
|
||||
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
||||
from .playbook_manager import PlaybookManager
|
||||
from . import tools as _tools
|
||||
|
||||
def _render_memory_block(facts) -> str:
|
||||
"""Render memory items as grouped ## Section / - bullet markdown.
|
||||
@@ -230,11 +231,15 @@ 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(message)
|
||||
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
||||
_active_pb = playbooks.get_main_playbook()
|
||||
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
||||
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
||||
context = payload.get("context", {})
|
||||
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
||||
history = payload.get("history", [])
|
||||
temperature = payload.get("temperature", app_settings.get("temperature"))
|
||||
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
|
||||
think = payload.get("think", app_settings.get("think", False))
|
||||
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
||||
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
||||
@@ -280,6 +285,13 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
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=3)
|
||||
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
|
||||
|
||||
# Fetch web search results for time-sensitive queries
|
||||
search_results = ""
|
||||
if needs_web_search(message):
|
||||
@@ -338,7 +350,19 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
_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, "think": think}
|
||||
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature, "num_gpu": num_gpu, "num_ctx": num_ctx, "think": think}
|
||||
|
||||
# Vision: base64 images (data: prefix stripped by the client) ride on the user turn.
|
||||
images = payload.get("images")
|
||||
if images:
|
||||
metadata["images"] = images
|
||||
|
||||
# Tool-using playbook: advertise the active playbook's allowlisted tools.
|
||||
if _main_pb and getattr(_main_pb, "tools", None):
|
||||
schemas = _tools.schemas_for(_main_pb.tools)
|
||||
if schemas:
|
||||
metadata["tools"] = schemas
|
||||
_synapse_trace(f" TOOLS : {', '.join(_main_pb.tools)}\n")
|
||||
|
||||
# Persist conversation and user message before streaming
|
||||
store.create_conversation(conversation_id)
|
||||
@@ -363,6 +387,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
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
|
||||
response_chunks.append(chunk)
|
||||
yield f"data: {_json.dumps(chunk)}\n\n"
|
||||
except _asyncio.TimeoutError:
|
||||
@@ -637,13 +664,26 @@ async def ollama_status_endpoint():
|
||||
# -------------------------
|
||||
# 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():
|
||||
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
|
||||
@@ -653,19 +693,24 @@ async def ollama_start_endpoint():
|
||||
|
||||
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. This blocks
|
||||
# until the model is resident, so the UI's Start finishes only once the AI
|
||||
# is actually ready to answer. Best-effort — warm() never raises.
|
||||
# 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:
|
||||
try:
|
||||
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)
|
||||
warmed = warm_model
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
@@ -701,6 +746,8 @@ async def get_playbooks():
|
||||
"goal": p.goal,
|
||||
"instructions": getattr(p, "instructions", ""),
|
||||
"tags": getattr(p, "tags", []),
|
||||
"tools": getattr(p, "tools", []),
|
||||
"model": getattr(p, "model", ""),
|
||||
}
|
||||
for p in playbook_list
|
||||
]
|
||||
@@ -749,11 +796,13 @@ def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
goal=playbook_dict.get("goal", ""),
|
||||
instructions=playbook_dict.get("instructions", ""),
|
||||
tags=playbook_dict.get("tags", []),
|
||||
tools=playbook_dict.get("tools", []),
|
||||
model=playbook_dict.get("model", ""),
|
||||
order=order
|
||||
)
|
||||
|
||||
playbook_store.add_playbook(playbook_item)
|
||||
|
||||
|
||||
# Return as dict
|
||||
return {
|
||||
"id": playbook_item.id,
|
||||
@@ -761,6 +810,8 @@ def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"goal": playbook_item.goal,
|
||||
"instructions": playbook_item.instructions,
|
||||
"tags": playbook_item.tags,
|
||||
"tools": playbook_item.tools,
|
||||
"model": playbook_item.model,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
||||
@@ -810,6 +861,8 @@ async def get_playbook(id: UUID):
|
||||
"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:
|
||||
@@ -880,6 +933,41 @@ async def delete_playbook_endpoint(id: UUID):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Documents (RAG)
|
||||
# -------------------------
|
||||
@app.get("/documents")
|
||||
async def list_documents():
|
||||
return {"documents": store.list_documents()}
|
||||
|
||||
|
||||
@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)
|
||||
if result["chunks"] == 0:
|
||||
raise HTTPException(status_code=400, detail="no text to index")
|
||||
return result
|
||||
|
||||
|
||||
@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
|
||||
# -------------------------
|
||||
@@ -914,19 +1002,24 @@ async def get_conversations(q: Optional[str] = None):
|
||||
|
||||
|
||||
@app.get("/conversations/export")
|
||||
async def export_conversations(min_turns: int = 1):
|
||||
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)
|
||||
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
|
||||
|
||||
conversations = store.all_conversations()
|
||||
if conversation_id:
|
||||
one = store.get_conversation(conversation_id)
|
||||
conversations = [one] if one else []
|
||||
else:
|
||||
conversations = store.all_conversations()
|
||||
lines = []
|
||||
|
||||
for conv in conversations:
|
||||
|
||||
Reference in New Issue
Block a user