forked from enderofwings/NexusOS
feat: workspaces, agentic action tools, local Whisper STT, vec recall
- Projects/workspaces: documents grouped into projects; chat RAG scopes to the active project. Switcher in the Documents page. - Agentic action tools: web_search, fetch_url, and remember (first write tool), allowlist-gated per playbook. - Local Whisper STT (faster-whisper, no torch): on-device dictation replacing the browser Web Speech API. POST /stt + GET /stt/status; browser fallback. - Vector index extended to conversation recall (message_vectors), with the brute-force cosine scan kept as the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,23 +19,64 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
|
||||
const [editText, setEditText] = useState("");
|
||||
const [listening, setListening] = useState(false); // mic dictation active
|
||||
const [transcribing, setTranscribing] = useState(false); // local STT running
|
||||
const [sttLocal, setSttLocal] = useState(false); // backend Whisper available
|
||||
const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud
|
||||
const recognitionRef = useRef(null);
|
||||
const mediaRecRef = 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 canRecord = typeof navigator !== "undefined" && navigator.mediaDevices && window.MediaRecorder;
|
||||
|
||||
const toggleMic = () => {
|
||||
if (!SpeechRec) return;
|
||||
if (listening) { recognitionRef.current?.stop(); return; }
|
||||
// Prefer local Whisper (on-device) over browser speech (Chrome routes audio to Google).
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/stt/status`).then(r => r.ok ? r.json() : null)
|
||||
.then(d => setSttLocal(Boolean(d && d.available))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const _appendTranscript = (text) => {
|
||||
if (text) setInput(prev => (prev ? prev + " " : "") + text);
|
||||
};
|
||||
|
||||
// Local path: record audio, POST to /stt (faster-whisper transcribes on-device).
|
||||
const startLocalDictation = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const rec = new MediaRecorder(stream);
|
||||
const chunks = [];
|
||||
rec.ondataavailable = e => e.data.size && chunks.push(e.data);
|
||||
rec.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
setListening(false);
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const b64 = await new Promise((res) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => res(String(fr.result).split(",")[1]);
|
||||
fr.readAsDataURL(new Blob(chunks, { type: rec.mimeType }));
|
||||
});
|
||||
const r = await fetch(`${API_BASE}/stt`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ audio: b64 }),
|
||||
});
|
||||
if (r.ok) _appendTranscript((await r.json()).text);
|
||||
} catch { /* ignore */ } finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
};
|
||||
mediaRecRef.current = rec;
|
||||
setListening(true);
|
||||
rec.start();
|
||||
} catch { setListening(false); }
|
||||
};
|
||||
|
||||
const startBrowserDictation = () => {
|
||||
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.onresult = (e) => _appendTranscript(Array.from(e.results).map(r => r[0].transcript).join(" ").trim());
|
||||
rec.onend = () => setListening(false);
|
||||
rec.onerror = () => setListening(false);
|
||||
recognitionRef.current = rec;
|
||||
@@ -43,6 +84,17 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
rec.start();
|
||||
};
|
||||
|
||||
const toggleMic = () => {
|
||||
if (transcribing) return;
|
||||
if (listening) {
|
||||
if (sttLocal && mediaRecRef.current) mediaRecRef.current.stop();
|
||||
else recognitionRef.current?.stop();
|
||||
return;
|
||||
}
|
||||
if (sttLocal && canRecord) startLocalDictation();
|
||||
else if (SpeechRec) startBrowserDictation();
|
||||
};
|
||||
|
||||
// Read a reply aloud (light markdown strip so symbols aren't spoken).
|
||||
const speak = (idx, text) => {
|
||||
if (!ttsSupported) return;
|
||||
@@ -617,10 +669,11 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
||||
📎
|
||||
<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 ? "🔴" : "🎤"}
|
||||
{((sttLocal && canRecord) || SpeechRec) && (
|
||||
<button onClick={toggleMic}
|
||||
title={transcribing ? "Transcribing…" : listening ? "Stop dictation" : sttLocal ? "Dictate (on-device Whisper)" : "Dictate (browser speech)"}
|
||||
style={{ padding: "0.9rem", background: listening ? "#3a1a1a" : "#222", border: "1px solid " + (listening ? "#c0392b" : "#333"), borderRadius: "10px", cursor: transcribing ? "default" : "pointer", color: listening ? "#ff8a80" : "#ccc" }}>
|
||||
{transcribing ? "⏳" : listening ? "🔴" : "🎤"}
|
||||
</button>
|
||||
)}
|
||||
<textarea
|
||||
|
||||
@@ -10,6 +10,43 @@ export function Documents() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [activeProject, setActiveProject] = useState(""); // "" = All
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/projects`);
|
||||
if (r.ok) { const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); }
|
||||
} catch { /* offline */ }
|
||||
};
|
||||
|
||||
// Switch the active workspace (persisted in settings; scopes chat RAG too).
|
||||
const setActive = async (pid) => {
|
||||
await fetch(`${API_BASE}/settings`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ active_project: pid }),
|
||||
});
|
||||
setActiveProject(pid);
|
||||
load();
|
||||
loadProjects();
|
||||
};
|
||||
|
||||
const newProject = async () => {
|
||||
const name = window.prompt("New project name:");
|
||||
if (!name || !name.trim()) return;
|
||||
const r = await fetch(`${API_BASE}/projects`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
});
|
||||
if (r.ok) { const p = await r.json(); await setActive(p.id); }
|
||||
};
|
||||
|
||||
const removeProject = async () => {
|
||||
if (!activeProject) return;
|
||||
if (!window.confirm("Delete this project? Its documents are kept but become unscoped.")) return;
|
||||
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
|
||||
await setActive("");
|
||||
};
|
||||
|
||||
const openDoc = async (doc) => {
|
||||
try {
|
||||
@@ -25,7 +62,7 @@ export function Documents() {
|
||||
} catch { /* offline — leave list as-is */ }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { load(); loadProjects(); }, []);
|
||||
|
||||
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
|
||||
// text. Base64 in JSON — no multipart dependency.
|
||||
@@ -106,6 +143,30 @@ export function Documents() {
|
||||
material when a message is relevant.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.2rem", flexWrap: "wrap" }}>
|
||||
<span style={{ color: "#888", fontSize: "0.85rem" }}>Workspace:</span>
|
||||
<select
|
||||
value={activeProject}
|
||||
onChange={e => (e.target.value === "__new__" ? newProject() : setActive(e.target.value))}
|
||||
style={{ padding: "0.5rem 0.7rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px" }}
|
||||
>
|
||||
<option value="">All documents</option>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.docs})</option>
|
||||
))}
|
||||
<option value="__new__">+ New project…</option>
|
||||
</select>
|
||||
{activeProject && (
|
||||
<button onClick={removeProject} title="Delete this project (documents kept)"
|
||||
style={{ padding: "0.4rem 0.7rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }}>
|
||||
Delete project
|
||||
</button>
|
||||
)}
|
||||
<span style={{ color: "#666", fontSize: "0.78rem" }}>
|
||||
{activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
|
||||
@@ -360,7 +360,7 @@ export function Playbook() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tools (comma separated): search_memory, search_history, search_documents, list_models, get_time"
|
||||
placeholder="Tools: search_memory, search_history, search_documents, list_models, get_time, web_search, fetch_url, remember"
|
||||
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" }}
|
||||
|
||||
@@ -43,6 +43,8 @@ python-docx
|
||||
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
|
||||
# stays as the fallback when the host Python can't load extensions.
|
||||
sqlite-vec
|
||||
# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
|
||||
faster-whisper
|
||||
|
||||
# Documentation Support
|
||||
markdown-it-py
|
||||
|
||||
@@ -15,6 +15,8 @@ python-docx
|
||||
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
|
||||
# stays as the fallback when the host Python can't load extensions.
|
||||
sqlite-vec
|
||||
# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
|
||||
faster-whisper
|
||||
|
||||
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
|
||||
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
|
||||
|
||||
+62
-3
@@ -290,6 +290,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
message, get_ollama_manager().embed,
|
||||
limit=app_settings.get("rag_top_k", 3),
|
||||
min_score=app_settings.get("rag_min_score", 0.6),
|
||||
project_id=app_settings.get("active_project", "") or None,
|
||||
)
|
||||
doc_titles: list = []
|
||||
if doc_hits:
|
||||
@@ -943,12 +944,70 @@ async def delete_playbook_endpoint(id: UUID):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Speech-to-text (local Whisper)
|
||||
# -------------------------
|
||||
from . import stt as _stt
|
||||
|
||||
|
||||
@app.get("/stt/status")
|
||||
async def stt_status():
|
||||
return {"available": _stt.available()}
|
||||
|
||||
|
||||
@app.post("/stt")
|
||||
async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
|
||||
if not _stt.available():
|
||||
raise HTTPException(status_code=503, detail="local STT (faster-whisper) not installed")
|
||||
audio = payload.get("audio") or ""
|
||||
if not audio:
|
||||
raise HTTPException(status_code=400, detail="audio (base64) is required")
|
||||
try:
|
||||
text = await _asyncio.to_thread(_stt.transcribe_b64, audio)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"transcription failed: {e}")
|
||||
return {"text": text}
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Projects / workspaces
|
||||
# -------------------------
|
||||
def _active_project() -> str:
|
||||
"""The active project id, or '' for the unscoped 'All' view."""
|
||||
return store.get_settings().get("active_project", "") or ""
|
||||
|
||||
|
||||
@app.get("/projects")
|
||||
async def list_projects():
|
||||
return {"projects": store.list_projects(), "active": _active_project()}
|
||||
|
||||
|
||||
@app.post("/projects")
|
||||
async def create_project(payload: Dict[str, Any] = Body(...)):
|
||||
name = (payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
return store.create_project(name)
|
||||
|
||||
|
||||
@app.delete("/projects/{project_id}")
|
||||
async def delete_project(project_id: str):
|
||||
if not store.delete_project(project_id):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
# If the deleted project was active, fall back to the "All" view.
|
||||
if _active_project() == project_id:
|
||||
store.update_settings({"active_project": ""})
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Documents (RAG)
|
||||
# -------------------------
|
||||
@app.get("/documents")
|
||||
async def list_documents():
|
||||
return {"documents": store.list_documents()}
|
||||
# Scope to the active project; "" (All) lists everything.
|
||||
active = _active_project()
|
||||
return {"documents": store.list_documents(active if active else None)}
|
||||
|
||||
|
||||
@app.post("/documents")
|
||||
@@ -957,7 +1016,7 @@ async def add_document(payload: Dict[str, Any] = Body(...)):
|
||||
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)
|
||||
result = await store.add_document(title, content, get_ollama_manager().embed, _active_project())
|
||||
if result["chunks"] == 0:
|
||||
raise HTTPException(status_code=400, detail="no text to index")
|
||||
return result
|
||||
@@ -1000,7 +1059,7 @@ async def upload_document(payload: Dict[str, Any] = Body(...)):
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="no extractable text in file")
|
||||
title = _os.path.splitext(_os.path.basename(filename))[0] or filename
|
||||
return await store.add_document(title, text, get_ollama_manager().embed)
|
||||
return await store.add_document(title, text, get_ollama_manager().embed, _active_project())
|
||||
|
||||
|
||||
@app.get("/documents/{doc_id}")
|
||||
|
||||
+142
-11
@@ -191,6 +191,20 @@ class PersistentMemoryStore:
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_documents_doc_id ON documents (doc_id)")
|
||||
|
||||
# Projects / workspaces: group documents so RAG can scope to one set.
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
# documents.project_id — "" (or missing) means unscoped / All.
|
||||
try:
|
||||
cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -569,10 +583,16 @@ class PersistentMemoryStore:
|
||||
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
|
||||
(row["id"], json.dumps(vec)),
|
||||
)
|
||||
self._vec_upsert_msg(conn, row["id"], vec) # mirror into the ANN index
|
||||
if missing:
|
||||
conn.commit()
|
||||
|
||||
# Score every stored message against the query vector.
|
||||
# Rank messages by similarity. Fast path: the sqlite-vec ANN index over an
|
||||
# over-fetch (conversation dedup below thins it); else brute-force cosine.
|
||||
scored = None
|
||||
if self.vec_enabled:
|
||||
scored = self._vec_search_messages(conn, query_vec, max(limit * 5, 20), min_score)
|
||||
if scored is None:
|
||||
cur.execute("""
|
||||
SELECT v.message_id, v.embedding, m.conversation_id
|
||||
FROM message_vectors v
|
||||
@@ -587,7 +607,6 @@ class PersistentMemoryStore:
|
||||
score = _cosine(query_vec, vec)
|
||||
if score >= min_score:
|
||||
scored.append((score, row["message_id"], row["conversation_id"]))
|
||||
|
||||
scored.sort(reverse=True)
|
||||
|
||||
results: List[dict] = []
|
||||
@@ -616,6 +635,70 @@ class PersistentMemoryStore:
|
||||
|
||||
return results
|
||||
|
||||
# --- message vector index (sqlite-vec) — same pattern as documents --------
|
||||
def _ensure_vec_msgs(self, conn, dim: int) -> None:
|
||||
conn.execute(
|
||||
f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_messages "
|
||||
f"USING vec0(embedding float[{dim}] distance_metric=cosine)"
|
||||
)
|
||||
|
||||
def _vec_upsert_msg(self, conn, message_id: int, vec: list) -> None:
|
||||
if not (self.vec_enabled and vec):
|
||||
return
|
||||
try:
|
||||
import sqlite_vec
|
||||
self._ensure_vec_msgs(conn, len(vec))
|
||||
conn.execute("DELETE FROM vec_messages WHERE rowid = ?", (message_id,))
|
||||
conn.execute(
|
||||
"INSERT INTO vec_messages(rowid, embedding) VALUES (?, ?)",
|
||||
(message_id, sqlite_vec.serialize_float32(vec)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _backfill_vec_msgs(self, conn, dim: int) -> None:
|
||||
"""Index any message_vectors rows missing from vec_messages."""
|
||||
try:
|
||||
import sqlite_vec
|
||||
self._ensure_vec_msgs(conn, dim)
|
||||
rows = conn.execute(
|
||||
"SELECT mv.message_id AS mid, mv.embedding AS emb FROM message_vectors mv "
|
||||
"LEFT JOIN vec_messages v ON v.rowid = mv.message_id WHERE v.rowid IS NULL"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
try:
|
||||
vec = json.loads(r["emb"])
|
||||
if len(vec) == dim:
|
||||
conn.execute(
|
||||
"INSERT INTO vec_messages(rowid, embedding) VALUES (?, ?)",
|
||||
(r["mid"], sqlite_vec.serialize_float32(vec)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _vec_search_messages(self, conn, query_vec: list, fetch: int, min_score: float):
|
||||
"""Top message hits via the vec index as sorted [(score, msg_id, conv_id)],
|
||||
or None to fall back to the brute-force scan. Stale rows for deleted
|
||||
messages are dropped by the inner join, so they never surface."""
|
||||
try:
|
||||
import sqlite_vec
|
||||
self._backfill_vec_msgs(conn, len(query_vec))
|
||||
rows = conn.execute(
|
||||
"SELECT v.rowid AS mid, v.distance AS distance, m.conversation_id AS cid "
|
||||
"FROM vec_messages v JOIN messages m ON m.id = v.rowid "
|
||||
"WHERE v.embedding MATCH ? ORDER BY v.distance LIMIT ?",
|
||||
(sqlite_vec.serialize_float32(query_vec), fetch),
|
||||
).fetchall()
|
||||
return [
|
||||
(1.0 - r["distance"], r["mid"], r["cid"])
|
||||
for r in rows if (1.0 - r["distance"]) >= min_score
|
||||
] # distance-asc == score-desc, already sorted
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _exchange_pair(self, cur, conv_id: str, message_id: int) -> Optional[dict]:
|
||||
"""Build a {id, updated_at, matches} record with the full user+assistant
|
||||
pair surrounding `message_id`, in the shape search callers expect."""
|
||||
@@ -677,7 +760,7 @@ class PersistentMemoryStore:
|
||||
chunks.append(buf)
|
||||
return chunks
|
||||
|
||||
async def add_document(self, title: str, content: str, embed_fn) -> dict:
|
||||
async def add_document(self, title: str, content: str, embed_fn, project_id: str = "") -> dict:
|
||||
"""Chunk, embed, and store a document. Returns {doc_id, chunks}."""
|
||||
import uuid as _uuid
|
||||
doc_id = str(_uuid.uuid4())
|
||||
@@ -688,16 +771,47 @@ class PersistentMemoryStore:
|
||||
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 (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO documents (id, doc_id, title, chunk_idx, text, embedding, created_at, project_id)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(str(_uuid.uuid4()), doc_id, title, i, piece,
|
||||
json.dumps(vec) if vec else None, now),
|
||||
json.dumps(vec) if vec else None, now, project_id or ""),
|
||||
)
|
||||
self._vec_upsert(conn, cur.lastrowid, vec) # mirror into the ANN index
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"doc_id": doc_id, "title": title, "chunks": len(pieces)}
|
||||
|
||||
# --- projects / workspaces --------------------------------------------------
|
||||
def create_project(self, name: str) -> dict:
|
||||
import uuid as _uuid
|
||||
pid = str(_uuid.uuid4())
|
||||
conn = self._connect()
|
||||
conn.execute("INSERT INTO projects (id, name, created_at) VALUES (?, ?, ?)",
|
||||
(pid, name.strip(), time.time()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"id": pid, "name": name.strip()}
|
||||
|
||||
def list_projects(self) -> List[dict]:
|
||||
conn = self._connect()
|
||||
rows = conn.execute("""
|
||||
SELECT p.id, p.name, p.created_at,
|
||||
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs
|
||||
FROM projects p ORDER BY p.created_at ASC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def delete_project(self, project_id: str) -> bool:
|
||||
"""Delete a project; its documents survive but become unscoped ("")."""
|
||||
conn = self._connect()
|
||||
conn.execute("UPDATE documents SET project_id = '' WHERE project_id = ?", (project_id,))
|
||||
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return deleted > 0
|
||||
|
||||
# --- vector index (sqlite-vec) — accelerator over the JSON embedding column,
|
||||
# --- with brute-force cosine below as the guaranteed fallback ---------------
|
||||
def _ensure_vec_docs(self, conn, dim: int) -> None:
|
||||
@@ -768,9 +882,17 @@ class PersistentMemoryStore:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def list_documents(self) -> List[dict]:
|
||||
def list_documents(self, project_id: Optional[str] = None) -> List[dict]:
|
||||
"""All documents, or just one project's when project_id is given."""
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
if project_id is not None:
|
||||
cur.execute("""
|
||||
SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
|
||||
FROM documents WHERE project_id = ?
|
||||
GROUP BY doc_id, title ORDER BY created_at DESC
|
||||
""", (project_id,))
|
||||
else:
|
||||
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
|
||||
@@ -807,22 +929,29 @@ class PersistentMemoryStore:
|
||||
return deleted > 0
|
||||
|
||||
async def search_documents(
|
||||
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
|
||||
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6,
|
||||
project_id: Optional[str] = None,
|
||||
) -> List[dict]:
|
||||
"""Top-`limit` document chunks most similar to `query`. Returns
|
||||
[{title, text, score}]. Empty on no query / embeddings down."""
|
||||
[{title, text, score}]. Empty on no query / embeddings down.
|
||||
When project_id is given, only that project's docs are searched.
|
||||
ponytail: scoped search uses the brute-force path (easy SQL filter, few
|
||||
docs per project); the vec index accelerates the unscoped "All" case."""
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
|
||||
if not query_vec:
|
||||
return []
|
||||
# Fast path: the sqlite-vec ANN index. None => fall through to brute force.
|
||||
if self.vec_enabled:
|
||||
# Fast path (unscoped only): the sqlite-vec ANN index.
|
||||
if not project_id and self.vec_enabled:
|
||||
hits = self._vec_search(query_vec, limit, min_score)
|
||||
if hits is not None:
|
||||
return hits
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
if project_id:
|
||||
cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL AND project_id = ?", (project_id,))
|
||||
else:
|
||||
cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL")
|
||||
scored = []
|
||||
for row in cur.fetchall():
|
||||
@@ -852,6 +981,8 @@ class PersistentMemoryStore:
|
||||
# cosine similarity (0-1) a chunk must clear to count as relevant.
|
||||
"rag_top_k": 3,
|
||||
"rag_min_score": 0.6,
|
||||
# Active project/workspace; "" = all documents (unscoped).
|
||||
"active_project": "",
|
||||
"system_prompt": "",
|
||||
"timeout": 120,
|
||||
# How long Ollama keeps the model resident in VRAM between messages.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Local speech-to-text via faster-whisper.
|
||||
|
||||
Deliberately torch-free: faster-whisper runs on CTranslate2 (CPU, int8), so it
|
||||
fits the "no ML stack in the venv" rule that keeps Promethean small. Replaces
|
||||
the browser's Web Speech API, which in Chrome ships audio to Google — the whole
|
||||
point is to keep dictation on-device.
|
||||
|
||||
Model size via NEXUS_STT_MODEL (default "base"); downloaded and cached on first
|
||||
use. If faster-whisper isn't installed, `available()` is False and the backend
|
||||
reports it so the UI can fall back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
_log = logging.getLogger("nexus.stt")
|
||||
_MODEL = None
|
||||
_MODEL_SIZE = os.getenv("NEXUS_STT_MODEL", "base")
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
try:
|
||||
import faster_whisper # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_model():
|
||||
global _MODEL
|
||||
if _MODEL is None:
|
||||
from faster_whisper import WhisperModel
|
||||
_log.info("loading whisper model %s (cpu/int8)", _MODEL_SIZE)
|
||||
_MODEL = WhisperModel(_MODEL_SIZE, device="cpu", compute_type="int8")
|
||||
return _MODEL
|
||||
|
||||
|
||||
def transcribe_b64(audio_b64: str) -> str:
|
||||
"""Transcribe a base64 audio blob (any container PyAV can decode — the
|
||||
browser's MediaRecorder produces webm/opus)."""
|
||||
raw = base64.b64decode(audio_b64)
|
||||
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
|
||||
f.write(raw)
|
||||
path = f.name
|
||||
try:
|
||||
segments, _info = _get_model().transcribe(path, vad_filter=True)
|
||||
return " ".join(s.text for s in segments).strip()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
+95
-6
@@ -1,17 +1,19 @@
|
||||
"""Read-only tools a playbook can call during chat.
|
||||
"""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.
|
||||
`message.tool_calls`, and this module is just the registry + dispatch.
|
||||
|
||||
Most tools READ local state (memory, history, documents, models). A few act:
|
||||
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
|
||||
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
|
||||
boundary — an action tool only fires when a playbook explicitly lists it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from .memory.store import store
|
||||
from .memory.store import store, MemoryItem
|
||||
from .ollama_manager import get_ollama_manager
|
||||
|
||||
|
||||
@@ -51,6 +53,45 @@ async def _get_time(**_) -> str:
|
||||
return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})
|
||||
|
||||
|
||||
async def _web_search(query: str = "", **_) -> str:
|
||||
import asyncio as _a
|
||||
from .search import web_search
|
||||
res = await _a.to_thread(web_search, query or "", 4)
|
||||
return res or "(no results)"
|
||||
|
||||
|
||||
async def _fetch_url(url: str = "", **_) -> str:
|
||||
import re
|
||||
import httpx
|
||||
url = (url or "").strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return json.dumps({"error": "url must start with http:// or https://"})
|
||||
# ponytail: no SSRF allow/deny-list — local single-user assistant, and the
|
||||
# tool only runs when a playbook explicitly grants fetch_url. Add host
|
||||
# filtering if this ever serves multiple/untrusted users.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as c:
|
||||
r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
|
||||
r.raise_for_status()
|
||||
html = r.text
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"fetch failed: {e}"})
|
||||
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", html)
|
||||
text = re.sub(r"(?s)<[^>]+>", " ", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text[:4000]
|
||||
|
||||
|
||||
async def _remember(text: str = "", section: str = "General", **_) -> str:
|
||||
"""WRITE tool: persist a memory fact. First action tool — allowlist-gated."""
|
||||
import uuid as _uuid
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return json.dumps({"error": "text is required"})
|
||||
store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text))
|
||||
return json.dumps({"saved": text, "section": section or "General"})
|
||||
|
||||
|
||||
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
"search_memory": (
|
||||
@@ -119,6 +160,54 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
},
|
||||
_get_time,
|
||||
),
|
||||
"web_search": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search the web (DuckDuckGo) and return the top result snippets.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_web_search,
|
||||
),
|
||||
"fetch_url": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_url",
|
||||
"description": "Fetch a web page and return its visible text (truncated).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"url": {"type": "string", "description": "http(s) URL"}},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_fetch_url,
|
||||
),
|
||||
"remember": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "remember",
|
||||
"description": "Save a durable fact to the user's persistent memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "the fact to remember"},
|
||||
"section": {"type": "string", "description": "optional category, e.g. Health"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_remember,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,62 @@ def test_search_empty_query_returns_nothing():
|
||||
assert asyncio.run(s.search_documents("", _fake_embed)) == []
|
||||
|
||||
|
||||
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
||||
s = _store()
|
||||
if not s.vec_enabled:
|
||||
import pytest
|
||||
pytest.skip("sqlite-vec not loadable on this host")
|
||||
|
||||
async def run():
|
||||
s.create_conversation("c1")
|
||||
s.add_message("c1", "user", "tell me about lego star wars")
|
||||
s.add_message("c1", "assistant", "lego star wars is a fun game")
|
||||
s.create_conversation("c2")
|
||||
s.add_message("c2", "user", "gpu vega vram notes")
|
||||
s.add_message("c2", "assistant", "vega has 4gb")
|
||||
|
||||
hits = await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
|
||||
assert hits and hits[0]["id"] == "c1"
|
||||
|
||||
conn = s._connect()
|
||||
n = conn.execute("SELECT COUNT(*) FROM vec_messages").fetchone()[0]
|
||||
conn.close()
|
||||
assert n >= 2 # dual-write populated the message vec index
|
||||
|
||||
s.vec_enabled = False
|
||||
bf = await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
|
||||
assert bf[0]["id"] == hits[0]["id"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_projects_scope_documents_and_survive_delete():
|
||||
s = _store()
|
||||
|
||||
async def run():
|
||||
p = s.create_project("Star Wars")
|
||||
assert [x["name"] for x in s.list_projects()] == ["Star Wars"]
|
||||
|
||||
await s.add_document("Lego", "lego star wars boss tips", _fake_embed, project_id=p["id"])
|
||||
await s.add_document("GPU", "gpu vega notes", _fake_embed) # unscoped
|
||||
|
||||
# project sees only its own; unscoped/all sees both
|
||||
assert [d["title"] for d in s.list_documents(p["id"])] == ["Lego"]
|
||||
assert len(s.list_documents(None)) == 2
|
||||
|
||||
# scoped search only returns the project's docs
|
||||
scoped = await s.search_documents("star wars", _fake_embed, min_score=0.1, project_id=p["id"])
|
||||
assert scoped and all(h["title"] == "Lego" for h in scoped)
|
||||
|
||||
# deleting the project keeps the docs but unscopes them
|
||||
assert s.delete_project(p["id"]) is True
|
||||
assert s.list_projects() == []
|
||||
assert len(s.list_documents(None)) == 2
|
||||
assert len(s.list_documents(p["id"])) == 0 # nothing left in that project
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_chunker_overlap_and_hard_split():
|
||||
s = _store()
|
||||
# a single oversized paragraph (no blank lines, as in PDF text) is split
|
||||
|
||||
@@ -43,6 +43,13 @@ def test_keep_alive_pins_the_model():
|
||||
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
|
||||
|
||||
|
||||
def test_stt_status_endpoint():
|
||||
# Reports whether local Whisper is installed; wiring must respond either way.
|
||||
resp = TestClient(app).get("/stt/status")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json()["available"], bool)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -23,6 +23,24 @@ async def _drain(gen):
|
||||
return [s async for s in gen]
|
||||
|
||||
|
||||
def test_remember_writes_a_memory_fact(tmp_path, monkeypatch):
|
||||
# The `remember` action tool persists a fact through the store.
|
||||
import synapse.memory.store as store_mod
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
fresh = PersistentMemoryStore(tmp_path / "m.db")
|
||||
monkeypatch.setattr(tools, "store", fresh)
|
||||
out = asyncio.run(tools.dispatch("remember", {"text": "user likes tea", "section": "Prefs"}))
|
||||
assert "user likes tea" in out
|
||||
assert any(it.text == "user likes tea" for it in fresh.all())
|
||||
|
||||
|
||||
def test_action_tools_registered():
|
||||
for name in ("web_search", "fetch_url", "remember"):
|
||||
assert name in tools.REGISTRY
|
||||
names = [s["function"]["name"] for s in tools.schemas_for(["web_search", "remember", "nope"])]
|
||||
assert names == ["web_search", "remember"]
|
||||
|
||||
|
||||
class _FakeManager:
|
||||
"""Returns a tool_call on the first chat() call, plain content after."""
|
||||
def __init__(self):
|
||||
|
||||
Reference in New Issue
Block a user