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:
+54
-2
@@ -8,6 +8,11 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
|
||||
|
||||
from .nexus_config import settings, DEFAULT_CHAT_MODEL
|
||||
from .ollama_manager import get_ollama_manager
|
||||
from . import tools as _tools
|
||||
|
||||
# Cap on tool-call round-trips before the final answer — stops a confused small
|
||||
# model from looping forever.
|
||||
MAX_TOOL_STEPS = 5
|
||||
|
||||
|
||||
# -------------------------
|
||||
@@ -126,6 +131,38 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N
|
||||
yield str(item)
|
||||
|
||||
|
||||
async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu):
|
||||
"""Let the model call read-only tools before the final streamed answer.
|
||||
|
||||
Mutates `messages` IN PLACE, appending the assistant tool-call turns and
|
||||
their `role:"tool"` results, and yields a `__status__<tool>` sentinel before
|
||||
each tool runs (surfaced to the UI as a "running tool" indicator).
|
||||
Non-streamed — tool calls arrive as whole messages. Degrades to an untouched
|
||||
`messages` if the model can't do tool calling.
|
||||
|
||||
ponytail: the turn that finally returns content is thrown away and the answer
|
||||
is re-generated by the streaming turn (one wasted call). Simpler than
|
||||
streaming a maybe-already-complete message; revisit if latency matters.
|
||||
"""
|
||||
for _ in range(MAX_TOOL_STEPS):
|
||||
msg = await manager.chat(
|
||||
messages=messages, model=model, stream=False,
|
||||
temperature=temperature, num_gpu=num_gpu, tools=tool_schemas,
|
||||
)
|
||||
if not isinstance(msg, dict):
|
||||
break # None/error or no tool support -> fall back to plain stream
|
||||
calls = msg.get("tool_calls")
|
||||
if not calls:
|
||||
break
|
||||
messages.append(msg)
|
||||
for c in calls:
|
||||
fn = c.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
yield f"__status__{name}"
|
||||
result = await _tools.dispatch(name, fn.get("arguments"))
|
||||
messages.append({"role": "tool", "content": result})
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Streaming implementation
|
||||
# -------------------------
|
||||
@@ -143,6 +180,7 @@ async def stream_chat_response(
|
||||
model = metadata.get("model") or DEFAULT_CHAT_MODEL
|
||||
temperature = metadata.get("temperature")
|
||||
num_gpu = metadata.get("num_gpu")
|
||||
num_ctx = metadata.get("num_ctx")
|
||||
think = metadata.get("think", False)
|
||||
|
||||
# Build messages array for /api/chat multi-turn format
|
||||
@@ -151,7 +189,21 @@ async def stream_chat_response(
|
||||
messages.append({"role": "system", "content": system})
|
||||
for msg in (history or []):
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
user_msg: Dict[str, Any] = {"role": "user", "content": user_message}
|
||||
images = metadata.get("images") # base64 strings (no data: prefix) for vision models
|
||||
if images:
|
||||
user_msg["images"] = images
|
||||
messages.append(user_msg)
|
||||
|
||||
# Tool-using playbooks: run read-only tool calls, then stream the final answer
|
||||
# with their results already in the messages array.
|
||||
tool_schemas = metadata.get("tools")
|
||||
if tool_schemas:
|
||||
try:
|
||||
async for status in _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu):
|
||||
yield status
|
||||
except Exception:
|
||||
_logger.exception("tool loop failed; streaming without tools")
|
||||
|
||||
_logger.info("stream_chat_response: starting stream (model=%s, turns=%d, timeout=%s)", model, len(messages), timeout)
|
||||
|
||||
@@ -162,7 +214,7 @@ async def stream_chat_response(
|
||||
_synapse_trace(f"USR: {user_message}\n{'─' * 50}\n")
|
||||
|
||||
try:
|
||||
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature, num_gpu=num_gpu, think=think)
|
||||
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature, num_gpu=num_gpu, think=think, num_ctx=num_ctx)
|
||||
async_gen = _normalize_to_async_generator(maybe_iter)
|
||||
|
||||
buffer_parts: list[str] = []
|
||||
|
||||
Reference in New Issue
Block a user