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:
@@ -4,6 +4,7 @@ import { Playbook } from "./Playbook";
|
||||
import { Models } from "./Models";
|
||||
import { Settings } from "./Settings";
|
||||
import { Memory } from "./Memory";
|
||||
import { Documents } from "./Documents";
|
||||
import { Logs } from "./Logs";
|
||||
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm("Delete this conversation?")) return;
|
||||
@@ -149,6 +159,7 @@ function App() {
|
||||
{ key: "playbook", label: "📖 Playbooks" },
|
||||
{ key: "models", label: "🤖 Models", badge: isModelPulling },
|
||||
{ key: "memory", label: "🧠 Memory" },
|
||||
{ key: "documents", label: "📄 Documents" },
|
||||
{ key: "logs", label: "📜 Logs" },
|
||||
{ key: "settings", label: "⚙️ Settings" },
|
||||
];
|
||||
@@ -295,6 +306,23 @@ function App() {
|
||||
}}>
|
||||
<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>
|
||||
<div style={{ display: "flex", gap: "0.35rem" }}>
|
||||
<button
|
||||
onClick={() => exportConversations(null, null)}
|
||||
title="Export all conversations (ShareGPT JSONL)"
|
||||
disabled={conversations.length === 0}
|
||||
style={{
|
||||
padding: "0.2rem 0.5rem",
|
||||
background: "#161616",
|
||||
color: conversations.length === 0 ? "#555" : "#bbb",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "6px",
|
||||
cursor: conversations.length === 0 ? "default" : "pointer",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
⤓ Export
|
||||
</button>
|
||||
<button
|
||||
onClick={startNewChat}
|
||||
title="New chat"
|
||||
@@ -311,6 +339,7 @@ function App() {
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
@@ -372,6 +401,22 @@ function App() {
|
||||
</div>
|
||||
{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
|
||||
onClick={(e) => renameConversation(e, conv)}
|
||||
title="Rename"
|
||||
@@ -434,6 +479,7 @@ function App() {
|
||||
)}
|
||||
{currentPage === "playbook" && <Playbook />}
|
||||
{currentPage === "memory" && <Memory />}
|
||||
{currentPage === "documents" && <Documents />}
|
||||
{currentPage === "logs" && <Logs />}
|
||||
{currentPage === "settings" && <Settings />}
|
||||
</main>
|
||||
|
||||
+195
-34
@@ -14,6 +14,46 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
const [copiedIdx, setCopiedIdx] = useState(null);
|
||||
const [lastStats, setLastStats] = 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 messagesEndRef = useRef(null);
|
||||
@@ -78,19 +118,22 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
setConversationId(crypto.randomUUID());
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setInput("");
|
||||
|
||||
// Add user message
|
||||
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
|
||||
|
||||
// Prepare assistant placeholder
|
||||
const assistantIndex = messages.length + 1;
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "" }]);
|
||||
const onImagePick = (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
files.forEach(file => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const b64 = String(reader.result || "").split(",")[1]; // strip data: prefix
|
||||
if (b64) setImages(prev => [...prev, { name: file.name, b64 }]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Abort previous stream if still open
|
||||
@@ -99,16 +142,14 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
abortRef.current = controller;
|
||||
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
history: historySnapshot.map(m => ({ role: m.role, content: m.content })),
|
||||
history,
|
||||
...(imgs && imgs.length ? { images: imgs } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -169,10 +210,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
pendingEventType = null;
|
||||
continue;
|
||||
}
|
||||
if (pendingEventType === "status") {
|
||||
try { setActiveTool(JSON.parse(payload).tool); } catch { /* ignore */ }
|
||||
pendingEventType = null;
|
||||
continue;
|
||||
}
|
||||
if (pendingEventType === "done") {
|
||||
// Answer is complete; re-enable input while the backend finishes
|
||||
// slow post-processing (title, memory) on the still-open stream.
|
||||
setLoading(false);
|
||||
setActiveTool(null);
|
||||
pendingEventType = null;
|
||||
continue;
|
||||
}
|
||||
@@ -197,6 +244,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
let token = payload;
|
||||
try { token = JSON.parse(payload); } catch { /* plain text fallback */ }
|
||||
|
||||
if (activeTool) setActiveTool(null); // tokens started -> tools done
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
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) => {
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
@@ -404,14 +500,34 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
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 & 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>
|
||||
: msg.content
|
||||
? <Markdown content={msg.content} />
|
||||
: <span style={{ color: "#aaa" }}>Thinking...</span>
|
||||
}
|
||||
</div>
|
||||
{msg.content && (
|
||||
{msg.content && editingIdx !== idx && (
|
||||
<div style={{ display: "flex", gap: "0.25rem", marginTop: "0.25rem" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(msg.content).then(() => {
|
||||
@@ -419,18 +535,36 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
setTimeout(() => setCopiedIdx(null), 1500);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
marginTop: "0.25rem",
|
||||
padding: "0.15rem 0.5rem",
|
||||
fontSize: "0.7rem",
|
||||
color: copiedIdx === idx ? "#4caf50" : "#555",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: copiedIdx === idx ? "#4caf50" : "#555", background: "transparent", border: "none", cursor: "pointer" }}
|
||||
>
|
||||
{copiedIdx === idx ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
{msg.role === "user" && !loading && (
|
||||
<button
|
||||
onClick={() => { setEditingIdx(idx); setEditText(msg.content); }}
|
||||
style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555", background: "transparent", border: "none", cursor: "pointer" }}
|
||||
>
|
||||
Edit
|
||||
</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>
|
||||
))
|
||||
@@ -439,7 +573,34 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
<div ref={messagesEndRef} />
|
||||
</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" }}>
|
||||
<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
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
@@ -460,19 +621,19 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={loading || !input.trim()}
|
||||
onClick={loading ? stopGeneration : sendMessage}
|
||||
disabled={!loading && !input.trim() && images.length === 0}
|
||||
style={{
|
||||
padding: "0.9rem 1.5rem",
|
||||
background: loading || !input.trim() ? "#555" : "#007acc",
|
||||
background: loading ? "#c0392b" : (!input.trim() && images.length === 0 ? "#555" : "#007acc"),
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
cursor: loading || !input.trim() ? "default" : "pointer",
|
||||
opacity: loading || !input.trim() ? 0.6 : 1,
|
||||
cursor: !loading && !input.trim() && images.length === 0 ? "default" : "pointer",
|
||||
opacity: !loading && !input.trim() && images.length === 0 ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Send
|
||||
{loading ? "Stop" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,8 @@ export function Playbook() {
|
||||
goal: full.goal || "",
|
||||
instructions: full.instructions || "",
|
||||
tags: (full.tags || []).join(", "),
|
||||
tools: (full.tools || []).join(", "),
|
||||
model: full.model || "",
|
||||
});
|
||||
} catch {
|
||||
setForm({
|
||||
@@ -71,13 +73,15 @@ export function Playbook() {
|
||||
goal: playbookSummary.goal || "",
|
||||
instructions: playbookSummary.instructions || "",
|
||||
tags: (playbookSummary.tags || []).join(", "),
|
||||
tools: (playbookSummary.tools || []).join(", "),
|
||||
model: playbookSummary.model || "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedId(null);
|
||||
setForm({ title: "", goal: "", instructions: "", tags: "" });
|
||||
setForm({ title: "", goal: "", instructions: "", tags: "", tools: "", model: "" });
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
@@ -91,6 +95,8 @@ export function Playbook() {
|
||||
goal: form.goal,
|
||||
instructions: form.instructions,
|
||||
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 safeId = isUpdate ? encodeURIComponent(String(selectedId)) : null;
|
||||
@@ -352,6 +358,20 @@ export function Playbook() {
|
||||
onChange={e => setForm(prev => ({ ...prev, tags: e.target.value }))}
|
||||
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
|
||||
rows={4}
|
||||
placeholder="Goal"
|
||||
|
||||
@@ -5,6 +5,7 @@ const DEFAULTS = {
|
||||
model: "",
|
||||
think: false, // Qwen3-style reasoning; off = much faster chat/memory
|
||||
temperature: 0.7,
|
||||
num_ctx: 0, // context window in tokens; 0 = model default
|
||||
system_prompt: "",
|
||||
timeout: 120,
|
||||
gpu_offload: -1, // -1 = Auto; 0–100 = percent of layers forced onto the GPU
|
||||
@@ -188,6 +189,26 @@ export function Settings() {
|
||||
</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" }}>
|
||||
<label style={labelStyle}>
|
||||
GPU Offload
|
||||
|
||||
+22
-9
@@ -301,20 +301,23 @@ def stop_service(svc: Service) -> bool:
|
||||
|
||||
# -- ollama --------------------------------------------------------------------
|
||||
|
||||
def start_ollama() -> None:
|
||||
def start_ollama(background: bool = False) -> None:
|
||||
"""Driven through the backend endpoint (the path the control panel uses)
|
||||
rather than launching the binary, because OllamaManager owns model and GPU
|
||||
selection. Requires the backend to be up.
|
||||
|
||||
Long timeout: the endpoint blocks until the model is warmed - weights read
|
||||
off disk into RAM/VRAM - which the web UI's own "Loading model..." button
|
||||
state calls out as routinely taking about a minute, not just the Ollama
|
||||
process launching."""
|
||||
background=True (`ncp start`): the endpoint returns as soon as `ollama serve`
|
||||
is up and warms the model in a background task, so boot finishes in seconds
|
||||
and the model loads concurrently into the first chat.
|
||||
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...")
|
||||
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:
|
||||
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:
|
||||
print(f" OLLAMA start failed: {e}")
|
||||
|
||||
@@ -350,14 +353,24 @@ def cmd_start(target) -> None:
|
||||
elif target in ("--ai", "-a"):
|
||||
start_ollama()
|
||||
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["backend"])
|
||||
wait_for_port(SERVICES["memory"])
|
||||
wait_for_port(SERVICES["backend"])
|
||||
start_ollama()
|
||||
t_services = time.perf_counter()
|
||||
launch(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:
|
||||
show_help()
|
||||
|
||||
|
||||
+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] = []
|
||||
|
||||
+105
-12
@@ -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,8 +664,21 @@ 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:
|
||||
@@ -653,17 +693,22 @@ 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:
|
||||
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:
|
||||
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
|
||||
warmed = await _warm_default_model()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -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,6 +796,8 @@ 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
|
||||
)
|
||||
|
||||
@@ -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,7 +1002,7 @@ 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:
|
||||
@@ -922,10 +1010,15 @@ async def export_conversations(min_turns: int = 1):
|
||||
|
||||
Query params:
|
||||
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
|
||||
|
||||
if conversation_id:
|
||||
one = store.get_conversation(conversation_id)
|
||||
conversations = [one] if one else []
|
||||
else:
|
||||
conversations = store.all_conversations()
|
||||
lines = []
|
||||
|
||||
|
||||
@@ -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("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -602,6 +618,106 @@ class PersistentMemoryStore:
|
||||
"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
|
||||
# -----------------------------
|
||||
@@ -611,6 +727,8 @@ class PersistentMemoryStore:
|
||||
# latency for chat/memory. Turn on for hard multi-step problems.
|
||||
"think": False,
|
||||
"temperature": 0.7,
|
||||
# Context window (tokens Ollama keeps in view). 0 → Ollama's model default.
|
||||
"num_ctx": 0,
|
||||
"system_prompt": "",
|
||||
"timeout": 120,
|
||||
# How long Ollama keeps the model resident in VRAM between messages.
|
||||
|
||||
@@ -175,7 +175,7 @@ def _preferred_model(models: list, preference) -> str | 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.
|
||||
|
||||
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
|
||||
if num_gpu is not None:
|
||||
opts["num_gpu"] = num_gpu
|
||||
if num_ctx: # 0 / None -> let Ollama use the model default
|
||||
opts["num_ctx"] = num_ctx
|
||||
return opts
|
||||
|
||||
|
||||
@@ -503,10 +505,16 @@ class OllamaManager:
|
||||
temperature: float | None = None,
|
||||
num_gpu: int | None = None,
|
||||
think: bool = False,
|
||||
tools: list | None = None,
|
||||
num_ctx: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""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>
|
||||
block is pure latency for chat/memory. Ollama ignores it for models that
|
||||
don't support thinking.
|
||||
@@ -516,12 +524,14 @@ class OllamaManager:
|
||||
if stream:
|
||||
return self._chat_stream(
|
||||
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:
|
||||
body: dict = {"model": model, "messages": messages, "stream": False}
|
||||
body["think"] = think
|
||||
opts = _chat_options(temperature, num_gpu)
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
opts = _chat_options(temperature, num_gpu, num_ctx)
|
||||
if opts:
|
||||
body["options"] = opts
|
||||
self._apply_keep_alive(body)
|
||||
@@ -529,7 +539,9 @@ class OllamaManager:
|
||||
r = await client.post(f"{self._api_base}/api/chat", json=body)
|
||||
elapsed = time.perf_counter() - start
|
||||
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:
|
||||
elapsed = time.perf_counter() - start
|
||||
_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,
|
||||
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."""
|
||||
try:
|
||||
body: dict = {"model": model, "messages": messages, "stream": True}
|
||||
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:
|
||||
body["options"] = opts
|
||||
self._apply_keep_alive(body)
|
||||
|
||||
@@ -22,6 +22,8 @@ class PlaybookItem(BaseModel):
|
||||
goal: str
|
||||
instructions: str
|
||||
tags: List[str] = []
|
||||
tools: List[str] = []
|
||||
model: str = ""
|
||||
order: int = 0
|
||||
|
||||
|
||||
@@ -43,6 +45,8 @@ class PlaybookFileStore:
|
||||
goal=data.get("goal", ""),
|
||||
instructions=data.get("instructions", ""),
|
||||
tags=data.get("tags", []),
|
||||
tools=data.get("tools", []),
|
||||
model=data.get("model", ""),
|
||||
order=data.get("order", 0),
|
||||
)
|
||||
except Exception:
|
||||
@@ -58,6 +62,8 @@ class PlaybookFileStore:
|
||||
"title": item.title,
|
||||
"goal": item.goal,
|
||||
"tags": item.tags,
|
||||
"tools": item.tools,
|
||||
"model": item.model,
|
||||
"order": item.order,
|
||||
"instructions": self._clean(item.instructions),
|
||||
}
|
||||
|
||||
@@ -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}"})
|
||||
@@ -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)) == []
|
||||
@@ -43,6 +43,14 @@ def test_keep_alive_pins_the_model():
|
||||
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():
|
||||
# The curator default is the config constant, not a copy of it.
|
||||
assert PersistentMemoryStore._SETTINGS_DEFAULTS["memory_model"] == DEFAULT_MEMORY_MODEL
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user