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:
@@ -0,0 +1,138 @@
|
||||
"""Read-only tools a playbook can call during chat.
|
||||
|
||||
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
||||
`message.tool_calls`, and this module is just the registry + dispatch. Every
|
||||
tool here only READS local state (SQLite, Ollama) — no side effects. The
|
||||
per-playbook allowlist (`PlaybookItem.tools`) is the security boundary; keep the
|
||||
registry read-only until the loop is trusted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from .memory.store import store
|
||||
from .ollama_manager import get_ollama_manager
|
||||
|
||||
|
||||
async def _search_memory(query: str = "", **_) -> str:
|
||||
q = (query or "").strip().lower()
|
||||
hits = [
|
||||
{"section": it.section, "text": it.text}
|
||||
for it in store.all()
|
||||
if not q
|
||||
or q in it.text.lower()
|
||||
or q in (it.section or "").lower()
|
||||
or any(q in t.lower() for t in it.tags)
|
||||
]
|
||||
return json.dumps(hits[:20])
|
||||
|
||||
|
||||
async def _search_history(query: str = "", **_) -> str:
|
||||
# Hybrid recall: semantic (embeddings) unioned with lexical, falls back to
|
||||
# lexical if embeddings are down. Same retrieval the chat endpoint uses.
|
||||
convs = await store.semantic_search_conversations(
|
||||
query or "", get_ollama_manager().embed, limit=3
|
||||
)
|
||||
return json.dumps([{"matches": c.get("matches", [])} for c in convs])
|
||||
|
||||
|
||||
async def _list_models(**_) -> str:
|
||||
return json.dumps(await get_ollama_manager().list_models())
|
||||
|
||||
|
||||
async def _search_documents(query: str = "", **_) -> str:
|
||||
hits = await store.search_documents(query or "", get_ollama_manager().embed, limit=3)
|
||||
return json.dumps([{"title": h["title"], "text": h["text"]} for h in hits])
|
||||
|
||||
|
||||
async def _get_time(**_) -> str:
|
||||
from datetime import datetime
|
||||
return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})
|
||||
|
||||
|
||||
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
"search_memory": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_memory",
|
||||
"description": "Search the user's persistent memory facts. Empty query returns all facts.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "text to match"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
_search_memory,
|
||||
),
|
||||
"search_history": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_history",
|
||||
"description": "Search past conversations for exchanges containing the query text.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_search_history,
|
||||
),
|
||||
"list_models": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_models",
|
||||
"description": "List the locally installed Ollama models.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
_list_models,
|
||||
),
|
||||
"search_documents": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_documents",
|
||||
"description": "Search the user's uploaded documents for relevant passages.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_search_documents,
|
||||
),
|
||||
"get_time": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_time",
|
||||
"description": "Get the current local date and time.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
_get_time,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def schemas_for(names: list[str]) -> list[dict]:
|
||||
"""Tool schemas for a playbook's allowlist; unknown names are dropped."""
|
||||
return [REGISTRY[n][0] for n in (names or []) if n in REGISTRY]
|
||||
|
||||
|
||||
async def dispatch(name: str, args: dict | None) -> str:
|
||||
"""Run a tool by name. Never raises — returns an error string on failure."""
|
||||
entry = REGISTRY.get(name)
|
||||
if not entry:
|
||||
return json.dumps({"error": f"unknown tool: {name}"})
|
||||
try:
|
||||
return await entry[1](**(args or {}))
|
||||
except Exception as e: # a broken tool must not kill the chat loop
|
||||
return json.dumps({"error": f"{name} failed: {e}"})
|
||||
Reference in New Issue
Block a user