- Added `trillium.py` for searching and creating notes with Trillium's ETAPI. - Implemented `search_notes` and `create_note` functions with appropriate error handling and validation. feat: Add web search functionality using DuckDuckGo - Introduced `web_search.py` for performing web searches without API keys. - Implemented `search_web` function with result handling and validation. feat: Create provider-agnostic function caller for iterative tool calling - Developed `function_caller.py` to manage LLM interactions with tools. - Implemented iterative calling logic with error handling and tool execution. feat: Establish a tool registry for managing available tools - Created `registry.py` to define and manage tool availability and execution. - Integrated feature flags for enabling/disabling tools based on environment variables. feat: Implement event streaming for tool calling processes - Added `stream_events.py` to manage Server-Sent Events (SSE) for tool calling. - Enabled real-time updates during tool execution for enhanced user experience. test: Add tests for tool calling system components - Created `test_tools.py` to validate functionality of code execution, web search, and tool registry. - Implemented asynchronous tests to ensure proper execution and result handling. chore: Add Dockerfile for sandbox environment setup - Created `Dockerfile` to set up a Python environment with necessary dependencies for code execution. chore: Add debug regex script for testing XML parsing - Introduced `debug_regex.py` to validate regex patterns against XML tool calls. chore: Add HTML template for displaying thinking stream events - Created `test_thinking_stream.html` for visualizing tool calling events in a user-friendly format. test: Add tests for OllamaAdapter XML parsing - Developed `test_ollama_parser.py` to validate XML parsing with various test cases, including malformed XML.
536 lines
18 KiB
HTML
536 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>Lyra Core Chat</title>
|
||
<link rel="stylesheet" href="style.css" />
|
||
<!-- PWA -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||
<meta name="mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||
<link rel="manifest" href="manifest.json" />
|
||
|
||
</head>
|
||
<body>
|
||
<div id="chat">
|
||
<!-- Mode selector -->
|
||
<div id="model-select">
|
||
<label for="mode">Mode:</label>
|
||
<select id="mode">
|
||
<option value="standard">Standard</option>
|
||
<option value="cortex">Cortex</option>
|
||
</select>
|
||
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
|
||
<div id="theme-toggle">
|
||
<button id="toggleThemeBtn">🌙 Dark Mode</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Session selector -->
|
||
<div id="session-select">
|
||
<label for="sessions">Session:</label>
|
||
<select id="sessions"></select>
|
||
<button id="newSessionBtn">➕ New</button>
|
||
<button id="renameSessionBtn">✏️ Rename</button>
|
||
<button id="thinkingStreamBtn" title="Show thinking stream in new window">🧠 Show Work</button>
|
||
</div>
|
||
|
||
<!-- Status -->
|
||
<div id="status">
|
||
<span id="status-dot"></span>
|
||
<span id="status-text">Checking Relay...</span>
|
||
</div>
|
||
|
||
<!-- Chat messages -->
|
||
<div id="messages"></div>
|
||
|
||
<!-- Input box -->
|
||
<div id="input">
|
||
<input id="userInput" type="text" placeholder="Type a message..." autofocus />
|
||
<button id="sendBtn">Send</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings Modal (outside chat container) -->
|
||
<div id="settingsModal" class="modal">
|
||
<div class="modal-overlay"></div>
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Settings</h3>
|
||
<button id="closeModalBtn" class="close-btn">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="settings-section">
|
||
<h4>Standard Mode Backend</h4>
|
||
<p class="settings-desc">Select which LLM backend to use for Standard Mode:</p>
|
||
<div class="radio-group">
|
||
<label class="radio-label">
|
||
<input type="radio" name="backend" value="SECONDARY" checked>
|
||
<span>SECONDARY - Ollama/Qwen (3090)</span>
|
||
<small>Fast, local, good for general chat</small>
|
||
</label>
|
||
<label class="radio-label">
|
||
<input type="radio" name="backend" value="OPENAI">
|
||
<span>OPENAI - GPT-4o-mini</span>
|
||
<small>Cloud-based, high quality (costs money)</small>
|
||
</label>
|
||
<label class="radio-label">
|
||
<input type="radio" name="backend" value="custom">
|
||
<span>Custom Backend</span>
|
||
<input type="text" id="customBackend" placeholder="e.g., PRIMARY, FALLBACK" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section" style="margin-top: 24px;">
|
||
<h4>Session Management</h4>
|
||
<p class="settings-desc">Manage your saved chat sessions:</p>
|
||
<div id="sessionList" class="session-list">
|
||
<p style="color: var(--text-fade); font-size: 0.85rem;">Loading sessions...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button id="saveSettingsBtn" class="primary-btn">Save</button>
|
||
<button id="cancelSettingsBtn">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const RELAY_BASE = "http://10.0.0.41:7078";
|
||
const API_URL = `${RELAY_BASE}/v1/chat/completions`;
|
||
|
||
function generateSessionId() {
|
||
return "sess-" + Math.random().toString(36).substring(2, 10);
|
||
}
|
||
|
||
let history = [];
|
||
let currentSession = localStorage.getItem("currentSession") || null;
|
||
let sessions = []; // Now loaded from server
|
||
|
||
async function loadSessionsFromServer() {
|
||
try {
|
||
const resp = await fetch(`${RELAY_BASE}/sessions`);
|
||
const serverSessions = await resp.json();
|
||
sessions = serverSessions;
|
||
return sessions;
|
||
} catch (e) {
|
||
console.error("Failed to load sessions from server:", e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function renderSessions() {
|
||
const select = document.getElementById("sessions");
|
||
select.innerHTML = "";
|
||
|
||
sessions.forEach(s => {
|
||
const opt = document.createElement("option");
|
||
opt.value = s.id;
|
||
opt.textContent = s.name || s.id;
|
||
if (s.id === currentSession) opt.selected = true;
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function getSessionName(id) {
|
||
const s = sessions.find(s => s.id === id);
|
||
return s ? (s.name || s.id) : id;
|
||
}
|
||
|
||
async function saveSessionMetadata(sessionId, name) {
|
||
try {
|
||
await fetch(`${RELAY_BASE}/sessions/${sessionId}/metadata`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name })
|
||
});
|
||
return true;
|
||
} catch (e) {
|
||
console.error("Failed to save session metadata:", e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function loadSession(id) {
|
||
try {
|
||
const res = await fetch(`${RELAY_BASE}/sessions/${id}`);
|
||
const data = await res.json();
|
||
history = Array.isArray(data) ? data : [];
|
||
const messagesEl = document.getElementById("messages");
|
||
messagesEl.innerHTML = "";
|
||
history.forEach(m => addMessage(m.role, m.content));
|
||
addMessage("system", `📂 Loaded session: ${getSessionName(id)} — ${history.length} message(s)`);
|
||
} catch (e) {
|
||
addMessage("system", `Failed to load session: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function saveSession() {
|
||
if (!currentSession) return;
|
||
try {
|
||
await fetch(`${RELAY_BASE}/sessions/${currentSession}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(history)
|
||
});
|
||
} catch (e) {
|
||
addMessage("system", `Failed to save session: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const inputEl = document.getElementById("userInput");
|
||
const msg = inputEl.value.trim();
|
||
if (!msg) return;
|
||
inputEl.value = "";
|
||
|
||
addMessage("user", msg);
|
||
history.push({ role: "user", content: msg });
|
||
await saveSession(); // ✅ persist both user + assistant messages
|
||
|
||
|
||
const mode = document.getElementById("mode").value;
|
||
|
||
// make sure we always include a stable user_id
|
||
let userId = localStorage.getItem("userId");
|
||
if (!userId) {
|
||
userId = "brian"; // use whatever ID you seeded Mem0 with
|
||
localStorage.setItem("userId", userId);
|
||
}
|
||
|
||
// Get backend preference for Standard Mode
|
||
let backend = null;
|
||
if (mode === "standard") {
|
||
backend = localStorage.getItem("standardModeBackend") || "SECONDARY";
|
||
}
|
||
|
||
const body = {
|
||
mode: mode,
|
||
messages: history,
|
||
sessionId: currentSession
|
||
};
|
||
|
||
// Only add backend if in standard mode
|
||
if (backend) {
|
||
body.backend = backend;
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch(API_URL, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
const data = await resp.json();
|
||
const reply = data.choices?.[0]?.message?.content || "(no reply)";
|
||
addMessage("assistant", reply);
|
||
history.push({ role: "assistant", content: reply });
|
||
await saveSession();
|
||
} catch (err) {
|
||
addMessage("system", "Error: " + err.message);
|
||
}
|
||
}
|
||
|
||
function addMessage(role, text) {
|
||
const messagesEl = document.getElementById("messages");
|
||
|
||
const msgDiv = document.createElement("div");
|
||
msgDiv.className = `msg ${role}`;
|
||
msgDiv.textContent = text;
|
||
messagesEl.appendChild(msgDiv);
|
||
|
||
// only auto-scroll if user is near bottom
|
||
const threshold = 120;
|
||
const isNearBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < threshold;
|
||
if (isNearBottom) {
|
||
messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" });
|
||
}
|
||
}
|
||
|
||
|
||
async function checkHealth() {
|
||
try {
|
||
const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health"));
|
||
if (resp.ok) {
|
||
document.getElementById("status-dot").className = "dot ok";
|
||
document.getElementById("status-text").textContent = "Relay Online";
|
||
} else {
|
||
throw new Error("Bad status");
|
||
}
|
||
} catch (err) {
|
||
document.getElementById("status-dot").className = "dot fail";
|
||
document.getElementById("status-text").textContent = "Relay Offline";
|
||
}
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
// Dark mode toggle - defaults to dark
|
||
const btn = document.getElementById("toggleThemeBtn");
|
||
|
||
// Set dark mode by default if no preference saved
|
||
const savedTheme = localStorage.getItem("theme");
|
||
if (!savedTheme || savedTheme === "dark") {
|
||
document.body.classList.add("dark");
|
||
btn.textContent = "☀️ Light Mode";
|
||
localStorage.setItem("theme", "dark");
|
||
} else {
|
||
btn.textContent = "🌙 Dark Mode";
|
||
}
|
||
|
||
btn.addEventListener("click", () => {
|
||
document.body.classList.toggle("dark");
|
||
const isDark = document.body.classList.contains("dark");
|
||
btn.textContent = isDark ? "☀️ Light Mode" : "🌙 Dark Mode";
|
||
localStorage.setItem("theme", isDark ? "dark" : "light");
|
||
});
|
||
|
||
// Sessions - Load from server
|
||
(async () => {
|
||
await loadSessionsFromServer();
|
||
await renderSessions();
|
||
|
||
// Ensure we have at least one session
|
||
if (sessions.length === 0) {
|
||
const id = generateSessionId();
|
||
const name = "default";
|
||
currentSession = id;
|
||
history = [];
|
||
await saveSession(); // Create empty session on server
|
||
await saveSessionMetadata(id, name);
|
||
await loadSessionsFromServer();
|
||
await renderSessions();
|
||
localStorage.setItem("currentSession", currentSession);
|
||
} else {
|
||
// If no current session or current session doesn't exist, use first one
|
||
if (!currentSession || !sessions.find(s => s.id === currentSession)) {
|
||
currentSession = sessions[0].id;
|
||
localStorage.setItem("currentSession", currentSession);
|
||
}
|
||
}
|
||
|
||
// Load current session history
|
||
if (currentSession) {
|
||
await loadSession(currentSession);
|
||
}
|
||
})();
|
||
|
||
// Switch session
|
||
document.getElementById("sessions").addEventListener("change", async e => {
|
||
currentSession = e.target.value;
|
||
history = [];
|
||
localStorage.setItem("currentSession", currentSession);
|
||
addMessage("system", `Switched to session: ${getSessionName(currentSession)}`);
|
||
await loadSession(currentSession);
|
||
});
|
||
|
||
// Create new session
|
||
document.getElementById("newSessionBtn").addEventListener("click", async () => {
|
||
const name = prompt("Enter new session name:");
|
||
if (!name) return;
|
||
const id = generateSessionId();
|
||
currentSession = id;
|
||
history = [];
|
||
localStorage.setItem("currentSession", currentSession);
|
||
|
||
// Create session on server
|
||
await saveSession();
|
||
await saveSessionMetadata(id, name);
|
||
await loadSessionsFromServer();
|
||
await renderSessions();
|
||
|
||
addMessage("system", `Created session: ${name}`);
|
||
});
|
||
|
||
// Rename session
|
||
document.getElementById("renameSessionBtn").addEventListener("click", async () => {
|
||
const session = sessions.find(s => s.id === currentSession);
|
||
if (!session) return;
|
||
const newName = prompt("Rename session:", session.name || currentSession);
|
||
if (!newName) return;
|
||
|
||
// Update metadata on server
|
||
await saveSessionMetadata(currentSession, newName);
|
||
await loadSessionsFromServer();
|
||
await renderSessions();
|
||
|
||
addMessage("system", `Session renamed to: ${newName}`);
|
||
});
|
||
|
||
// Thinking Stream button
|
||
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
|
||
if (!currentSession) {
|
||
alert("Please select a session first");
|
||
return;
|
||
}
|
||
|
||
// Open thinking stream in new window
|
||
const streamUrl = `http://10.0.0.41:8081/thinking-stream.html?session=${currentSession}`;
|
||
const windowFeatures = "width=600,height=800,menubar=no,toolbar=no,location=no,status=no";
|
||
window.open(streamUrl, `thinking_${currentSession}`, windowFeatures);
|
||
|
||
addMessage("system", "🧠 Opened thinking stream in new window");
|
||
});
|
||
|
||
|
||
// Settings Modal
|
||
const settingsModal = document.getElementById("settingsModal");
|
||
const settingsBtn = document.getElementById("settingsBtn");
|
||
const closeModalBtn = document.getElementById("closeModalBtn");
|
||
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
|
||
const cancelSettingsBtn = document.getElementById("cancelSettingsBtn");
|
||
const modalOverlay = document.querySelector(".modal-overlay");
|
||
|
||
// Load saved backend preference
|
||
const savedBackend = localStorage.getItem("standardModeBackend") || "SECONDARY";
|
||
|
||
// Set initial radio button state
|
||
const backendRadios = document.querySelectorAll('input[name="backend"]');
|
||
let isCustomBackend = !["SECONDARY", "OPENAI"].includes(savedBackend);
|
||
|
||
if (isCustomBackend) {
|
||
document.querySelector('input[name="backend"][value="custom"]').checked = true;
|
||
document.getElementById("customBackend").value = savedBackend;
|
||
} else {
|
||
document.querySelector(`input[name="backend"][value="${savedBackend}"]`).checked = true;
|
||
}
|
||
|
||
// Session management functions
|
||
async function loadSessionList() {
|
||
try {
|
||
// Reload from server to get latest
|
||
await loadSessionsFromServer();
|
||
|
||
const sessionListEl = document.getElementById("sessionList");
|
||
if (sessions.length === 0) {
|
||
sessionListEl.innerHTML = '<p style="color: var(--text-fade); font-size: 0.85rem;">No saved sessions found</p>';
|
||
return;
|
||
}
|
||
|
||
sessionListEl.innerHTML = "";
|
||
sessions.forEach(sess => {
|
||
const sessionItem = document.createElement("div");
|
||
sessionItem.className = "session-item";
|
||
|
||
const sessionInfo = document.createElement("div");
|
||
sessionInfo.className = "session-info";
|
||
|
||
const sessionName = sess.name || sess.id;
|
||
const lastModified = new Date(sess.lastModified).toLocaleString();
|
||
|
||
sessionInfo.innerHTML = `
|
||
<strong>${sessionName}</strong>
|
||
<small>${sess.messageCount} messages • ${lastModified}</small>
|
||
`;
|
||
|
||
const deleteBtn = document.createElement("button");
|
||
deleteBtn.className = "session-delete-btn";
|
||
deleteBtn.textContent = "🗑️";
|
||
deleteBtn.title = "Delete session";
|
||
deleteBtn.onclick = async () => {
|
||
if (!confirm(`Delete session "${sessionName}"?`)) return;
|
||
|
||
try {
|
||
await fetch(`${RELAY_BASE}/sessions/${sess.id}`, { method: "DELETE" });
|
||
|
||
// Reload sessions from server
|
||
await loadSessionsFromServer();
|
||
|
||
// If we deleted the current session, switch to another or create new
|
||
if (currentSession === sess.id) {
|
||
if (sessions.length > 0) {
|
||
currentSession = sessions[0].id;
|
||
localStorage.setItem("currentSession", currentSession);
|
||
history = [];
|
||
await loadSession(currentSession);
|
||
} else {
|
||
const id = generateSessionId();
|
||
const name = "default";
|
||
currentSession = id;
|
||
localStorage.setItem("currentSession", currentSession);
|
||
history = [];
|
||
await saveSession();
|
||
await saveSessionMetadata(id, name);
|
||
await loadSessionsFromServer();
|
||
}
|
||
}
|
||
|
||
// Refresh both the dropdown and the settings list
|
||
await renderSessions();
|
||
await loadSessionList();
|
||
|
||
addMessage("system", `Deleted session: ${sessionName}`);
|
||
} catch (e) {
|
||
alert("Failed to delete session: " + e.message);
|
||
}
|
||
};
|
||
|
||
sessionItem.appendChild(sessionInfo);
|
||
sessionItem.appendChild(deleteBtn);
|
||
sessionListEl.appendChild(sessionItem);
|
||
});
|
||
} catch (e) {
|
||
const sessionListEl = document.getElementById("sessionList");
|
||
sessionListEl.innerHTML = '<p style="color: #ff3333; font-size: 0.85rem;">Failed to load sessions</p>';
|
||
}
|
||
}
|
||
|
||
// Show modal and load session list
|
||
settingsBtn.addEventListener("click", () => {
|
||
settingsModal.classList.add("show");
|
||
loadSessionList(); // Refresh session list when opening settings
|
||
});
|
||
|
||
// Hide modal functions
|
||
const hideModal = () => {
|
||
settingsModal.classList.remove("show");
|
||
};
|
||
|
||
closeModalBtn.addEventListener("click", hideModal);
|
||
cancelSettingsBtn.addEventListener("click", hideModal);
|
||
modalOverlay.addEventListener("click", hideModal);
|
||
|
||
// ESC key to close
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && settingsModal.classList.contains("show")) {
|
||
hideModal();
|
||
}
|
||
});
|
||
|
||
// Save settings
|
||
saveSettingsBtn.addEventListener("click", () => {
|
||
const selectedRadio = document.querySelector('input[name="backend"]:checked');
|
||
let backendValue;
|
||
|
||
if (selectedRadio.value === "custom") {
|
||
backendValue = document.getElementById("customBackend").value.trim().toUpperCase();
|
||
if (!backendValue) {
|
||
alert("Please enter a custom backend name");
|
||
return;
|
||
}
|
||
} else {
|
||
backendValue = selectedRadio.value;
|
||
}
|
||
|
||
localStorage.setItem("standardModeBackend", backendValue);
|
||
addMessage("system", `Backend changed to: ${backendValue}`);
|
||
hideModal();
|
||
});
|
||
|
||
// Health check
|
||
checkHealth();
|
||
setInterval(checkHealth, 10000);
|
||
|
||
// Input events
|
||
document.getElementById("sendBtn").addEventListener("click", sendMessage);
|
||
document.getElementById("userInput").addEventListener("keypress", e => {
|
||
if (e.key === "Enter") sendMessage();
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|