Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
859 lines
36 KiB
React
859 lines
36 KiB
React
import { useState, useRef, useEffect } from "react";
|
|
|
|
import { API_BASE } from "./config";
|
|
import { Markdown } from "./Markdown";
|
|
|
|
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
|
|
const [messages, setMessages] = useState([]);
|
|
const [input, setInput] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [queue, setQueue] = useState([]); // messages typed while a reply was streaming, sent in order once it's free
|
|
const [modelList, setModelList] = useState([]);
|
|
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
|
|
const [autoModel, setAutoModel] = useState(null);
|
|
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
|
|
const [showPicker, setShowPicker] = useState(false);
|
|
const [copiedIdx, setCopiedIdx] = 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 [pendingApproval, setPendingApproval] = useState(null); // [{name, arguments}] awaiting yes/no
|
|
const [approvalToken, setApprovalToken] = useState(null); // single-use token authorizing /chat/approve
|
|
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;
|
|
|
|
// 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) => _appendTranscript(Array.from(e.results).map(r => r[0].transcript).join(" ").trim());
|
|
rec.onend = () => setListening(false);
|
|
rec.onerror = () => setListening(false);
|
|
recognitionRef.current = rec;
|
|
setListening(true);
|
|
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;
|
|
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);
|
|
const pickerRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
if (!conversationId) return;
|
|
if (abortRef.current) abortRef.current.abort();
|
|
let cancelled = false;
|
|
fetch(`${API_BASE}/conversations/${conversationId}`)
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => {
|
|
if (cancelled) return;
|
|
// tokens/model are persisted per message; elapsed/rate are live-only.
|
|
setMessages(data?.messages?.map(m => ({
|
|
role: m.role, content: m.content, model: m.model,
|
|
stats: m.tokens ? { tokens: m.tokens } : undefined,
|
|
})) || []);
|
|
})
|
|
.catch(() => { if (!cancelled) setMessages([]); });
|
|
return () => { cancelled = true; };
|
|
}, [conversationId]);
|
|
|
|
// Keyed on `visible`, not []: this component stays mounted while other pages
|
|
// show (App hides it with display:none so an in-flight reply survives
|
|
// navigation), so a mount-once fetch left the picker showing whatever was
|
|
// installed when the tab first opened — a model pulled on the Models page
|
|
// didn't appear here until a full browser reload.
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
Promise.all([
|
|
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null),
|
|
fetch(`${API_BASE}/settings`).then(r => r.ok ? r.json() : null),
|
|
]).then(([models, settings]) => {
|
|
if (models?.models) setModelList(models.models);
|
|
if (models?.selected) setAutoModel(models.selected);
|
|
if (settings) {
|
|
setSelectedModel(settings.model || "");
|
|
setThink(!!settings.think);
|
|
}
|
|
}).catch(() => {});
|
|
}, [visible]);
|
|
|
|
useEffect(() => {
|
|
if (!showPicker) return;
|
|
const handle = (e) => {
|
|
if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false);
|
|
};
|
|
document.addEventListener("mousedown", handle);
|
|
return () => document.removeEventListener("mousedown", handle);
|
|
}, [showPicker]);
|
|
|
|
// Declared ahead of sendMessage: it is called from there, and a const arrow
|
|
// defined further down is still in the TDZ as far as the linter is concerned.
|
|
const updateAssistant = (index, text) => {
|
|
setMessages(prev => {
|
|
const updated = [...prev];
|
|
updated[index] = { ...updated[index], content: text };
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
const setModelChoice = async (model) => {
|
|
setSelectedModel(model);
|
|
setShowPicker(false);
|
|
try {
|
|
await fetch(`${API_BASE}/settings`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ model }),
|
|
});
|
|
} catch { /* persisting the model choice is best-effort */ }
|
|
};
|
|
|
|
const toggleThink = async () => {
|
|
const next = !think;
|
|
setThink(next);
|
|
try {
|
|
await fetch(`${API_BASE}/settings`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ think: next }),
|
|
});
|
|
} catch { /* persisting the toggle is best-effort */ }
|
|
};
|
|
|
|
const startNewChat = () => {
|
|
if (abortRef.current) abortRef.current.abort();
|
|
setInput("");
|
|
setQueue([]);
|
|
setLoading(false);
|
|
setConversationId(crypto.randomUUID());
|
|
};
|
|
|
|
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
|
|
if (abortRef.current) abortRef.current.abort();
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/chat/stream`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
message,
|
|
conversation_id: conversationId,
|
|
history,
|
|
think,
|
|
...(imgs && imgs.length ? { images: imgs } : {}),
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
updateAssistant(assistantIndex, `Error: ${text}`);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
let buffer = "";
|
|
let pendingEventType = null;
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop();
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("event: ")) {
|
|
pendingEventType = line.slice(7).trim();
|
|
continue;
|
|
}
|
|
if (line.startsWith("data: ")) {
|
|
const payload = line.slice(6);
|
|
if (!payload.trim()) { pendingEventType = null; continue; }
|
|
|
|
if (pendingEventType === "meta") {
|
|
try {
|
|
const stats = JSON.parse(payload);
|
|
// Tag this message with the model that answered and its token stats
|
|
setMessages(prev => {
|
|
const updated = [...prev];
|
|
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model, stats };
|
|
return updated;
|
|
});
|
|
} catch { /* ignore */ }
|
|
pendingEventType = null;
|
|
continue;
|
|
}
|
|
if (pendingEventType === "memory") {
|
|
try {
|
|
const mem = JSON.parse(payload);
|
|
setMemoryToast(mem);
|
|
setTimeout(() => setMemoryToast(null), 5000);
|
|
} catch { /* ignore */ }
|
|
pendingEventType = null;
|
|
continue;
|
|
}
|
|
if (pendingEventType === "status") {
|
|
try { setActiveTool(JSON.parse(payload).tool); } catch { /* ignore */ }
|
|
pendingEventType = null;
|
|
continue;
|
|
}
|
|
if (pendingEventType === "tool_request") {
|
|
try {
|
|
const parsed = JSON.parse(payload);
|
|
setPendingApproval(parsed.actions || []);
|
|
setApprovalToken(parsed.token || null);
|
|
} catch { /* ignore */ }
|
|
pendingEventType = null;
|
|
continue;
|
|
}
|
|
if (pendingEventType === "sources") {
|
|
try {
|
|
const src = JSON.parse(payload).sources;
|
|
setMessages(prev => {
|
|
const updated = [...prev];
|
|
updated[assistantIndex] = { ...updated[assistantIndex], sources: src };
|
|
return updated;
|
|
});
|
|
} 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);
|
|
setPendingApproval(null);
|
|
pendingEventType = null;
|
|
continue;
|
|
}
|
|
if (pendingEventType === "title") {
|
|
if (onConversationChanged) onConversationChanged();
|
|
pendingEventType = null;
|
|
continue;
|
|
}
|
|
if (pendingEventType === "error") {
|
|
try {
|
|
const err = JSON.parse(payload);
|
|
updateAssistant(assistantIndex, `Error: ${err.detail || payload}`);
|
|
} catch {
|
|
updateAssistant(assistantIndex, `Error: ${payload}`);
|
|
}
|
|
pendingEventType = null;
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
pendingEventType = null;
|
|
|
|
let token = payload;
|
|
try { token = JSON.parse(payload); } catch { /* plain text fallback */ }
|
|
|
|
if (activeTool) setActiveTool(null); // tokens started -> tools done
|
|
if (pendingApproval) setPendingApproval(null);
|
|
setMessages(prev => {
|
|
const updated = [...prev];
|
|
updated[assistantIndex] = {
|
|
...updated[assistantIndex],
|
|
content: (updated[assistantIndex].content || "") + token,
|
|
};
|
|
return updated;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (err) {
|
|
if (err.name !== "AbortError") {
|
|
updateAssistant(assistantIndex, `Connection error: ${err.message}`);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
if (onConversationChanged) onConversationChanged();
|
|
}
|
|
};
|
|
|
|
const histBefore = (index) =>
|
|
messages.slice(0, index).filter(m => m.content.trim() !== "")
|
|
.map(m => ({ role: m.role, content: m.content }));
|
|
|
|
// Shared by an immediate send and an auto-flushed queued message.
|
|
const doSend = async (userMessage, outImages = []) => {
|
|
// 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 sendMessage = async () => {
|
|
if ((!input.trim() && images.length === 0) || loading) return;
|
|
const userMessage = input.trim();
|
|
const outImages = images.map(i => i.b64);
|
|
setInput("");
|
|
setImages([]);
|
|
await doSend(userMessage, outImages);
|
|
};
|
|
|
|
// Enter while a reply is streaming queues the message instead of sending it;
|
|
// it's auto-sent, in order, once the current reply finishes (see the flush effect below).
|
|
const queueMessage = () => {
|
|
const text = input.trim();
|
|
if (!text) return;
|
|
setQueue(prev => [...prev, text]);
|
|
setInput("");
|
|
};
|
|
|
|
const removeQueued = (idx) => setQueue(prev => prev.filter((_, i) => i !== idx));
|
|
|
|
// Flush one queued message each time the box goes idle.
|
|
useEffect(() => {
|
|
if (loading || queue.length === 0) return;
|
|
const [next, ...rest] = queue;
|
|
setQueue(rest);
|
|
doSend(next);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [loading, queue]);
|
|
|
|
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 });
|
|
};
|
|
|
|
// Approve or deny the pending action tool(s); the open chat stream resumes.
|
|
const resolveApproval = async (approve) => {
|
|
const req = pendingApproval || [];
|
|
const token = approvalToken;
|
|
setPendingApproval(null);
|
|
setApprovalToken(null);
|
|
const decisions = {};
|
|
req.forEach(a => { decisions[a.name] = approve; });
|
|
try {
|
|
await fetch(`${API_BASE}/chat/approve`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ conversation_id: conversationId, token, decisions }),
|
|
});
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
const stopGeneration = () => {
|
|
if (abortRef.current) abortRef.current.abort();
|
|
setLoading(false);
|
|
setActiveTool(null);
|
|
if (pendingApproval) resolveApproval(false); // stopping = deny pending actions
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (loading) queueMessage();
|
|
else sendMessage();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
|
|
<div style={{
|
|
flexGrow: 1,
|
|
background: "#161616",
|
|
border: "1px solid #333",
|
|
borderRadius: "12px",
|
|
padding: "1rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden"
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.75rem" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
<span style={{ fontSize: "0.85rem", color: "#555" }}>
|
|
{messages.length === 0 ? "New conversation" : `${Math.ceil(messages.length / 2)} exchange${messages.length > 2 ? "s" : ""}`}
|
|
</span>
|
|
<div ref={pickerRef} style={{ position: "relative" }}>
|
|
<button
|
|
onClick={() => setShowPicker(p => !p)}
|
|
style={{
|
|
fontSize: "0.7rem",
|
|
color: "#7aa",
|
|
background: showPicker ? "#1e2e2e" : "#1a2a2a",
|
|
border: "1px solid #2a4a4a",
|
|
borderRadius: "4px",
|
|
padding: "0.15rem 0.5rem",
|
|
cursor: "pointer",
|
|
letterSpacing: "0.03em",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.3rem",
|
|
}}
|
|
>
|
|
{!selectedModel && <span style={{ color: "#4a6a6a" }}>auto ·</span>}
|
|
{selectedModel || autoModel || "no model"}
|
|
<span style={{ color: "#4a7a7a", fontSize: "0.6rem" }}>▾</span>
|
|
</button>
|
|
{showPicker && (
|
|
<div style={{
|
|
position: "absolute",
|
|
top: "calc(100% + 4px)",
|
|
left: 0,
|
|
background: "#1a1a1a",
|
|
border: "1px solid #333",
|
|
borderRadius: "8px",
|
|
zIndex: 100,
|
|
minWidth: "200px",
|
|
maxHeight: "240px",
|
|
overflowY: "auto",
|
|
boxShadow: "0 4px 16px rgba(0,0,0,0.6)",
|
|
}}>
|
|
<div
|
|
onClick={() => setModelChoice("")}
|
|
style={{
|
|
padding: "0.55rem 0.85rem",
|
|
cursor: "pointer",
|
|
fontSize: "0.8rem",
|
|
color: !selectedModel ? "#7aa" : "#888",
|
|
background: !selectedModel ? "#1a2a2a" : "transparent",
|
|
borderBottom: "1px solid #262626",
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span>Auto <span style={{ color: "#445", fontSize: "0.7rem" }}>{autoModel ? `(${autoModel})` : ""}</span></span>
|
|
{!selectedModel && <span>✓</span>}
|
|
</div>
|
|
{modelList.map(m => (
|
|
<div
|
|
key={m}
|
|
onClick={() => setModelChoice(m)}
|
|
style={{
|
|
padding: "0.55rem 0.85rem",
|
|
cursor: "pointer",
|
|
fontSize: "0.8rem",
|
|
color: selectedModel === m ? "#7aa" : "#ccc",
|
|
background: selectedModel === m ? "#1a2a2a" : "transparent",
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span>{m}</span>
|
|
{selectedModel === m && <span>✓</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={toggleThink}
|
|
title={think ? "Extended thinking is on — click to turn off" : "Extended thinking is off — click to turn on"}
|
|
style={{
|
|
fontSize: "0.7rem",
|
|
color: think ? "#c4b5fd" : "#888",
|
|
background: think ? "#1e1a2e" : "#1a1a1a",
|
|
border: "1px solid " + (think ? "#7c3aed" : "#2a2a2a"),
|
|
borderRadius: "4px",
|
|
padding: "0.15rem 0.5rem",
|
|
cursor: "pointer",
|
|
letterSpacing: "0.03em",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.3rem",
|
|
}}
|
|
>
|
|
🧠 Think
|
|
<span style={{ fontSize: "0.6rem", color: think ? "#8aff8a" : "#555" }}>
|
|
{think ? "on" : "off"}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={startNewChat}
|
|
disabled={loading}
|
|
style={{
|
|
padding: "0.4rem 0.85rem",
|
|
background: "#222",
|
|
color: "#aaa",
|
|
border: "1px solid #444",
|
|
borderRadius: "6px",
|
|
cursor: loading ? "default" : "pointer",
|
|
fontSize: "0.8rem",
|
|
opacity: loading ? 0.5 : 1,
|
|
}}
|
|
>
|
|
New Chat
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{
|
|
flexGrow: 1,
|
|
overflowY: "auto",
|
|
marginBottom: "1rem",
|
|
paddingRight: "0.5rem"
|
|
}}>
|
|
{messages.length === 0 ? (
|
|
<div style={{ color: "#666", textAlign: "center", paddingTop: "2rem" }}>
|
|
<p>Start a conversation with the chatbot...</p>
|
|
</div>
|
|
) : (
|
|
messages.map((msg, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
marginBottom: "1rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: msg.role === "user" ? "flex-end" : "flex-start",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
maxWidth: msg.role === "user" ? "70%" : "90%",
|
|
width: msg.role === "assistant" ? "100%" : undefined,
|
|
padding: "0.9rem",
|
|
borderRadius: "10px",
|
|
background: msg.role === "user" ? "#007acc" : "#262626",
|
|
color: "#eee",
|
|
wordWrap: "break-word",
|
|
boxSizing: "border-box",
|
|
}}
|
|
>
|
|
{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.sources && msg.sources.length > 0 && (
|
|
<div style={{ marginTop: "0.35rem", display: "flex", flexWrap: "wrap", gap: "0.35rem", alignItems: "center" }}>
|
|
<span style={{ fontSize: "0.7rem", color: "#777" }}>Sources:</span>
|
|
{msg.sources.map((s, i) => (
|
|
<span key={i} style={{ fontSize: "0.72rem", color: "#8ab4ff", background: "#1a2433", border: "1px solid #2a3a52", borderRadius: "6px", padding: "0.1rem 0.45rem" }}>
|
|
📄 {s}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{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.stats && (
|
|
<span style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555" }}>
|
|
{msg.stats.tokens} tok
|
|
{msg.stats.elapsed_s ? ` · ${msg.stats.elapsed_s}s` : ""}
|
|
{msg.stats.tokens_per_s > 0 ? ` · ${msg.stats.tokens_per_s} t/s` : ""}
|
|
{msg.model ? ` · ${msg.model}` : ""}
|
|
</span>
|
|
)}
|
|
{msg.role === "assistant" && ttsSupported && (
|
|
<button
|
|
onClick={() => speak(idx, msg.content)}
|
|
title={speakingIdx === idx ? "Stop" : "Read aloud"}
|
|
style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: speakingIdx === idx ? "#4caf50" : "#555", background: "transparent", border: "none", cursor: "pointer" }}
|
|
>
|
|
{speakingIdx === idx ? "🔊 Stop" : "🔊 Speak"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{pendingApproval && (
|
|
<div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}>
|
|
<div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
|
|
⚠️ The assistant wants to run:
|
|
{" "}
|
|
{pendingApproval.map((a, i) => (
|
|
<code key={i} style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem" }}>
|
|
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
|
|
</code>
|
|
))}
|
|
</div>
|
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
<button onClick={() => resolveApproval(true)}
|
|
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
|
|
Approve
|
|
</button>
|
|
<button onClick={() => resolveApproval(false)}
|
|
style={{ padding: "0.4rem 1rem", background: "#3a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
|
|
Deny
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeTool && (
|
|
<div style={{ marginBottom: "0.5rem", color: "#8ab4ff", fontSize: "0.9rem" }}>
|
|
🔧 running tool: {activeTool}…
|
|
</div>
|
|
)}
|
|
{images.length > 0 && (
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
|
{images.map((img, i) => (
|
|
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: "0.4rem", padding: "0.35rem 0.6rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "8px", color: "#ccc", fontSize: "0.85rem" }}>
|
|
📷 {img.name}
|
|
<button onClick={() => setImages(prev => prev.filter((_, j) => j !== i))}
|
|
style={{ background: "none", border: "none", color: "#ff8a80", cursor: "pointer", padding: 0 }}>✕</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{queue.length > 0 && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.3rem", marginBottom: "0.5rem" }}>
|
|
{queue.map((text, i) => (
|
|
<div key={i} style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.35rem 0.6rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "8px", color: "#999", fontSize: "0.8rem" }}>
|
|
<span style={{ color: "#557", flexShrink: 0 }}>Queued #{i + 1}</span>
|
|
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexGrow: 1 }}>{text}</span>
|
|
<button onClick={() => removeQueued(i)}
|
|
style={{ background: "none", border: "none", color: "#ff8a80", cursor: "pointer", padding: 0, flexShrink: 0 }}>✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{memoryToast && (
|
|
<div style={{
|
|
marginBottom: "0.5rem", padding: "0.5rem 0.9rem",
|
|
background: "#1a1a2e", border: "1px solid #7c3aed",
|
|
borderRadius: "10px", fontSize: "0.85rem", color: "#c4b5fd",
|
|
}}>
|
|
🧠 <strong>Memory saved</strong> [{memoryToast.section}]{" "}
|
|
<span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
|
|
</div>
|
|
)}
|
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "stretch" }}>
|
|
<label title="Attach image (needs a vision model)"
|
|
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0 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>
|
|
{((sttLocal && canRecord) || SpeechRec) && (
|
|
<button onClick={toggleMic}
|
|
title={transcribing ? "Transcribing…" : listening ? "Stop dictation" : sttLocal ? "Dictate (on-device Whisper)" : "Dictate (browser speech)"}
|
|
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0 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
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={loading ? "Keep typing — Enter queues it for after this reply..." : "Type your message... (Shift+Enter for new line)"}
|
|
rows={3}
|
|
style={{
|
|
flexGrow: 1,
|
|
padding: "0.9rem",
|
|
background: "#222",
|
|
color: "#eee",
|
|
border: "1px solid #333",
|
|
borderRadius: "10px",
|
|
fontFamily: "system-ui",
|
|
resize: "none",
|
|
}}
|
|
/>
|
|
{loading && input.trim() && (
|
|
<button
|
|
onClick={queueMessage}
|
|
title="Queue this message for after the current reply"
|
|
style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
padding: "0 1.25rem", background: "#2a2a4a", color: "#fff",
|
|
border: "1px solid #444a7a", borderRadius: "8px", cursor: "pointer",
|
|
}}
|
|
>
|
|
Queue
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={loading ? stopGeneration : sendMessage}
|
|
disabled={!loading && !input.trim() && images.length === 0}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: "0 1.5rem",
|
|
background: loading ? "#c0392b" : (!input.trim() && images.length === 0 ? "#555" : "#007acc"),
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: "8px",
|
|
cursor: !loading && !input.trim() && images.length === 0 ? "default" : "pointer",
|
|
opacity: !loading && !input.trim() && images.length === 0 ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{loading ? "Stop" : "Send"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |