botserver/web/index.html

1073 lines
37 KiB
HTML

<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<title>General Bots 2400</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.2/anime.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800&family=Inter:wght@400;600&display=swap");
:root {
--dante-blue: #000a1f;
--dante-blue2: #001a3d;
--dante-gold: #ffd700;
--dante-gold2: #ffed4e;
--dante-glow: rgba(255, 215, 0, 0.8);
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: "Inter", sans-serif;
background: radial-gradient(
circle at 20% 30%,
rgba(0, 48, 135, 0.4),
rgba(0, 26, 77, 0.7)
);
color: #fff;
overflow: hidden;
}
.neon-text {
color: var(--dante-gold);
text-shadow:
0 0 15px var(--dante-glow),
0 0 30px var(--dante-glow),
0 0 60px var(--dante-glow),
0 0 90px rgba(255, 215, 0, 0.5);
font-weight: 700;
letter-spacing: 2px;
}
.neon-border {
border: 3px solid var(--dante-gold);
box-shadow:
0 0 40px var(--dante-glow),
0 0 60px rgba(255, 215, 0, 0.6),
inset 0 0 30px rgba(255, 215, 0, 0.15);
}
.glass {
background: rgba(0, 20, 60, 0.4);
backdrop-filter: blur(20px);
border: 1px solid rgba(253, 185, 19, 0.2);
}
.background-animated {
position: fixed;
top: 0;
left: 0;
width: 200%;
height: 200%;
background: conic-gradient(
from 90deg,
#001a4d,
#003087,
#00509e,
#003087,
#001a4d
);
animation: rotate-bg 20s linear infinite;
opacity: 0.5;
z-index: 0;
filter: blur(120px);
}
@keyframes rotate-bg {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.shine::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(253, 185, 19, 0.4),
transparent
);
animation: shine 3s infinite;
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.typing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dante-gold);
animation: typing-bounce 1.2s infinite;
box-shadow: 0 0 10px var(--dante-glow);
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-bounce {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-8px);
}
}
::-webkit-scrollbar {
width: 0;
display: none;
}
.voice-status {
text-align: center;
margin: 10px 0;
color: #19c37d;
font-family: "Orbitron", monospace;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.voice-message {
color: #19c37d;
font-style: italic;
}
.history-item {
padding: 12px;
border-radius: 6px;
margin-bottom: 5px;
cursor: pointer;
color: #d1d5db;
transition: all 0.3s ease;
}
.history-item:hover {
background: rgba(255, 215, 0, 0.1);
}
.sidebar {
width: 260px;
background: rgba(0, 10, 31, 0.8);
padding: 10px;
display: flex;
flex-direction: column;
backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 215, 0, 0.2);
}
.voice-toggle {
background: rgba(25, 195, 125, 0.2);
border: 1px solid rgba(25, 195, 125, 0.5);
color: #19c37d;
padding: 12px;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.3s ease;
font-family: "Orbitron", monospace;
font-weight: 600;
}
.voice-toggle.recording {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
color: #ef4444;
}
.new-chat {
background: transparent;
border: 1px solid rgba(255, 215, 0, 0.3);
color: var(--dante-gold);
padding: 12px;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.3s ease;
font-family: "Orbitron", monospace;
font-weight: 600;
}
.new-chat:hover,
.voice-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}
.history {
flex: 1;
overflow-y: auto;
margin-top: 10px;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.message {
max-width: 800px;
margin: 0 auto 20px;
line-height: 1.5;
}
.user-message {
color: #d1d5db;
}
.assistant-message {
color: #ececf1;
}
.input-area {
max-width: 800px;
margin: 0 auto 20px;
position: relative;
}
.input-area input {
width: 100%;
background: rgba(64, 65, 79, 0.5);
border: none;
padding: 12px 45px 12px 15px;
border-radius: 12px;
color: white;
font-size: 16px;
backdrop-filter: blur(10px);
}
.input-area button {
position: absolute;
right: 5px;
top: 5px;
background: rgba(25, 195, 125, 0.3);
border: 1px solid rgba(25, 195, 125, 0.5);
padding: 8px 12px;
border-radius: 6px;
color: #19c37d;
cursor: pointer;
font-family: "Orbitron", monospace;
font-weight: 600;
}
.thinking-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(255, 215, 0, 0.1);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 12px;
margin: 10px auto;
max-width: 800px;
animation: neonPulse 2s infinite;
box-shadow: 0 0 20px rgba(255, 215, 0, 0.3);
}
@keyframes neonPulse {
0%,
100% {
box-shadow:
0 0 5px rgba(255, 215, 0, 0.3),
0 0 10px rgba(255, 215, 0, 0.2);
}
50% {
box-shadow:
0 0 10px rgba(255, 215, 0, 0.5),
0 0 20px rgba(255, 215, 0, 0.3),
0 0 30px rgba(255, 215, 0, 0.1);
}
}
.warning-message {
background: rgba(255, 69, 0, 0.2);
border: 1px solid rgba(255, 69, 0, 0.5);
color: #ff4500;
padding: 12px 16px;
border-radius: 12px;
margin: 10px auto;
max-width: 800px;
text-align: center;
animation: flash 0.5s ease 3;
}
@keyframes flash {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Markdown Styles */
.markdown-content {
line-height: 1.6;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: var(--dante-gold);
font-weight: 600;
}
.markdown-content h1 {
font-size: 1.8em;
border-bottom: 2px solid var(--dante-gold);
padding-bottom: 0.3em;
}
.markdown-content h2 {
font-size: 1.5em;
}
.markdown-content h3 {
font-size: 1.3em;
}
.markdown-content p {
margin-bottom: 1em;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 1em;
padding-left: 2em;
}
.markdown-content li {
margin-bottom: 0.5em;
}
.markdown-content code {
background: rgba(255, 215, 0, 0.1);
color: var(--dante-gold2);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: rgba(0, 10, 31, 0.8);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 8px;
padding: 1em;
overflow-x: auto;
margin-bottom: 1em;
}
.markdown-content pre code {
background: none;
padding: 0;
color: #e0e0e0;
}
.markdown-content blockquote {
border-left: 4px solid var(--dante-gold);
padding-left: 1em;
margin-left: 0;
margin-bottom: 1em;
color: #ccc;
font-style: italic;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1em;
}
.markdown-content th,
.markdown-content td {
border: 1px solid rgba(255, 215, 0, 0.3);
padding: 0.5em;
text-align: left;
}
.markdown-content th {
background: rgba(255, 215, 0, 0.1);
color: var(--dante-gold);
font-weight: 600;
}
.markdown-content a {
color: var(--dante-gold2);
text-decoration: none;
border-bottom: 1px dotted var(--dante-gold2);
}
.markdown-content a:hover {
border-bottom: 1px solid var(--dante-gold2);
}
.markdown-content strong {
color: var(--dante-gold2);
font-weight: 600;
}
.markdown-content em {
color: #ffed4e;
font-style: italic;
}
</style>
</head>
<body class="relative overflow-hidden flex">
<div class="background-animated"></div>
<!-- Sidebar -->
<div class="sidebar">
<button class="new-chat" onclick="createNewSession()">
+ Novo Chat
</button>
<button
class="voice-toggle"
id="voiceToggle"
onclick="toggleVoiceMode()"
>
🎤 Modo Voz
</button>
<div class="history" id="history"></div>
</div>
<!-- Main Content -->
<div class="main">
<header
class="glass border-b border-yellow-400/40 relative z-20 flex items-center justify-between px-10 py-6 shadow-2xl"
>
<div class="flex items-center gap-4">
<div
class="w-14 h-14 rounded-2xl neon-border flex items-center justify-center relative shine"
>
<span class="text-3xl font-extrabold neon-text">D</span>
</div>
<h1 class="text-4xl font-extrabold neon-text">
General Bots
</h1>
</div>
<button
id="newChatBtn"
class="px-8 py-3 rounded-xl glass neon-border text-yellow-300 font-semibold hover:scale-105 transition-all"
>
Novo Chat
</button>
</header>
<div class="voice-status" id="voiceStatus" style="display: none">
<div class="pulse">🎤 Ouvindo... Fale agora</div>
</div>
<main
id="messages"
class="relative z-10 overflow-y-auto h-[calc(100vh-170px)] px-10 py-8"
>
<div
id="emptyState"
class="text-center pt-40 flex flex-col items-center justify-center"
>
<div
class="w-36 h-36 rounded-3xl neon-border flex items-center justify-center shine mb-6"
>
<span class="text-7xl neon-text font-extrabold">D</span>
</div>
<h2 class="text-4xl neon-text font-bold">
Bem-vindo ao General Bots
</h2>
<p class="text-blue-200 mt-3 opacity-80 text-lg">
Seu assistente de IA avançado
</p>
</div>
</main>
<footer
class="glass border-t border-yellow-400/30 px-10 py-6 relative z-20 backdrop-blur-lg"
>
<div class="flex items-center gap-4">
<input
id="messageInput"
type="text"
placeholder="Fale com General Bots..."
class="flex-1 rounded-2xl px-8 py-4 glass border border-yellow-400/40 text-lg text-white placeholder-yellow-200/40 focus:outline-none focus:border-yellow-300 transition-all"
/>
<button
id="sendBtn"
class="rounded-2xl px-10 py-4 neon-border hover:scale-105 transition-all text-yellow-300 font-bold tracking-wide"
>
Enviar
</button>
</div>
</footer>
</div>
<script>
let ws = null;
let currentSessionId = null;
let isStreaming = false;
let voiceRoom = null;
let isVoiceMode = false;
let mediaRecorder = null;
let audioChunks = [];
let streamingMessageId = null;
let isThinking = false;
let thinkingIndicatorId = null;
const messagesDiv = document.getElementById("messages");
const input = document.getElementById("messageInput");
const sendBtn = document.getElementById("sendBtn");
const newChatBtn = document.getElementById("newChatBtn");
// Configure marked for markdown parsing
marked.setOptions({
highlight: function (code, lang) {
// Simple syntax highlighting - you could integrate highlight.js here
return `<pre><code class="language-${lang}">${code}</code></pre>`;
},
breaks: true,
gfm: true,
});
// Initialize
createNewSession();
async function loadSessions() {
const response = await fetch("/api/sessions");
const sessions = await response.json();
const history = document.getElementById("history");
history.innerHTML = "";
sessions.forEach((session) => {
const div = document.createElement("div");
div.className = "history-item";
div.textContent = session.title;
div.onclick = () => switchSession(session.id);
history.appendChild(div);
});
}
async function createNewSession() {
const response = await fetch("/api/sessions", {
method: "POST",
});
const session = await response.json();
currentSessionId = session.session_id;
connectWebSocket();
loadSessions();
document.getElementById("messages").innerHTML = "";
if (isVoiceMode) {
await startVoiceSession();
}
await fetch("/api/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
}),
});
}
function switchSession(sessionId) {
currentSessionId = sessionId;
loadSessionHistory(sessionId);
connectWebSocket();
if (isVoiceMode) {
startVoiceSession();
}
}
async function loadSessionHistory(sessionId) {
const response = await fetch("/api/sessions/" + sessionId);
const history = await response.json();
const messages = document.getElementById("messages");
messages.innerHTML = "";
history.forEach(([role, content]) => {
const className =
role === "user"
? "user-message"
: role === "assistant"
? "assistant-message"
: "voice-message";
addMessage(
role === "user" ? "Você" : "Assistente",
content,
className,
);
});
}
function connectWebSocket() {
if (ws) ws.close();
ws = new WebSocket("ws://" + window.location.host + "/ws");
ws.onmessage = function (event) {
const response = JSON.parse(event.data);
// Handle event messages (thinking_start, thinking_end, warn)
if (response.message_type === 2) {
const eventData = JSON.parse(response.content);
handleEvent(eventData.event, eventData.data);
return;
}
// Handle regular messages
if (!response.is_complete) {
if (!isStreaming) {
isStreaming = true;
streamingMessageId = "streaming-" + Date.now();
addMessage(
"assistant",
response.content,
true,
streamingMessageId,
);
} else {
updateLastMessage(response.content);
}
} else {
isStreaming = false;
streamingMessageId = null;
}
};
ws.onopen = function () {
console.log("Connected to WebSocket");
};
}
function handleEvent(eventType, eventData) {
console.log("Event received:", eventType, eventData);
switch (eventType) {
case "thinking_start":
showThinkingIndicator();
isThinking = true;
break;
case "thinking_end":
hideThinkingIndicator();
isThinking = false;
break;
case "warn":
showWarning(eventData.message);
break;
default:
console.log("Unknown event type:", eventType);
}
}
function showThinkingIndicator() {
if (isThinking) return;
const emptyState = document.getElementById("emptyState");
if (emptyState) emptyState.remove();
const thinkingDiv = document.createElement("div");
thinkingDiv.id = "thinking-indicator";
thinkingDiv.className = "thinking-indicator";
thinkingDiv.innerHTML = `
<div class="typing-dots flex gap-2">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
<span class="text-yellow-300 font-semibold">Pensando...</span>
`;
messagesDiv.appendChild(thinkingDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
isThinking = true;
thinkingIndicatorId = thinkingDiv.id;
}
function hideThinkingIndicator() {
if (!isThinking) return;
const thinkingDiv =
document.getElementById("thinking-indicator");
if (thinkingDiv) {
thinkingDiv.remove();
}
isThinking = false;
thinkingIndicatorId = null;
}
function showWarning(message) {
const warningDiv = document.createElement("div");
warningDiv.className = "warning-message";
warningDiv.innerHTML = `⚠️ ${message}`;
messagesDiv.appendChild(warningDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
// Remove warning after 5 seconds
setTimeout(() => {
if (warningDiv.parentNode) {
warningDiv.remove();
}
}, 5000);
}
function addMessage(
role,
content,
streaming = false,
msgId = null,
) {
const emptyState = document.getElementById("emptyState");
if (emptyState) emptyState.remove();
const msg = document.createElement("div");
msg.className = "mb-8";
// Parse markdown for assistant messages
const processedContent =
role === "assistant" || role === "voice"
? marked.parse(content)
: content;
if (role === "user") {
msg.innerHTML = `<div class="flex justify-end"><div class="glass neon-border rounded-2xl px-6 py-4 max-w-3xl text-lg text-yellow-100 font-semibold shadow-2xl">${content}</div></div>`;
} else if (role === "assistant") {
msg.innerHTML = `<div class="flex justify-start"><div class="flex gap-4 max-w-3xl"><div class="w-12 h-12 rounded-xl neon-border flex items-center justify-center flex-shrink-0 shine shadow-2xl"><span class="text-2xl neon-text font-extrabold">D</span></div><div class="glass border-2 border-yellow-400/30 rounded-2xl px-6 py-4 flex-1 text-blue-50 font-medium text-lg shadow-2xl markdown-content" id="${streaming ? msgId : ""}">${streaming ? "" : processedContent}</div></div></div>`;
} else {
// Voice message
msg.innerHTML = `<div class="flex justify-start"><div class="flex gap-4 max-w-3xl"><div class="w-12 h-12 rounded-xl neon-border flex items-center justify-center flex-shrink-0 shine shadow-2xl"><span class="text-2xl neon-text font-extrabold">D</span></div><div class="glass border-2 border-green-400/30 rounded-2xl px-6 py-4 flex-1 text-green-100 font-medium text-lg shadow-2xl">${content}</div></div></div>`;
}
messagesDiv.appendChild(msg);
gsap.from(msg, {
opacity: 0,
y: 30,
duration: 0.6,
ease: "power3.out",
});
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function updateLastMessage(content) {
const m = document.getElementById(streamingMessageId);
if (m) {
// Parse markdown incrementally during streaming
const currentContent = m.textContent || m.innerText;
const newContent = currentContent + content;
m.innerHTML = marked.parse(newContent);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
}
function sendMessage() {
const message = input.value.trim();
if (!message || !ws || ws.readyState !== WebSocket.OPEN) return;
// Hide thinking indicator if it's showing
if (isThinking) {
hideThinkingIndicator();
}
addMessage("user", message);
ws.send(message);
input.value = "";
anime({
targets: sendBtn,
scale: [1, 0.85, 1],
duration: 300,
easing: "easeInOutQuad",
});
}
sendBtn.onclick = sendMessage;
input.addEventListener("keypress", (e) => {
if (e.key === "Enter") sendMessage();
});
newChatBtn.onclick = () => createNewSession();
async function toggleVoiceMode() {
isVoiceMode = !isVoiceMode;
const voiceToggle = document.getElementById("voiceToggle");
const voiceStatus = document.getElementById("voiceStatus");
if (isVoiceMode) {
voiceToggle.textContent = "🔴 Parar Voz";
voiceToggle.classList.add("recording");
voiceStatus.style.display = "block";
await startVoiceSession();
} else {
voiceToggle.textContent = "🎤 Modo Voz";
voiceToggle.classList.remove("recording");
voiceStatus.style.display = "none";
await stopVoiceSession();
}
}
async function startVoiceSession() {
if (!currentSessionId) return;
try {
const response = await fetch("/api/voice/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
user_id: "user_" + currentSessionId,
}),
});
const data = await response.json();
if (data.token) {
await connectToVoiceRoom(data.token);
startVoiceRecording();
}
} catch (error) {
console.error("Failed to start voice session:", error);
}
}
async function stopVoiceSession() {
if (!currentSessionId) return;
try {
await fetch("/api/voice/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
}),
});
if (voiceRoom) {
voiceRoom.disconnect();
voiceRoom = null;
}
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
}
} catch (error) {
console.error("Failed to stop voice session:", error);
}
}
async function connectToVoiceRoom(token) {
try {
const room = new LiveKitClient.Room();
await room.connect("ws://localhost:7880", token);
voiceRoom = room;
room.on("dataReceived", (data) => {
const decoder = new TextDecoder();
const message = decoder.decode(data);
try {
const parsed = JSON.parse(message);
if (parsed.type === "voice_response") {
addMessage("assistant", parsed.text);
}
} catch (e) {
console.log("Voice data:", message);
}
});
const localTracks = await LiveKitClient.createLocalTracks({
audio: true,
video: false,
});
for (const track of localTracks) {
await room.localParticipant.publishTrack(track);
}
} catch (error) {
console.error("Failed to connect to voice room:", error);
}
}
function startVoiceRecording() {
if (!navigator.mediaDevices) {
console.log("Media devices not supported");
return;
}
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, {
type: "audio/wav",
});
simulateVoiceTranscription();
};
mediaRecorder.start();
setTimeout(() => {
if (
mediaRecorder &&
mediaRecorder.state === "recording"
) {
mediaRecorder.stop();
setTimeout(() => {
if (isVoiceMode) {
startVoiceRecording();
}
}, 1000);
}
}, 5000);
})
.catch((error) => {
console.error("Error accessing microphone:", error);
});
}
function simulateVoiceTranscription() {
const phrases = [
"Olá, como posso ajudá-lo hoje?",
"Entendo o que você está dizendo",
"Esse é um ponto interessante",
"Deixe-me pensar sobre isso",
"Posso ajudá-lo com isso",
"O que você gostaria de saber?",
"Isso parece ótimo",
"Estou ouvindo sua voz",
];
const randomPhrase =
phrases[Math.floor(Math.random() * phrases.length)];
if (voiceRoom) {
const message = {
type: "voice_input",
content: randomPhrase,
timestamp: new Date().toISOString(),
};
voiceRoom.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify(message)),
LiveKitClient.DataPacketKind.RELIABLE,
);
}
addMessage("voice", `🎤 ${randomPhrase}`);
}
// Neon text animation
gsap.to(".neon-text", {
textShadow:
"0 0 25px var(--dante-glow),0 0 50px var(--dante-glow),0 0 100px rgba(255,215,0,0.8)",
repeat: -1,
yoyo: true,
duration: 1.8,
ease: "power1.inOut",
});
// Test warning functionality
window.testWarning = function () {
fetch("/api/warn", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: currentSessionId || "default",
channel: "web",
message: "Esta é uma mensagem de teste de aviso!",
}),
});
};
// Test markdown functionality
window.testMarkdown = function () {
const markdownContent = `# Título Principal
## Subtítulo
Este é um **texto em negrito** e este é um *texto em itálico*.
### Lista de Itens:
- Primeiro item
- Segundo item
- Terceiro item
### Código:
\`\`\`javascript
function exemplo() {
console.log("Olá, mundo!");
return 42;
}
\`\`\`
> Esta é uma citação importante sobre o assunto.
[Link para documentação](https://exemplo.com)`;
addMessage("assistant", markdownContent);
};
</script>
</body>
</html>