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:
jon
2026-07-23 13:31:48 -05:00
co-authored by Claude Opus 4.8
parent f514d3dbe5
commit f509034fdd
15 changed files with 1061 additions and 96 deletions
+61 -15
View File
@@ -4,6 +4,7 @@ import { Playbook } from "./Playbook";
import { Models } from "./Models"; import { Models } from "./Models";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { Memory } from "./Memory"; import { Memory } from "./Memory";
import { Documents } from "./Documents";
import { Logs } from "./Logs"; import { Logs } from "./Logs";
import { API_BASE } from "./config"; import { API_BASE } from "./config";
@@ -128,6 +129,15 @@ function App() {
} }
}; };
// Download ShareGPT JSONL — one conversation if convId given, else all.
const exportConversations = (e, convId) => {
if (e) e.stopPropagation();
const url = convId
? `${API_BASE}/conversations/export?conversation_id=${encodeURIComponent(convId)}`
: `${API_BASE}/conversations/export`;
window.open(url, "_blank");
};
const deleteConversation = async (e, conversationId) => { const deleteConversation = async (e, conversationId) => {
e.stopPropagation(); e.stopPropagation();
if (!window.confirm("Delete this conversation?")) return; if (!window.confirm("Delete this conversation?")) return;
@@ -149,6 +159,7 @@ function App() {
{ key: "playbook", label: "📖 Playbooks" }, { key: "playbook", label: "📖 Playbooks" },
{ key: "models", label: "🤖 Models", badge: isModelPulling }, { key: "models", label: "🤖 Models", badge: isModelPulling },
{ key: "memory", label: "🧠 Memory" }, { key: "memory", label: "🧠 Memory" },
{ key: "documents", label: "📄 Documents" },
{ key: "logs", label: "📜 Logs" }, { key: "logs", label: "📜 Logs" },
{ key: "settings", label: "⚙️ Settings" }, { key: "settings", label: "⚙️ Settings" },
]; ];
@@ -295,21 +306,39 @@ function App() {
}}> }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem", flexShrink: 0 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem", flexShrink: 0 }}>
<span style={{ fontSize: "0.75rem", color: "#888", textTransform: "uppercase", letterSpacing: "0.05em" }}>Chats</span> <span style={{ fontSize: "0.75rem", color: "#888", textTransform: "uppercase", letterSpacing: "0.05em" }}>Chats</span>
<button <div style={{ display: "flex", gap: "0.35rem" }}>
onClick={startNewChat} <button
title="New chat" onClick={() => exportConversations(null, null)}
style={{ title="Export all conversations (ShareGPT JSONL)"
padding: "0.2rem 0.55rem", disabled={conversations.length === 0}
background: "#161616", style={{
color: "#bbb", padding: "0.2rem 0.5rem",
border: "1px solid #2a2a2a", background: "#161616",
borderRadius: "6px", color: conversations.length === 0 ? "#555" : "#bbb",
cursor: "pointer", border: "1px solid #2a2a2a",
fontSize: "0.75rem", borderRadius: "6px",
}} cursor: conversations.length === 0 ? "default" : "pointer",
> fontSize: "0.75rem",
+ New }}
</button> >
Export
</button>
<button
onClick={startNewChat}
title="New chat"
style={{
padding: "0.2rem 0.55rem",
background: "#161616",
color: "#bbb",
border: "1px solid #2a2a2a",
borderRadius: "6px",
cursor: "pointer",
fontSize: "0.75rem",
}}
>
+ New
</button>
</div>
</div> </div>
<input <input
type="text" type="text"
@@ -372,6 +401,22 @@ function App() {
</div> </div>
{isHover && ( {isHover && (
<> <>
<button
onClick={(e) => exportConversations(e, conv.id)}
title="Export this conversation"
style={{
padding: "0.15rem 0.4rem",
background: "transparent",
color: "#888",
border: "1px solid #333",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.7rem",
flexShrink: 0,
}}
>
📤
</button>
<button <button
onClick={(e) => renameConversation(e, conv)} onClick={(e) => renameConversation(e, conv)}
title="Rename" title="Rename"
@@ -434,6 +479,7 @@ function App() {
)} )}
{currentPage === "playbook" && <Playbook />} {currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />} {currentPage === "memory" && <Memory />}
{currentPage === "documents" && <Documents />}
{currentPage === "logs" && <Logs />} {currentPage === "logs" && <Logs />}
{currentPage === "settings" && <Settings />} {currentPage === "settings" && <Settings />}
</main> </main>
+205 -44
View File
@@ -14,6 +14,46 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const [copiedIdx, setCopiedIdx] = useState(null); const [copiedIdx, setCopiedIdx] = useState(null);
const [lastStats, setLastStats] = useState(null); const [lastStats, setLastStats] = useState(null);
const [memoryToast, setMemoryToast] = useState(null); const [memoryToast, setMemoryToast] = useState(null);
const [images, setImages] = useState([]); // {name, b64} for vision models
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
const [editText, setEditText] = useState("");
const [listening, setListening] = useState(false); // mic dictation active
const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud
const recognitionRef = useRef(null);
// Web Speech API — browser-native, no backend/model. Absent on unsupported browsers.
const SpeechRec = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window;
const toggleMic = () => {
if (!SpeechRec) return;
if (listening) { recognitionRef.current?.stop(); return; }
const rec = new SpeechRec();
rec.lang = "en-US";
rec.interimResults = false;
rec.onresult = (e) => {
const text = Array.from(e.results).map(r => r[0].transcript).join(" ").trim();
if (text) setInput(prev => (prev ? prev + " " : "") + text);
};
rec.onend = () => setListening(false);
rec.onerror = () => setListening(false);
recognitionRef.current = rec;
setListening(true);
rec.start();
};
// Read a reply aloud (light markdown strip so symbols aren't spoken).
const speak = (idx, text) => {
if (!ttsSupported) return;
window.speechSynthesis.cancel();
if (speakingIdx === idx) { setSpeakingIdx(null); return; }
const clean = text.replace(/[*_`#>]/g, "").replace(/\[(.*?)\]\(.*?\)/g, "$1");
const u = new SpeechSynthesisUtterance(clean);
u.onend = () => setSpeakingIdx(null);
setSpeakingIdx(idx);
window.speechSynthesis.speak(u);
};
const abortRef = useRef(null); const abortRef = useRef(null);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
@@ -78,19 +118,22 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
setConversationId(crypto.randomUUID()); setConversationId(crypto.randomUUID());
}; };
const sendMessage = async () => { const onImagePick = (e) => {
if (!input.trim() || loading) return; const files = Array.from(e.target.files || []);
files.forEach(file => {
const userMessage = input.trim(); const reader = new FileReader();
setInput(""); reader.onload = () => {
const b64 = String(reader.result || "").split(",")[1]; // strip data: prefix
// Add user message if (b64) setImages(prev => [...prev, { name: file.name, b64 }]);
setMessages(prev => [...prev, { role: "user", content: userMessage }]); };
reader.readAsDataURL(file);
// Prepare assistant placeholder });
const assistantIndex = messages.length + 1; e.target.value = "";
setMessages(prev => [...prev, { role: "assistant", content: "" }]); };
// Shared streaming core: POST /chat/stream and fold tokens into the assistant
// message at `assistantIndex`. Used by send, regenerate, and edit-and-resend.
const streamAssistant = async ({ message, history, images: imgs, assistantIndex }) => {
setLoading(true); setLoading(true);
// Abort previous stream if still open // Abort previous stream if still open
@@ -99,16 +142,14 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
abortRef.current = controller; abortRef.current = controller;
try { try {
// Exclude the empty assistant placeholder that was just appended
const historySnapshot = messages.filter(m => m.content.trim() !== "");
const response = await fetch(`${API_BASE}/chat/stream`, { const response = await fetch(`${API_BASE}/chat/stream`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
message: userMessage, message,
conversation_id: conversationId, conversation_id: conversationId,
history: historySnapshot.map(m => ({ role: m.role, content: m.content })), history,
...(imgs && imgs.length ? { images: imgs } : {}),
}), }),
signal: controller.signal, signal: controller.signal,
}); });
@@ -169,10 +210,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
pendingEventType = null; pendingEventType = null;
continue; continue;
} }
if (pendingEventType === "status") {
try { setActiveTool(JSON.parse(payload).tool); } catch { /* ignore */ }
pendingEventType = null;
continue;
}
if (pendingEventType === "done") { if (pendingEventType === "done") {
// Answer is complete; re-enable input while the backend finishes // Answer is complete; re-enable input while the backend finishes
// slow post-processing (title, memory) on the still-open stream. // slow post-processing (title, memory) on the still-open stream.
setLoading(false); setLoading(false);
setActiveTool(null);
pendingEventType = null; pendingEventType = null;
continue; continue;
} }
@@ -197,6 +244,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
let token = payload; let token = payload;
try { token = JSON.parse(payload); } catch { /* plain text fallback */ } try { token = JSON.parse(payload); } catch { /* plain text fallback */ }
if (activeTool) setActiveTool(null); // tokens started -> tools done
setMessages(prev => { setMessages(prev => {
const updated = [...prev]; const updated = [...prev];
updated[assistantIndex] = { updated[assistantIndex] = {
@@ -219,6 +267,54 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
} }
}; };
const histBefore = (index) =>
messages.slice(0, index).filter(m => m.content.trim() !== "")
.map(m => ({ role: m.role, content: m.content }));
const sendMessage = async () => {
if ((!input.trim() && images.length === 0) || loading) return;
const userMessage = input.trim();
const outImages = images.map(i => i.b64);
setInput("");
setImages([]);
// Note any attached images so image-only turns aren't blank
const shownContent = outImages.length
? `${userMessage}${userMessage ? "\n\n" : ""}📷 ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached`
: userMessage;
const history = histBefore(messages.length);
const assistantIndex = messages.length + 1;
setMessages(prev => [...prev, { role: "user", content: shownContent }, { role: "assistant", content: "" }]);
await streamAssistant({ message: userMessage, history, images: outImages, assistantIndex });
};
const regenerate = async () => {
if (loading) return;
const lastUserIdx = messages.map(m => m.role).lastIndexOf("user");
if (lastUserIdx < 0) return;
const userMessage = messages[lastUserIdx].content;
const history = histBefore(lastUserIdx);
const base = messages.slice(0, lastUserIdx + 1); // drop the old assistant reply
setMessages([...base, { role: "assistant", content: "" }]);
await streamAssistant({ message: userMessage, history, images: [], assistantIndex: base.length });
};
const editAndResend = async (index, newText) => {
if (loading || !newText.trim()) return;
const history = histBefore(index);
const base = [...messages.slice(0, index), { role: "user", content: newText.trim() }];
setEditingIdx(null);
setMessages([...base, { role: "assistant", content: "" }]);
await streamAssistant({ message: newText.trim(), history, images: [], assistantIndex: base.length });
};
const stopGeneration = () => {
if (abortRef.current) abortRef.current.abort();
setLoading(false);
setActiveTool(null);
};
const updateAssistant = (index, text) => { const updateAssistant = (index, text) => {
setMessages(prev => { setMessages(prev => {
const updated = [...prev]; const updated = [...prev];
@@ -404,33 +500,71 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
boxSizing: "border-box", boxSizing: "border-box",
}} }}
> >
{msg.role === "user" {msg.role === "user" && editingIdx === idx ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
<textarea
value={editText}
onChange={e => setEditText(e.target.value)}
rows={3}
style={{ width: "100%", padding: "0.5rem", background: "#0a3a5c", color: "#fff", border: "1px solid #0099ff", borderRadius: "8px", resize: "vertical", boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: "0.4rem", justifyContent: "flex-end" }}>
<button onClick={() => editAndResend(idx, editText)} disabled={loading || !editText.trim()}
style={{ padding: "0.3rem 0.7rem", fontSize: "0.8rem", background: "#0099ff", color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer" }}>
Save &amp; resend
</button>
<button onClick={() => setEditingIdx(null)}
style={{ padding: "0.3rem 0.7rem", fontSize: "0.8rem", background: "transparent", color: "#cce", border: "1px solid #0099ff", borderRadius: "6px", cursor: "pointer" }}>
Cancel
</button>
</div>
</div>
) : msg.role === "user"
? <span style={{ whiteSpace: "pre-wrap" }}>{msg.content}</span> ? <span style={{ whiteSpace: "pre-wrap" }}>{msg.content}</span>
: msg.content : msg.content
? <Markdown content={msg.content} /> ? <Markdown content={msg.content} />
: <span style={{ color: "#aaa" }}>Thinking...</span> : <span style={{ color: "#aaa" }}>Thinking...</span>
} }
</div> </div>
{msg.content && ( {msg.content && editingIdx !== idx && (
<button <div style={{ display: "flex", gap: "0.25rem", marginTop: "0.25rem" }}>
onClick={() => { <button
navigator.clipboard.writeText(msg.content).then(() => { onClick={() => {
setCopiedIdx(idx); navigator.clipboard.writeText(msg.content).then(() => {
setTimeout(() => setCopiedIdx(null), 1500); setCopiedIdx(idx);
}); setTimeout(() => setCopiedIdx(null), 1500);
}} });
style={{ }}
marginTop: "0.25rem", style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: copiedIdx === idx ? "#4caf50" : "#555", background: "transparent", border: "none", cursor: "pointer" }}
padding: "0.15rem 0.5rem", >
fontSize: "0.7rem", {copiedIdx === idx ? "Copied!" : "Copy"}
color: copiedIdx === idx ? "#4caf50" : "#555", </button>
background: "transparent", {msg.role === "user" && !loading && (
border: "none", <button
cursor: "pointer", onClick={() => { setEditingIdx(idx); setEditText(msg.content); }}
}} style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555", background: "transparent", border: "none", cursor: "pointer" }}
> >
{copiedIdx === idx ? "Copied!" : "Copy"} Edit
</button> </button>
)}
{msg.role === "assistant" && !loading && idx === messages.length - 1 && (
<button
onClick={regenerate}
style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555", background: "transparent", border: "none", cursor: "pointer" }}
>
Regenerate
</button>
)}
{msg.role === "assistant" && ttsSupported && (
<button
onClick={() => speak(idx, msg.content)}
title={speakingIdx === idx ? "Stop" : "Read aloud"}
style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: speakingIdx === idx ? "#4caf50" : "#555", background: "transparent", border: "none", cursor: "pointer" }}
>
{speakingIdx === idx ? "🔊 Stop" : "🔊 Speak"}
</button>
)}
</div>
)} )}
</div> </div>
)) ))
@@ -439,7 +573,34 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{activeTool && (
<div style={{ marginBottom: "0.5rem", color: "#8ab4ff", fontSize: "0.9rem" }}>
🔧 running tool: {activeTool}
</div>
)}
{images.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
{images.map((img, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: "0.4rem", padding: "0.35rem 0.6rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "8px", color: "#ccc", fontSize: "0.85rem" }}>
📷 {img.name}
<button onClick={() => setImages(prev => prev.filter((_, j) => j !== i))}
style={{ background: "none", border: "none", color: "#ff8a80", cursor: "pointer", padding: 0 }}></button>
</span>
))}
</div>
)}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-end" }}> <div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-end" }}>
<label title="Attach image (needs a vision model)"
style={{ padding: "0.9rem", background: "#222", border: "1px solid #333", borderRadius: "10px", cursor: loading ? "default" : "pointer", opacity: loading ? 0.6 : 1, color: "#ccc" }}>
📎
<input type="file" accept="image/*" multiple onChange={onImagePick} disabled={loading} style={{ display: "none" }} />
</label>
{SpeechRec && (
<button onClick={toggleMic} title={listening ? "Stop dictation" : "Dictate (speech to text)"}
style={{ padding: "0.9rem", background: listening ? "#3a1a1a" : "#222", border: "1px solid " + (listening ? "#c0392b" : "#333"), borderRadius: "10px", cursor: "pointer", color: listening ? "#ff8a80" : "#ccc" }}>
{listening ? "🔴" : "🎤"}
</button>
)}
<textarea <textarea
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
@@ -460,19 +621,19 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
}} }}
/> />
<button <button
onClick={sendMessage} onClick={loading ? stopGeneration : sendMessage}
disabled={loading || !input.trim()} disabled={!loading && !input.trim() && images.length === 0}
style={{ style={{
padding: "0.9rem 1.5rem", padding: "0.9rem 1.5rem",
background: loading || !input.trim() ? "#555" : "#007acc", background: loading ? "#c0392b" : (!input.trim() && images.length === 0 ? "#555" : "#007acc"),
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: "8px", borderRadius: "8px",
cursor: loading || !input.trim() ? "default" : "pointer", cursor: !loading && !input.trim() && images.length === 0 ? "default" : "pointer",
opacity: loading || !input.trim() ? 0.6 : 1, opacity: !loading && !input.trim() && images.length === 0 ? 0.6 : 1,
}} }}
> >
Send {loading ? "Stop" : "Send"}
</button> </button>
</div> </div>
</div> </div>
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// RAG document manager: upload/paste text, chunked + embedded server-side, then
// retrieved into the chat system prompt. See synapse/memory/store.py.
export function Documents() {
const [docs, setDocs] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
const load = async () => {
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
} catch { /* offline — leave list as-is */ }
};
useEffect(() => { load(); }, []);
const onFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setContent(String(reader.result || ""));
if (!title.trim()) setTitle(file.name.replace(/\.[^.]+$/, ""));
};
reader.readAsText(file);
e.target.value = ""; // allow re-selecting the same file
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px" }}>
<h2 style={{ marginTop: 0 }}>📄 Documents</h2>
<p style={{ color: "#aaa", marginTop: 0 }}>
Upload or paste text. It's chunked, embedded, and pulled into chat as source
material when a message is relevant.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or load a .txt/.md file below"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".txt,.md,.markdown,text/*" onChange={onFile}
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px" }}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)}
style={{ padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
Delete
</button>
</div>
))}
</div>
)}
{viewing && (
<div onClick={() => setViewing(null)}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{viewing.title}</strong>
<button onClick={() => setViewing(null)}
style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
+21 -1
View File
@@ -64,6 +64,8 @@ export function Playbook() {
goal: full.goal || "", goal: full.goal || "",
instructions: full.instructions || "", instructions: full.instructions || "",
tags: (full.tags || []).join(", "), tags: (full.tags || []).join(", "),
tools: (full.tools || []).join(", "),
model: full.model || "",
}); });
} catch { } catch {
setForm({ setForm({
@@ -71,13 +73,15 @@ export function Playbook() {
goal: playbookSummary.goal || "", goal: playbookSummary.goal || "",
instructions: playbookSummary.instructions || "", instructions: playbookSummary.instructions || "",
tags: (playbookSummary.tags || []).join(", "), tags: (playbookSummary.tags || []).join(", "),
tools: (playbookSummary.tools || []).join(", "),
model: playbookSummary.model || "",
}); });
} }
}; };
const resetForm = () => { const resetForm = () => {
setSelectedId(null); setSelectedId(null);
setForm({ title: "", goal: "", instructions: "", tags: "" }); setForm({ title: "", goal: "", instructions: "", tags: "", tools: "", model: "" });
setMessage(""); setMessage("");
}; };
@@ -91,6 +95,8 @@ export function Playbook() {
goal: form.goal, goal: form.goal,
instructions: form.instructions, instructions: form.instructions,
tags: form.tags.split(",").map(t => t.trim()).filter(Boolean), tags: form.tags.split(",").map(t => t.trim()).filter(Boolean),
tools: (form.tools || "").split(",").map(t => t.trim()).filter(Boolean),
model: (form.model || "").trim(),
}; };
const isUpdate = Boolean(selectedId); const isUpdate = Boolean(selectedId);
const safeId = isUpdate ? encodeURIComponent(String(selectedId)) : null; const safeId = isUpdate ? encodeURIComponent(String(selectedId)) : null;
@@ -352,6 +358,20 @@ export function Playbook() {
onChange={e => setForm(prev => ({ ...prev, tags: e.target.value }))} onChange={e => setForm(prev => ({ ...prev, tags: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }} style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/> />
<input
type="text"
placeholder="Tools (comma separated): search_memory, search_history, search_documents, list_models, get_time"
value={form.tools}
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
<input
type="text"
placeholder="Model (blank = auto-select): e.g. mistral:latest"
value={form.model}
onChange={e => setForm(prev => ({ ...prev, model: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
<textarea <textarea
rows={4} rows={4}
placeholder="Goal" placeholder="Goal"
+21
View File
@@ -5,6 +5,7 @@ const DEFAULTS = {
model: "", model: "",
think: false, // Qwen3-style reasoning; off = much faster chat/memory think: false, // Qwen3-style reasoning; off = much faster chat/memory
temperature: 0.7, temperature: 0.7,
num_ctx: 0, // context window in tokens; 0 = model default
system_prompt: "", system_prompt: "",
timeout: 120, timeout: 120,
gpu_offload: -1, // -1 = Auto; 0100 = percent of layers forced onto the GPU gpu_offload: -1, // -1 = Auto; 0100 = percent of layers forced onto the GPU
@@ -188,6 +189,26 @@ export function Settings() {
</div> </div>
</div> </div>
<div style={{ marginTop: "1.25rem" }}>
<label style={labelStyle}>
Context Window (num_ctx)
<span style={{ float: "right", color: "#7aa", fontWeight: 600, textTransform: "none", letterSpacing: 0 }}>
{form.num_ctx > 0 ? `${form.num_ctx} tok` : "Model default"}
</span>
</label>
<input
type="number"
min="0"
step="512"
value={form.num_ctx}
onChange={e => update("num_ctx", parseInt(e.target.value) || 0)}
style={{ width: "100%", padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", boxSizing: "border-box" }}
/>
<div style={{ fontSize: "0.72rem", color: "#555", marginTop: "0.2rem" }}>
0 = use the model's default. Larger fits more context but uses more memory.
</div>
</div>
<div style={{ marginTop: "1.25rem" }}> <div style={{ marginTop: "1.25rem" }}>
<label style={labelStyle}> <label style={labelStyle}>
GPU Offload GPU Offload
+22 -9
View File
@@ -301,20 +301,23 @@ def stop_service(svc: Service) -> bool:
# -- ollama -------------------------------------------------------------------- # -- ollama --------------------------------------------------------------------
def start_ollama() -> None: def start_ollama(background: bool = False) -> None:
"""Driven through the backend endpoint (the path the control panel uses) """Driven through the backend endpoint (the path the control panel uses)
rather than launching the binary, because OllamaManager owns model and GPU rather than launching the binary, because OllamaManager owns model and GPU
selection. Requires the backend to be up. selection. Requires the backend to be up.
Long timeout: the endpoint blocks until the model is warmed - weights read background=True (`ncp start`): the endpoint returns as soon as `ollama serve`
off disk into RAM/VRAM - which the web UI's own "Loading model..." button is up and warms the model in a background task, so boot finishes in seconds
state calls out as routinely taking about a minute, not just the Ollama and the model loads concurrently into the first chat.
process launching.""" background=False (`ncp start --ai`): blocks until the model is warmed - weights
read off disk into RAM/VRAM, routinely about a minute - so "started" means the
AI can actually answer."""
print("Starting OLLAMA...") print("Starting OLLAMA...")
req = urllib.request.Request("http://localhost:8000/ollama/start", method="POST") url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "")
req = urllib.request.Request(url, method="POST")
try: try:
urllib.request.urlopen(req, timeout=180).read(1) urllib.request.urlopen(req, timeout=180).read(1)
print("NEXUS OLLAMA STARTED") print("NEXUS OLLAMA WARMING (background)" if background else "NEXUS OLLAMA STARTED")
except Exception as e: except Exception as e:
print(f" OLLAMA start failed: {e}") print(f" OLLAMA start failed: {e}")
@@ -350,14 +353,24 @@ def cmd_start(target) -> None:
elif target in ("--ai", "-a"): elif target in ("--ai", "-a"):
start_ollama() start_ollama()
elif target in (None, "", "all"): elif target in (None, "", "all"):
# Memory + backend in parallel, both ready before the frontend starts. # Bring the UI up first, then warm Ollama in the background — the model
# loads concurrently and into the first chat instead of blocking boot.
t0 = time.perf_counter()
launch(SERVICES["memory"]) launch(SERVICES["memory"])
launch(SERVICES["backend"]) launch(SERVICES["backend"])
wait_for_port(SERVICES["memory"]) wait_for_port(SERVICES["memory"])
wait_for_port(SERVICES["backend"]) wait_for_port(SERVICES["backend"])
start_ollama() t_services = time.perf_counter()
launch(SERVICES["frontend"]) launch(SERVICES["frontend"])
wait_for_port(SERVICES["frontend"]) wait_for_port(SERVICES["frontend"])
t_frontend = time.perf_counter()
start_ollama(background=True)
t_ollama = time.perf_counter()
print("\nBoot timing:")
print(f" services (memory+backend) : {t_services - t0:5.1f}s")
print(f" frontend (UI ready) : {t_frontend - t_services:5.1f}s")
print(f" ollama kickoff (bg warm) : {t_ollama - t_frontend:5.1f}s")
print(f" total to interactive : {t_ollama - t0:5.1f}s")
else: else:
show_help() show_help()
+54 -2
View File
@@ -8,6 +8,11 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
from .nexus_config import settings, DEFAULT_CHAT_MODEL from .nexus_config import settings, DEFAULT_CHAT_MODEL
from .ollama_manager import get_ollama_manager 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) 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 # Streaming implementation
# ------------------------- # -------------------------
@@ -143,6 +180,7 @@ async def stream_chat_response(
model = metadata.get("model") or DEFAULT_CHAT_MODEL model = metadata.get("model") or DEFAULT_CHAT_MODEL
temperature = metadata.get("temperature") temperature = metadata.get("temperature")
num_gpu = metadata.get("num_gpu") num_gpu = metadata.get("num_gpu")
num_ctx = metadata.get("num_ctx")
think = metadata.get("think", False) think = metadata.get("think", False)
# Build messages array for /api/chat multi-turn format # Build messages array for /api/chat multi-turn format
@@ -151,7 +189,21 @@ async def stream_chat_response(
messages.append({"role": "system", "content": system}) messages.append({"role": "system", "content": system})
for msg in (history or []): for msg in (history or []):
messages.append({"role": msg["role"], "content": msg["content"]}) 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) _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") _synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
try: 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) async_gen = _normalize_to_async_generator(maybe_iter)
buffer_parts: list[str] = [] buffer_parts: list[str] = []
+112 -19
View File
@@ -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 .chat import generate_chat_response, stream_chat_response, _synapse_trace
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from .playbook_manager import PlaybookManager from .playbook_manager import PlaybookManager
from . import tools as _tools
def _render_memory_block(facts) -> str: def _render_memory_block(facts) -> str:
"""Render memory items as grouped ## Section / - bullet markdown. """Render memory items as grouped ## Section / - bullet markdown.
@@ -230,11 +231,15 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
try: try:
message = payload.get("message", "") message = payload.get("message", "")
app_settings = store.get_settings() 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", {}) context = payload.get("context", {})
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4()) conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
history = payload.get("history", []) history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature")) 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)) think = payload.get("think", app_settings.get("think", False))
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1)) gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model) 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" 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 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 # Fetch web search results for time-sensitive queries
search_results = "" search_results = ""
if needs_web_search(message): if needs_web_search(message):
@@ -338,7 +350,19 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
_synapse_trace(f"{'' * 55}\n") _synapse_trace(f"{'' * 55}\n")
# ── end MindTrace pre-flight ────────────────────────────────────── # ── 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 # Persist conversation and user message before streaming
store.create_conversation(conversation_id) store.create_conversation(conversation_id)
@@ -363,6 +387,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
pass pass
yield f"event: meta\ndata: {chunk[8:]}\n\n" yield f"event: meta\ndata: {chunk[8:]}\n\n"
continue continue
if chunk.startswith("__status__"):
yield f"event: status\ndata: {_json.dumps({'tool': chunk[10:]})}\n\n"
continue
response_chunks.append(chunk) response_chunks.append(chunk)
yield f"data: {_json.dumps(chunk)}\n\n" yield f"data: {_json.dumps(chunk)}\n\n"
except _asyncio.TimeoutError: except _asyncio.TimeoutError:
@@ -637,13 +664,26 @@ async def ollama_status_endpoint():
# ------------------------- # -------------------------
# Ollama Start # 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") @app.post("/ollama/start")
async def ollama_start_endpoint(): async def ollama_start_endpoint(background: bool = False):
try: try:
global ollama global ollama
if ollama is None: if ollama is None:
ollama = initialize_ollama() ollama = initialize_ollama()
# start_async, NOT start: the sync one polls with time.sleep(1) up to 30 # 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 # 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 # 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 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 # Warm the default model so the first chat isn't a cold load.
# until the model is resident, so the UI's Start finishes only once the AI # background=False (UI "Start AI"): block until resident, so the button
# is actually ready to answer. Best-effort — warm() never raises. # 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 warmed = None
if is_running: if is_running:
try: if background:
s = store.get_settings() task = _asyncio.create_task(_warm_default_model())
warm_model = s.get("model") or await ollama.select_best_model() _warm_tasks.add(task) # hold a ref (asyncio only weak-refs tasks)
num_gpu = await ollama.resolve_num_gpu(s.get("gpu_offload", -1), warm_model) task.add_done_callback(_warm_tasks.discard)
await ollama.warm(warm_model, num_gpu) warmed = "background"
warmed = warm_model else:
except Exception: try:
pass warmed = await _warm_default_model()
except Exception:
pass
return {"status": "started" if is_running else "failed", "running": is_running, "warmed": warmed} return {"status": "started" if is_running else "failed", "running": is_running, "warmed": warmed}
except Exception as e: except Exception as e:
@@ -701,6 +746,8 @@ async def get_playbooks():
"goal": p.goal, "goal": p.goal,
"instructions": getattr(p, "instructions", ""), "instructions": getattr(p, "instructions", ""),
"tags": getattr(p, "tags", []), "tags": getattr(p, "tags", []),
"tools": getattr(p, "tools", []),
"model": getattr(p, "model", ""),
} }
for p in playbook_list 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", ""), goal=playbook_dict.get("goal", ""),
instructions=playbook_dict.get("instructions", ""), instructions=playbook_dict.get("instructions", ""),
tags=playbook_dict.get("tags", []), tags=playbook_dict.get("tags", []),
tools=playbook_dict.get("tools", []),
model=playbook_dict.get("model", ""),
order=order order=order
) )
playbook_store.add_playbook(playbook_item) playbook_store.add_playbook(playbook_item)
# Return as dict # Return as dict
return { return {
"id": playbook_item.id, "id": playbook_item.id,
@@ -761,6 +810,8 @@ def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
"goal": playbook_item.goal, "goal": playbook_item.goal,
"instructions": playbook_item.instructions, "instructions": playbook_item.instructions,
"tags": playbook_item.tags, "tags": playbook_item.tags,
"tools": playbook_item.tools,
"model": playbook_item.model,
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(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), "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 ""), "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 []), "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 return result
except HTTPException: except HTTPException:
@@ -880,6 +933,41 @@ async def delete_playbook_endpoint(id: UUID):
raise HTTPException(status_code=500, detail=str(e)) 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 # Unified Search
# ------------------------- # -------------------------
@@ -914,19 +1002,24 @@ async def get_conversations(q: Optional[str] = None):
@app.get("/conversations/export") @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. """Export conversations as ShareGPT JSONL for fine-tuning.
Each line is one conversation: Each line is one conversation:
{"conversations": [{"from": "human", "value": "..."}, {"from": "gpt", "value": "..."}]} {"conversations": [{"from": "human", "value": "..."}, {"from": "gpt", "value": "..."}]}
Query params: 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 from fastapi.responses import Response
import datetime 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 = [] lines = []
for conv in conversations: for conv in conversations:
+118
View File
@@ -150,6 +150,22 @@ class PersistentMemoryStore:
) )
""") """)
# RAG: one row per chunk. doc_id groups the chunks of a single uploaded
# document; embedding is a JSON vector (search_document-prefixed), set at
# ingest so retrieval needs no backfill.
cur.execute("""
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL,
title TEXT NOT NULL,
chunk_idx INTEGER NOT NULL,
text TEXT NOT NULL,
embedding TEXT,
created_at REAL NOT NULL
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_documents_doc_id ON documents (doc_id)")
cur.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -602,6 +618,106 @@ class PersistentMemoryStore:
"matches": matches, "matches": matches,
} }
# -----------------------------
# Documents (RAG)
# -----------------------------
@staticmethod
def _chunk_text(text: str, size: int = 800) -> List[str]:
"""Split on blank lines, then pack paragraphs into ~`size`-char chunks.
ponytail: naive char-based packing, no token counting or overlap good
enough for local recall; add overlap if retrieval misses boundaries."""
chunks: List[str] = []
buf = ""
for para in (p.strip() for p in text.split("\n\n")):
if not para:
continue
if buf and len(buf) + len(para) + 2 > size:
chunks.append(buf)
buf = para
else:
buf = f"{buf}\n\n{para}" if buf else para
if buf:
chunks.append(buf)
return chunks
async def add_document(self, title: str, content: str, embed_fn) -> dict:
"""Chunk, embed, and store a document. Returns {doc_id, chunks}."""
import uuid as _uuid
doc_id = str(_uuid.uuid4())
pieces = self._chunk_text(content)
now = time.time()
conn = self._connect()
cur = conn.cursor()
for i, piece in enumerate(pieces):
vec = await embed_fn(self._EMBED_DOC_PREFIX + piece[:2000])
cur.execute(
"INSERT INTO documents (id, doc_id, title, chunk_idx, text, embedding, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(str(_uuid.uuid4()), doc_id, title, i, piece,
json.dumps(vec) if vec else None, now),
)
conn.commit()
conn.close()
return {"doc_id": doc_id, "title": title, "chunks": len(pieces)}
def list_documents(self) -> List[dict]:
conn = self._connect()
cur = conn.cursor()
cur.execute("""
SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
FROM documents GROUP BY doc_id, title ORDER BY created_at DESC
""")
rows = cur.fetchall()
conn.close()
return [dict(r) for r in rows]
def get_document(self, doc_id: str) -> List[dict]:
"""Ordered chunks of one document: [{chunk_idx, text}]."""
conn = self._connect()
cur = conn.cursor()
cur.execute(
"SELECT chunk_idx, text FROM documents WHERE doc_id = ? ORDER BY chunk_idx",
(doc_id,),
)
rows = cur.fetchall()
conn.close()
return [dict(r) for r in rows]
def delete_document(self, doc_id: str) -> bool:
conn = self._connect()
cur = conn.cursor()
cur.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,))
deleted = cur.rowcount
conn.commit()
conn.close()
return deleted > 0
async def search_documents(
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
) -> List[dict]:
"""Top-`limit` document chunks most similar to `query`. Returns
[{title, text, score}]. Empty on no query / embeddings down."""
if not query or not query.strip():
return []
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
if not query_vec:
return []
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL")
scored = []
for row in cur.fetchall():
try:
vec = json.loads(row["embedding"])
except Exception:
continue
score = _cosine(query_vec, vec)
if score >= min_score:
scored.append({"title": row["title"], "text": row["text"], "score": score})
conn.close()
scored.sort(key=lambda d: d["score"], reverse=True)
return scored[:limit]
# ----------------------------- # -----------------------------
# Settings API # Settings API
# ----------------------------- # -----------------------------
@@ -611,6 +727,8 @@ class PersistentMemoryStore:
# latency for chat/memory. Turn on for hard multi-step problems. # latency for chat/memory. Turn on for hard multi-step problems.
"think": False, "think": False,
"temperature": 0.7, "temperature": 0.7,
# Context window (tokens Ollama keeps in view). 0 → Ollama's model default.
"num_ctx": 0,
"system_prompt": "", "system_prompt": "",
"timeout": 120, "timeout": 120,
# How long Ollama keeps the model resident in VRAM between messages. # How long Ollama keeps the model resident in VRAM between messages.
+18 -6
View File
@@ -175,7 +175,7 @@ def _preferred_model(models: list, preference) -> str | None:
return None return None
def _chat_options(temperature: float | None, num_gpu: int | None) -> dict: def _chat_options(temperature: float | None, num_gpu: int | None, num_ctx: int | None = None) -> dict:
"""Assemble the Ollama `options` block from the knobs we expose. """Assemble the Ollama `options` block from the knobs we expose.
Returns an empty dict when nothing is set so callers can omit `options` Returns an empty dict when nothing is set so callers can omit `options`
@@ -186,6 +186,8 @@ def _chat_options(temperature: float | None, num_gpu: int | None) -> dict:
opts["temperature"] = temperature opts["temperature"] = temperature
if num_gpu is not None: if num_gpu is not None:
opts["num_gpu"] = num_gpu opts["num_gpu"] = num_gpu
if num_ctx: # 0 / None -> let Ollama use the model default
opts["num_ctx"] = num_ctx
return opts return opts
@@ -503,10 +505,16 @@ class OllamaManager:
temperature: float | None = None, temperature: float | None = None,
num_gpu: int | None = None, num_gpu: int | None = None,
think: bool = False, think: bool = False,
tools: list | None = None,
num_ctx: int | None = None,
**kwargs, **kwargs,
): ):
"""Multi-turn chat via /api/chat (accepts a messages array with roles). """Multi-turn chat via /api/chat (accepts a messages array with roles).
When `tools` is given (non-stream only), the request advertises them and
the FULL message dict is returned (so the caller sees `tool_calls`);
otherwise the response content string is returned as before.
`think` toggles Qwen3-style reasoning. Default off: the hidden <think> `think` toggles Qwen3-style reasoning. Default off: the hidden <think>
block is pure latency for chat/memory. Ollama ignores it for models that block is pure latency for chat/memory. Ollama ignores it for models that
don't support thinking. don't support thinking.
@@ -516,12 +524,14 @@ class OllamaManager:
if stream: if stream:
return self._chat_stream( return self._chat_stream(
messages=messages, model=model, temperature=temperature, messages=messages, model=model, temperature=temperature,
num_gpu=num_gpu, think=think, start=start, num_gpu=num_gpu, think=think, start=start, num_ctx=num_ctx,
) )
else: else:
body: dict = {"model": model, "messages": messages, "stream": False} body: dict = {"model": model, "messages": messages, "stream": False}
body["think"] = think body["think"] = think
opts = _chat_options(temperature, num_gpu) if tools:
body["tools"] = tools
opts = _chat_options(temperature, num_gpu, num_ctx)
if opts: if opts:
body["options"] = opts body["options"] = opts
self._apply_keep_alive(body) self._apply_keep_alive(body)
@@ -529,7 +539,9 @@ class OllamaManager:
r = await client.post(f"{self._api_base}/api/chat", json=body) r = await client.post(f"{self._api_base}/api/chat", json=body)
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
r.raise_for_status() r.raise_for_status()
return r.json().get("message", {}).get("content", "") message = r.json().get("message", {})
# Tool callers need the whole message (tool_calls); others want content.
return message if tools else message.get("content", "")
except Exception as e: except Exception as e:
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
_log.exception("chat error after %.3fs: %s", elapsed, e) _log.exception("chat error after %.3fs: %s", elapsed, e)
@@ -643,12 +655,12 @@ class OllamaManager:
async def _chat_stream(self, messages: list, model: str, start: float, async def _chat_stream(self, messages: list, model: str, start: float,
temperature: float | None = None, num_gpu: int | None = None, temperature: float | None = None, num_gpu: int | None = None,
think: bool = False): think: bool = False, num_ctx: int | None = None):
"""Async generator streaming tokens, then a final __meta__ stats sentinel.""" """Async generator streaming tokens, then a final __meta__ stats sentinel."""
try: try:
body: dict = {"model": model, "messages": messages, "stream": True} body: dict = {"model": model, "messages": messages, "stream": True}
body["think"] = think # see chat(): reasoning off by default for speed body["think"] = think # see chat(): reasoning off by default for speed
opts = _chat_options(temperature, num_gpu) opts = _chat_options(temperature, num_gpu, num_ctx)
if opts: if opts:
body["options"] = opts body["options"] = opts
self._apply_keep_alive(body) self._apply_keep_alive(body)
+6
View File
@@ -22,6 +22,8 @@ class PlaybookItem(BaseModel):
goal: str goal: str
instructions: str instructions: str
tags: List[str] = [] tags: List[str] = []
tools: List[str] = []
model: str = ""
order: int = 0 order: int = 0
@@ -43,6 +45,8 @@ class PlaybookFileStore:
goal=data.get("goal", ""), goal=data.get("goal", ""),
instructions=data.get("instructions", ""), instructions=data.get("instructions", ""),
tags=data.get("tags", []), tags=data.get("tags", []),
tools=data.get("tools", []),
model=data.get("model", ""),
order=data.get("order", 0), order=data.get("order", 0),
) )
except Exception: except Exception:
@@ -58,6 +62,8 @@ class PlaybookFileStore:
"title": item.title, "title": item.title,
"goal": item.goal, "goal": item.goal,
"tags": item.tags, "tags": item.tags,
"tools": item.tools,
"model": item.model,
"order": item.order, "order": item.order,
"instructions": self._clean(item.instructions), "instructions": self._clean(item.instructions),
} }
+138
View File
@@ -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}"})
+57
View File
@@ -0,0 +1,57 @@
"""Document ingest / RAG store — hermetic (fake embeddings, temp DB)."""
import asyncio
import tempfile
from pathlib import Path
from synapse.memory.store import PersistentMemoryStore
def _store():
return PersistentMemoryStore(Path(tempfile.mkdtemp()) / "t.db")
def test_chunker_packs_and_splits():
s = _store()
one = s._chunk_text("short one.\n\nshort two.")
assert one == ["short one.\n\nshort two."] # both fit one chunk
many = s._chunk_text("a" * 700 + "\n\n" + "b" * 700)
assert len(many) == 2 # each paragraph near the size cap -> own chunk
async def _fake_embed(text):
kws = ["lego", "star", "wars", "gpu", "vega"]
v = [float(text.lower().count(k)) for k in kws]
return v if any(v) else None
def test_add_list_search_delete_roundtrip():
async def run():
s = _store()
r = await s.add_document(
"Guide",
"Beat the lego star wars boss with the force.\n\nUnrelated gpu vega notes.",
_fake_embed,
)
assert r["chunks"] >= 1
assert [d["title"] for d in s.list_documents()] == ["Guide"]
hits = await s.search_documents("lego star wars", _fake_embed, limit=2, min_score=0.1)
assert hits and "lego" in hits[0]["text"].lower()
# scores are sorted descending
assert all(hits[i]["score"] >= hits[i + 1]["score"] for i in range(len(hits) - 1))
# get_document returns ordered chunks for the viewer
chunks = s.get_document(r["doc_id"])
assert [c["chunk_idx"] for c in chunks] == list(range(len(chunks)))
assert s.delete_document(r["doc_id"]) is True
assert s.list_documents() == []
assert s.get_document(r["doc_id"]) == [] # gone -> no chunks
assert s.delete_document(r["doc_id"]) is False # already gone
asyncio.run(run())
def test_search_empty_query_returns_nothing():
s = _store()
assert asyncio.run(s.search_documents("", _fake_embed)) == []
+8
View File
@@ -43,6 +43,14 @@ def test_keep_alive_pins_the_model():
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"}) assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
def test_num_ctx_option_only_when_positive():
# 0 / None -> omit num_ctx so Ollama uses the model default; positive -> set it.
from synapse.ollama_manager import _chat_options
assert "num_ctx" not in _chat_options(None, None, 0)
assert "num_ctx" not in _chat_options(None, None, None)
assert _chat_options(None, None, 8192)["num_ctx"] == 8192
def test_default_models_have_one_source_of_truth(): def test_default_models_have_one_source_of_truth():
# The curator default is the config constant, not a copy of it. # The curator default is the config constant, not a copy of it.
assert PersistentMemoryStore._SETTINGS_DEFAULTS["memory_model"] == DEFAULT_MEMORY_MODEL assert PersistentMemoryStore._SETTINGS_DEFAULTS["memory_model"] == DEFAULT_MEMORY_MODEL
+74
View File
@@ -0,0 +1,74 @@
"""Tool-using playbook loop — the read-only MVP.
Run from nexus-core/ with the Promethean venv active: pytest -q
Hermetic: a fake manager stands in for Ollama, so no network/model is needed.
Guards the two pieces that would silently break the feature: the allowlist
filter and the tool-call loop's terminate-on-content behaviour.
"""
import asyncio
from synapse import tools
from synapse.chat import _run_tool_loop
def test_schemas_for_drops_unknown_names():
schemas = tools.schemas_for(["search_memory", "not_a_tool"])
names = [s["function"]["name"] for s in schemas]
assert names == ["search_memory"]
assert tools.schemas_for([]) == []
async def _drain(gen):
return [s async for s in gen]
class _FakeManager:
"""Returns a tool_call on the first chat() call, plain content after."""
def __init__(self):
self.calls = 0
async def chat(self, **_):
self.calls += 1
if self.calls == 1:
return {
"role": "assistant",
"tool_calls": [
{"function": {"name": "search_memory", "arguments": {"query": "gpu"}}}
],
}
return {"role": "assistant", "content": "here is the answer"}
def test_tool_loop_runs_tool_then_stops(monkeypatch):
async def fake_dispatch(name, args):
assert name == "search_memory"
assert args == {"query": "gpu"}
return '[{"section": "GPU", "text": "Vega 20 4GB"}]'
monkeypatch.setattr(tools, "dispatch", fake_dispatch)
messages = [{"role": "user", "content": "what gpu do i have?"}]
schemas = tools.schemas_for(["search_memory"])
statuses = asyncio.run(_drain(
_run_tool_loop(_FakeManager(), messages, "m", schemas, None, None)
))
# one status sentinel per tool run
assert statuses == ["__status__search_memory"]
# messages mutated in place: user -> assistant(tool_calls) -> tool(result);
# the final content turn is NOT appended (the streaming turn regenerates it).
assert [m["role"] for m in messages] == ["user", "assistant", "tool"]
assert "Vega 20" in messages[-1]["content"]
def test_tool_loop_degrades_when_model_returns_no_dict():
class _NoToolManager:
async def chat(self, **_):
return None # model can't do tools / errored
messages = [{"role": "user", "content": "hi"}]
before = list(messages)
statuses = asyncio.run(_drain(_run_tool_loop(_NoToolManager(), messages, "m", [{}], None, None)))
assert statuses == [] # no tool ran
assert messages == before # untouched -> falls back to a plain stream