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:
+61
-15
@@ -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,21 +306,39 @@ 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>
|
||||
<button
|
||||
onClick={startNewChat}
|
||||
title="New chat"
|
||||
style={{
|
||||
padding: "0.2rem 0.55rem",
|
||||
background: "#161616",
|
||||
color: "#bbb",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
<div 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"
|
||||
style={{
|
||||
padding: "0.2rem 0.55rem",
|
||||
background: "#161616",
|
||||
color: "#bbb",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
@@ -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>
|
||||
|
||||
+205
-44
@@ -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,33 +500,71 @@ 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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(msg.content).then(() => {
|
||||
setCopiedIdx(idx);
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{copiedIdx === idx ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
{msg.content && editingIdx !== idx && (
|
||||
<div style={{ display: "flex", gap: "0.25rem", marginTop: "0.25rem" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(msg.content).then(() => {
|
||||
setCopiedIdx(idx);
|
||||
setTimeout(() => setCopiedIdx(null), 1500);
|
||||
});
|
||||
}}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user