// Plain Vanilla Chat Frontend with Copy Button, SSE Streaming, Export, and Clear // Global mode management window.setMode = function(mode) { window.currentMode = mode; document.getElementById("btn-assistant").className = "mode-btn" + (mode === "assistant" ? " active" : ""); document.getElementById("btn-trainer").className = "mode-btn" + (mode === "trainer" ? " trainer-active" : ""); const label = document.getElementById("mode-label"); if(label) label.textContent = mode === "trainer" ? "— Trainer Mode 🎓" : ""; const output = document.getElementById("chat-output"); if(output) output.innerHTML = ""; fetch("/chat/clear", { method: "POST" }); }; window.currentMode = "assistant"; window.toggleTheme = function() { const root = document.documentElement; const btn = document.getElementById("theme-btn"); const isLight = root.classList.toggle("light"); btn.textContent = isLight ? "🌙" : "☀️"; btn.title = isLight ? "Switch to dark theme" : "Switch to light theme"; localStorage.setItem("deepshell-theme", isLight ? "light" : "dark"); // Swap Prism theme const darkTheme = document.getElementById("prism-theme"); const lightTheme = document.getElementById("prism-light-theme"); if (darkTheme && lightTheme) { darkTheme.disabled = isLight; lightTheme.disabled = !isLight; } }; // Apply saved Prism theme on load document.addEventListener("DOMContentLoaded", function() { const saved = localStorage.getItem("deepshell-theme"); if (saved === "light") { const darkTheme = document.getElementById("prism-theme"); const lightTheme = document.getElementById("prism-light-theme"); if (darkTheme && lightTheme) { darkTheme.disabled = true; lightTheme.disabled = false; } const btn = document.getElementById("theme-btn"); if (btn) btn.textContent = "🌙"; } }); // Apply saved theme on load (function() { const saved = localStorage.getItem("deepshell-theme"); if (saved === "light") { document.documentElement.classList.add("light"); document.addEventListener("DOMContentLoaded", function() { const btn = document.getElementById("theme-btn"); if (btn) { btn.textContent = "🌙"; } }); } })(); window.ttsEnabled = false; window.lastSpokenText = ""; window.ttsLang = 'en-US'; window.setLang = function(lang) { window.ttsLang = lang; window.speechSynthesis.cancel(); }; window.toggleTTS = function() { window.ttsEnabled = !window.ttsEnabled; const btn = document.getElementById("tts-btn"); if (window.ttsEnabled) { btn.classList.add("tts-active"); btn.title = "Voice ON — click to disable"; btn.innerHTML = "🔊 Voice ON"; } else { btn.classList.remove("tts-active"); btn.title = "Voice OFF — click to enable"; btn.innerHTML = "🔇 Voice OFF"; window.speechSynthesis.cancel(); } }; window.replayLast = function() { if (window.lastSpokenText) { window.speakText(window.lastSpokenText); } }; window.speakText = function(text) { if (!window.ttsEnabled || window.currentMode !== "trainer") return; window.lastSpokenText = text; window.speechSynthesis.cancel(); // Strip markdown symbols for cleaner speech const clean = text .replace(/#{1,6}\s/g, "") .replace(/\*\*/g, "") .replace(/\*/g, "") .replace(/```[\s\S]*?```/g, "") .replace(/`[^`]+`/g, "code snippet") .replace(/🎯|💻|🔍|⚠️|🚀|💡/g, "") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") .trim(); // Non-English: translate via backend then speak via Piper TTS if (window.ttsLang !== 'en-US' && window.ttsLang !== 'en-GB') { const langCode = window.ttsLang.split('-')[0]; const sentences = clean.match(/[^.!?]+[.!?]+/g) || [clean]; let i = 0; function translateAndSpeakNext() { if (i >= sentences.length || !window.ttsEnabled) return; const sentence = sentences[i].trim(); fetch('/translate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: sentence, target: langCode}) }) .then(r => r.json()) .then(data => { const translated = data.translatedText || sentence; return fetch('/tts', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: translated, lang: langCode}) }); }) .then(r => r.blob()) .then(blob => { const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.onended = () => { URL.revokeObjectURL(url); i++; translateAndSpeakNext(); }; // Small delay on first sentence to avoid audio startup cutoff const delay = i === 0 ? 300 : 0; setTimeout(() => audio.play(), delay); }) .catch(() => { const utt = new SpeechSynthesisUtterance(sentence); utt.lang = window.ttsLang; utt.onend = () => { i++; translateAndSpeakNext(); }; window.speechSynthesis.speak(utt); }); } translateAndSpeakNext(); return; } window._speakDirect(clean); }; window._speakDirect = function(clean) { const chunks = clean.match(/[^.!?]+[.!?]+/g) || [clean]; let i = 0; function speakNext() { if (i >= chunks.length || !window.ttsEnabled) return; const utt = new SpeechSynthesisUtterance(chunks[i].trim()); utt.lang = window.ttsLang; const voices = window.speechSynthesis.getVoices(); const match = voices.find(v => v.lang === window.ttsLang); if (match) utt.voice = match; utt.rate = 0.95; utt.pitch = 1.0; utt.volume = 1.0; utt.onend = () => { i++; speakNext(); }; window.speechSynthesis.speak(utt); } speakNext(); }; (function () { "use strict"; // ---------- Utilities ---------- function escapeHtml(text) { return String(text).replace(/[&<>"']/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[m]); } function renderMarkdown(md) { if (window.marked) { const renderer = new marked.Renderer(); renderer.code = function(code, lang) { const language = lang || 'bash'; const validLang = Prism.languages[language] ? language : 'bash'; return `
${escapeHtml(code)}
`; }; return marked.parse(md, { renderer }); } return `
${escapeHtml(md)}
`; } function createCopyButton(code) { const btn = document.createElement("button"); btn.className = "copy-btn"; btn.textContent = "Copy"; btn.onclick = async () => { try { await navigator.clipboard.writeText(code); btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = "Copy"; }, 2000); } catch (err) { const textarea = document.createElement("textarea"); textarea.value = code; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); try { document.execCommand("copy"); btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = "Copy"; }, 2000); } catch (e) { btn.textContent = "Failed"; setTimeout(() => { btn.textContent = "Copy"; }, 2000); } document.body.removeChild(textarea); } }; return btn; } function createExecuteButton(command, output) { const btn = document.createElement("button"); btn.className = "execute-btn"; btn.textContent = "Execute"; btn.onclick = async () => { btn.disabled = true; btn.textContent = "Executing..."; try { const response = await fetch("/chat/execute", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command }), }); const result = await response.json(); if (result.success) { const outputText = result.stdout || result.stderr || "(no output)"; appendMessage(output, "Command Output", `Exit Code: ${result.exit_code}\n\n${outputText}`, false); btn.textContent = "✓ Executed"; btn.style.background = "#22c55e"; } else { appendMessage(output, "Execution Error", result.error || "Unknown error", false); btn.textContent = "✗ Failed"; btn.style.background = "#ef4444"; } } catch (err) { appendMessage(output, "Error", `Failed to execute: ${err.message}`, false); btn.textContent = "✗ Error"; btn.style.background = "#ef4444"; } setTimeout(() => { btn.disabled = false; btn.textContent = "Execute"; btn.style.background = ""; }, 3000); }; return btn; } function extractExecuteTags(text) { const regex = /(.*?)<\/execute>/gs; const commands = []; let match; while ((match = regex.exec(text)) !== null) { commands.push(match[1].trim()); } return commands; } // Add copy buttons to all code blocks in a container function addCopyButtonsToCodeBlocks(container) { container.querySelectorAll("pre").forEach((pre) => { if (pre.parentElement && pre.parentElement.classList.contains("code-wrapper")) { return; } const codeEl = pre.querySelector("code"); const code = codeEl ? codeEl.textContent : pre.textContent; if (!code || !code.trim()) return; const wrapper = document.createElement("div"); wrapper.className = "code-wrapper"; const copyBtn = createCopyButton(code); pre.parentNode.insertBefore(wrapper, pre); wrapper.appendChild(copyBtn); wrapper.appendChild(pre); }); } function appendMessage(container, sender, message, useMarkdown = false, isError = false) { const msgDiv = document.createElement("div"); msgDiv.classList.add("message"); if (sender === "You") msgDiv.classList.add("user-msg"); if (isError) msgDiv.classList.add("error"); const commands = extractExecuteTags(message); if (commands.length > 0) { let displayText = message.replace(/.*?<\/execute>/gs, ""); msgDiv.innerHTML = `${sender}:${useMarkdown ? renderMarkdown(displayText) : `
${escapeHtml(displayText)}
`}`; commands.forEach(cmd => { const cmdWrapper = document.createElement("div"); cmdWrapper.className = "command-wrapper"; const cmdPre = document.createElement("pre"); cmdPre.className = "command-block"; cmdPre.textContent = cmd; const btnContainer = document.createElement("div"); btnContainer.className = "command-buttons"; const executeBtn = createExecuteButton(cmd, container); const copyBtn = createCopyButton(cmd); btnContainer.appendChild(executeBtn); btnContainer.appendChild(copyBtn); cmdWrapper.appendChild(cmdPre); cmdWrapper.appendChild(btnContainer); msgDiv.appendChild(cmdWrapper); }); } else if (useMarkdown) { msgDiv.innerHTML = `${sender}:
${renderMarkdown(String(message))}
`; addCopyButtonsToCodeBlocks(msgDiv); } else { msgDiv.innerHTML = `${sender}:
${escapeHtml(String(message))}
`; } container.appendChild(msgDiv); container.scrollTop = container.scrollHeight; if (window.Prism) { try { Prism.highlightAllUnder(msgDiv); } catch (_) {} } return msgDiv; } // ---------- Streaming UI ---------- function createStreamingMessage(container, sender) { const msgDiv = document.createElement("div"); msgDiv.classList.add("message", "loading"); msgDiv.innerHTML = `${sender}: `; const contentSpan = msgDiv.querySelector(".streaming-content"); container.appendChild(msgDiv); container.scrollTop = container.scrollHeight; return { msgDiv, contentSpan, updateContent: (text) => { contentSpan.textContent = text; container.scrollTop = container.scrollHeight; }, finalize: (text) => { msgDiv.remove(); appendMessage(container, sender, text, true); } }; } // ---------- SSE Streaming ---------- async function sendPromptStreaming(prompt, output, sendBtn, input) { const streamUI = createStreamingMessage(output, "DeepShell"); let fullText = ""; try { const response = await fetch("/chat/run-agent-stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, mode: window.currentMode || "assistant" }), }); if (!response.ok) { throw new Error(`Server error: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n\n"); buffer = lines.pop(); for (const line of lines) { if (!line.trim() || !line.startsWith("data: ")) continue; const jsonStr = line.substring(6); try { const data = JSON.parse(jsonStr); if (data.type === "token") { fullText += data.text; streamUI.updateContent(fullText); } else if (data.type === "done") { streamUI.finalize(fullText); window.speakText(fullText); return; } else if (data.type === "error") { throw new Error(data.message); } } catch (e) { console.warn("Failed to parse SSE data:", jsonStr, e); } } } if (fullText) { streamUI.finalize(fullText); } } catch (err) { streamUI.msgDiv.remove(); appendMessage(output, "Error", `${err.message}`, false, true); console.error("Streaming error:", err); } finally { if (sendBtn) sendBtn.disabled = false; input.focus(); } } // ---------- Clear Chat ---------- async function clearChat(output) { if (!confirm("Clear conversation history? This cannot be undone.")) { return; } try { const response = await fetch("/chat/clear", { method: "POST", }); if (response.ok) { output.innerHTML = ""; appendMessage(output, "System", "Chat cleared. Session history reset.", false); } else { throw new Error("Failed to clear chat"); } } catch (err) { appendMessage(output, "Error", `Failed to clear chat: ${err.message}`, false, true); } } // ---------- Export Chat ---------- function exportChat(output) { const messages = output.querySelectorAll(".message"); if (messages.length === 0) { alert("No messages to export!"); return; } let markdown = "# DeepShell Chat Export\n\n"; markdown += `**Exported:** ${new Date().toLocaleString()}\n\n---\n\n`; messages.forEach((msg) => { const sender = msg.querySelector("strong")?.textContent.replace(":", "") || "Unknown"; // Get text content, excluding buttons const clone = msg.cloneNode(true); clone.querySelectorAll("button").forEach(btn => btn.remove()); const content = clone.textContent .replace(sender + ":", "") .trim(); markdown += `## ${sender}\n\n${content}\n\n---\n\n`; }); // Create download const blob = new Blob([markdown], { type: "text/markdown" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `deepshell-chat-${Date.now()}.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ---------- Chat wiring ---------- document.addEventListener("DOMContentLoaded", () => { const form = document.getElementById("chat-form"); const input = document.getElementById("chat-input"); const output = document.getElementById("chat-output"); const sendBtn = document.getElementById("chat-send"); const clearBtn = document.getElementById("clear-btn"); const exportBtn = document.getElementById("export-btn"); if (!form || !input || !output) { console.warn("Chat elements not found (chat-form/chat-input/chat-output)."); return; } // Send message form.addEventListener("submit", (e) => { e.preventDefault(); const prompt = input.value.trim(); if (!prompt) return; // Hide welcome message on first user input const welcome = document.getElementById("welcome-msg"); if (welcome) welcome.style.display = "none"; appendMessage(output, "You", prompt); input.value = ""; if (sendBtn) sendBtn.disabled = true; sendPromptStreaming(prompt, output, sendBtn, input); }); if (sendBtn) { sendBtn.addEventListener("click", (e) => { e.preventDefault(); form.requestSubmit(); }); } // Clear chat button if (clearBtn) { clearBtn.addEventListener("click", () => { clearChat(output); }); } // Export chat button if (exportBtn) { exportBtn.addEventListener("click", () => { exportChat(output); }); } // Enter to send, Shift+Enter for newline input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } }); }); })();