deepshell / app.js
muralipala1504
fix: strip code blocks before Piper HI TTS — prose only
cda5bcd
// 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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[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 `<pre><code class="language-${validLang}">${escapeHtml(code)}</code></pre>`;
};
return marked.parse(md, { renderer });
}
return `<pre>${escapeHtml(md)}</pre>`;
}
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>(.*?)<\/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>.*?<\/execute>/gs, "");
msgDiv.innerHTML = `<strong>${sender}:</strong>${useMarkdown ? renderMarkdown(displayText) : `<pre>${escapeHtml(displayText)}</pre>`}`;
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 = `<strong>${sender}:</strong><div class="markdown-content">${renderMarkdown(String(message))}</div>`;
addCopyButtonsToCodeBlocks(msgDiv);
} else {
msgDiv.innerHTML = `<strong>${sender}:</strong> <pre>${escapeHtml(String(message))}</pre>`;
}
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 = `<strong>${sender}:</strong> <span class="streaming-content"></span><span class="spinner"></span>`;
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();
}
});
});
})();