// 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 = /${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}:${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();
}
});
});
})();