<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NeoChat AI</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/atom-one-dark.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.2.12/marked.min.js"></script>
</head>
<body>
<div class="container">
<div class="app-container">
<div class="sidebar">
<div class="logo">
<i class="fas fa-robot"></i>
<span>NeoChat</span>
</div>
<button id="new-chat" class="new-chat-btn">
<i class="fas fa-plus"></i>
<span>New Chat</span>
</button>
<div class="history-container">
<h3>Chat History</h3>
<div id="chat-history"></div>
</div>
<div class="settings">
<button id="clear-history">
<i class="fas fa-trash"></i>
<span>Clear History</span>
</button>
<button id="toggle-theme">
<i class="fas fa-moon"></i>
<span>Dark Mode</span>
</button>
</div>
</div>
<div class="chat-container">
<div class="chat-header">
<div class="current-chat-title" id="current-chat-title">
New Conversation
</div>
<div class="header-actions">
<button id="regenerate-response" title="Regenerate response">
<i class="fas fa-sync"></i>
</button>
<button id="stop-response" title="Stop generating" style="display: none">
<i class="fas fa-stop"></i>
</button>
<button id="export-chat" title="Export conversation">
<i class="fas fa-download"></i>
</button>
</div>
</div>
<div class="messages" id="messages">
<div class="intro-message">
<h1>Welcome to NeoChat AI</h1>
<p>Ask me anything. I'm powered by deepseek-r1.</p>
<div class="suggestion-chips">
<button class="suggestion-chip">Tell me a story</button>
<button class="suggestion-chip">
Explain quantum computing
</button>
<button class="suggestion-chip">Write a poem</button>
<button class="suggestion-chip">
Help me learn JavaScript
</button>
</div>
</div>
</div>
<div class="input-area">
<div class="input-container">
<button id="file-upload-button" title="Upload File">
<i class="fas fa-paperclip"></i>
</button>
<input type="file" id="file-upload" style="display: none" />
<textarea id="user-input" placeholder="Type your message here..." rows="1"></textarea>
<button id="send-button" title="Send message">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<!-- Pending file preview container -->
<div id="pending-file-preview"></div>
<div class="disclaimer">
NeoChat may produce inaccurate information. Messages are stored
locally.
</div>
</div>
</div>
</div>
</div>
</body>
</html>
:root {
--primary-color: #7c4dff;
--secondary-color: #b388ff;
--text-primary: #333;
--text-secondary: #666;
--background-primary: #fff;
--background-secondary: #f5f7fb;
--background-tertiary: #edf0f7;
--ai-message-bg: #f0f4ff;
--user-message-bg: #7c4dff;
--user-message-text: #fff;
--border-color: #e0e0e0;
--shadow-color: rgba(0, 0, 0, 0.1);
--animation-speed: 0.3s;
--border-radius: 12px;
--typing-indicator-color: var(--primary-color);
}
.dark-mode {
--primary-color: #9d74ff;
--secondary-color: #b388ff;
--text-primary: #e0e0e0;
--text-secondary: #aaaaaa;
--background-primary: #1a1a1a;
--background-secondary: #252525;
--background-tertiary: #333333;
--ai-message-bg: #2d2d3d;
--user-message-bg: #9d74ff;
--user-message-text: #fff;
--border-color: #444;
--shadow-color: rgba(0, 0, 0, 0.6);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: "Inter", system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
transition: background-color var(--animation-speed),
color var(--animation-speed);
}
body {
background-color: var(--background-secondary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.code-copy-container {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
}
.code-copy-button {
background-color: rgba(255, 255, 255, 0.1);
color: #ddd;
border: none;
border-radius: 4px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.code-copy-button:hover {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
.code-copy-button.copied {
background-color: #4caf50;
color: white;
}
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
padding: 20px;
}
.app-container {
display: flex;
width: 100%;
max-width: 1200px;
height: 85vh;
background-color: var(--background-primary);
border-radius: var(--border-radius);
box-shadow: 0 8px 30px var(--shadow-color);
overflow: hidden;
position: relative;
}
/* Sidebar Styles */
.sidebar {
width: 260px;
background-color: var(--background-tertiary);
padding: 20px;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
}
.logo {
display: flex;
align-items: center;
font-size: 22px;
font-weight: bold;
margin-bottom: 24px;
color: var(--primary-color);
}
.logo i {
margin-right: 10px;
font-size: 24px;
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-color);
color: white;
border: none;
padding: 12px;
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
margin-bottom: 20px;
transition: background-color 0.2s, transform 0.1s;
}
.new-chat-btn:hover {
background-color: var(--secondary-color);
transform: translateY(-2px);
}
.new-chat-btn i {
margin-right: 8px;
}
.history-container {
flex: 1;
overflow-y: auto;
}
.history-container h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 10px;
padding-left: 5px;
}
.chat-history-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: var(--border-radius);
margin-bottom: 5px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
overflow: visible;
}
#chat-history span {
white-space: nowrap;
width: 16ch;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-history-item:hover {
background-color: var(--background-secondary);
}
.chat-history-item.active {
background-color: rgba(124, 77, 255, 0.1);
font-weight: 500;
}
.chat-history-item i {
margin-right: 10px;
color: var(--text-secondary);
font-size: 14px;
}
.settings {
margin-top: 20px;
border-top: 1px solid var(--border-color);
padding-top: 15px;
}
.settings button {
display: flex;
align-items: center;
background: none;
border: none;
color: var(--text-secondary);
padding: 10px 5px;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: var(--border-radius);
font-size: 14px;
transition: background-color 0.2s;
}
.settings button:hover {
background-color: var(--background-secondary);
color: var(--text-primary);
}
.settings button i {
margin-right: 10px;
width: 16px;
}
/* Chat Container Styles */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--background-primary);
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 25px;
border-bottom: 1px solid var(--border-color);
}
.current-chat-title {
font-weight: 600;
font-size: 16px;
}
.header-actions button {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 16px;
padding: 5px;
border-radius: 5px;
transition: color 0.2s, background-color 0.2s;
}
.header-actions button:hover {
color: var(--primary-color);
background-color: var(--background-tertiary);
}
/* Added regenerate, stop, and export button styling */
.header-actions button#regenerate-response {
margin-right: 5px;
}
/* Chat messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 20px 0;
}
.message {
display: flex;
margin: 25px;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-content {
max-width: 80%;
padding: 12px 16px;
border-radius: var(--border-radius);
font-size: 15px;
line-height: 1.5;
}
.message.ai {
justify-content: flex-start;
}
.message.user {
justify-content: flex-end;
}
.message.ai .message-content {
background-color: var(--ai-message-bg);
color: var(--text-primary);
border-radius: var(--border-radius);
}
.message.user .message-content {
background-color: var(--user-message-bg);
color: var(--user-message-text);
border-radius: var(--border-radius);
}
.message.ai .message-content h1 {
font-size: 22px;
margin: 16px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
color: var(--primary-color);
}
.message.ai .message-content h2 {
font-size: 18px;
margin: 14px 0 8px;
color: var(--text-primary);
}
.message.ai .message-content h3 {
font-size: 16px;
margin: 12px 0 6px;
color: var(--text-primary);
}
/* List styles for better readability */
.message.ai .message-content ul,
.message.ai .message-content ol {
margin: 8px 0;
padding-left: 25px;
}
.message.ai .message-content li {
margin-bottom: 5px;
}
/* Block quote styling */
.message.ai .message-content blockquote {
border-left: 4px solid var(--primary-color);
padding: 0 0 0 15px;
margin: 10px 0;
color: var(--text-secondary);
}
/* Table styling */
.message.ai .message-content table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
}
.message.ai .message-content th,
.message.ai .message-content td {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
}
.message.ai .message-content th {
background-color: var(--background-tertiary);
font-weight: 600;
}
/* Links styling */
.message.ai .message-content a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.2s;
}
.message.ai .message-content a:hover {
text-decoration: underline;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-addition {
color: #c792ea;
}
.hljs-number,
.hljs-string,
.hljs-doctag,
.hljs-regexp {
color: #89ca78;
}
.hljs-title,
.hljs-section,
.hljs-built_in,
.hljs-name {
color: #e2b93d;
}
.hljs-variable,
.hljs-template-variable,
.hljs-selector-id,
.hljs-class .hljs-title {
color: #7fdbca;
}
.hljs-type,
.hljs-tag {
color: #e06c75;
}
/* Language badge for code blocks */
pre::before {
content: attr(class);
position: absolute;
top: 5px;
left: 12px;
font-size: 12px;
color: #aaa;
display: none;
}
/* Horizontal rule */
.message.ai .message-content hr {
border: none;
height: 1px;
background-color: var(--border-color);
margin: 15px 0;
}
.typing-indicator {
display: flex;
padding: 15px 25px;
}
.typing-dot {
width: 8px;
height: 8px;
margin: 0 2px;
background-color: var(--typing-indicator-color);
border-radius: 50%;
opacity: 0.6;
animation: typingAnimation 1.5s infinite;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typingAnimation {
0% {
transform: translateY(0px);
}
25% {
transform: translateY(-5px);
}
50% {
transform: translateY(0px);
}
}
.input-area {
padding: 15px 25px 20px;
border-top: 1px solid var(--border-color);
}
.input-container {
display: flex;
position: relative;
background-color: var(--background-tertiary);
border-radius: var(--border-radius);
overflow: hidden;
}
/* Style for file upload button */
#file-upload-button {
background: none;
border: none;
color: var(--text-secondary);
font-size: 18px;
padding: 15px;
cursor: pointer;
}
#file-upload-button:hover {
color: var(--primary-color);
}
textarea {
flex: 1;
border: none;
background: none;
padding: 15px;
font-size: 15px;
resize: none;
max-height: 150px;
color: var(--text-primary);
outline: none;
}
textarea::placeholder {
color: var(--text-secondary);
}
#send-button {
background-color: var(--primary-color);
color: white;
border: none;
width: 40px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
#send-button:hover {
background-color: var(--secondary-color);
}
.disclaimer {
font-size: 12px;
color: var(--text-secondary);
text-align: center;
margin-top: 10px;
}
/* Pending file preview (for selected file before send) */
#pending-file-preview {
display: none;
margin: 10px 0;
}
/* Intro Message Styles */
.intro-message {
text-align: center;
max-width: 600px;
margin: 40px auto;
padding: 30px;
background-color: var(--background-primary);
border-radius: var(--border-radius);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
animation: fadeIn 0.5s ease-out;
}
.intro-message h1 {
color: var(--primary-color);
font-size: 28px;
margin-bottom: 15px;
}
.intro-message p {
color: var(--text-secondary);
margin-bottom: 25px;
font-size: 16px;
}
.suggestion-chips {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.suggestion-chip {
background-color: var(--background-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.suggestion-chip:hover {
background-color: var(--background-secondary);
color: var(--primary-color);
transform: translateY(-2px);
}
/* Code block styling */
pre {
position: relative;
background-color: #282c34;
border-radius: 8px;
padding: 12px;
padding-top: 35px;
overflow-x: auto;
margin: 10px 0;
border: 1px solid #3e4451;
color: #edf0f7;
}
code {
font-family: "Fira Code", "Courier New", Courier, monospace;
font-size: 14px;
}
pre code {
white-space: pre;
font-size: 14px;
line-height: 1.5;
}
:not(pre) > code {
background-color: rgba(125, 125, 125, 0.1);
padding: 2px 4px;
border-radius: 4px;
color: var(--primary-color);
}
/* New CSS for chat options button and menu */
.chat-options-button {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 16px;
padding: 5px;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
.chat-options-menu {
position: absolute;
right: 10px;
top: 100%;
background-color: var(--background-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: 0 4px 8px var(--shadow-color);
z-index: 100;
}
.chat-options-item {
padding: 8px 12px;
cursor: pointer;
white-space: nowrap;
}
.chat-options-item:hover {
background-color: var(--background-secondary);
}
document.addEventListener("DOMContentLoaded", () => {
// DOM Elements
const messagesContainer = document.getElementById("messages");
const userInput = document.getElementById("user-input");
const sendButton = document.getElementById("send-button");
const newChatButton = document.getElementById("new-chat");
const clearHistoryButton = document.getElementById("clear-history");
const toggleThemeButton = document.getElementById("toggle-theme");
const chatHistoryContainer = document.getElementById("chat-history");
const currentChatTitle = document.getElementById("current-chat-title");
const exportChatButton = document.getElementById("export-chat");
const regenerateResponseButton = document.getElementById(
"regenerate-response"
);
const stopResponseButton = document.getElementById("stop-response");
const suggestionChips = document.querySelectorAll(".suggestion-chip");
const fileUploadButton = document.getElementById("file-upload-button");
const fileUploadInput = document.getElementById("file-upload");
// OpenRouter API Key and Configuration
const API_KEY = "paste-your-api-key-here";
// State variables
let currentChatId = null;
let isTyping = false;
let chatHistory = JSON.parse(localStorage.getItem("chatHistory")) || {};
let currentTheme = localStorage.getItem("theme") || "light";
let typingSpeed = 2; // reduced for faster letter-by-letter output
let letterTimeout = null; // separate timeout for letter typing
let pendingFile = null; // Store selected file until send
let stopGeneration = false; // Flag to stop the typing effect
// Helper function to get stable rendering:
function getStableRendering(text) {
// Split text on triple backticks.
let parts = text.split("```");
if (parts.length % 2 === 1) {
// All code blocks are closed.
return marked.parse(text);
} else {
// Code block is open; force rendering as a code block by artificially closing it.
let closedPart = parts.slice(0, parts.length - 1).join("```");
let openPart = parts[parts.length - 1];
return (
marked.parse(closedPart) + marked.parse("```" + openPart + "\n```")
);
}
}
// Initialize application
init();
// Function to initialize the application
function init() {
// Apply theme
if (currentTheme === "dark") {
document.body.classList.add("dark-mode");
toggleThemeButton.innerHTML =
'<i class="fas fa-sun"></i><span>Light Mode</span>';
}
// Set up textarea auto-resize
userInput.addEventListener("input", autoResizeTextarea);
// Set up event listeners
sendButton.addEventListener("click", handleSendMessage);
userInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
});
newChatButton.addEventListener("click", createNewChat);
clearHistoryButton.addEventListener("click", clearAllHistory);
toggleThemeButton.addEventListener("click", toggleTheme);
exportChatButton.addEventListener("click", exportCurrentChat);
regenerateResponseButton.addEventListener("click", regenerateLastResponse);
stopResponseButton.addEventListener("click", () => {
stopGeneration = true;
clearTimeout(letterTimeout);
stopResponseButton.style.display = "none";
regenerateResponseButton.style.display = "inline-block";
});
fileUploadButton.addEventListener("click", () => {
fileUploadInput.click();
});
// Store pending file and show preview on selection
fileUploadInput.addEventListener("change", (event) => {
const file = event.target.files[0];
if (file) {
pendingFile = file;
displayPendingFilePreview(file);
}
});
suggestionChips.forEach((chip) => {
chip.addEventListener("click", () => {
userInput.value = chip.textContent;
handleSendMessage();
});
});
// Load chat history
updateChatHistorySidebar();
// Create new chat if none exists
if (Object.keys(chatHistory).length === 0) {
createNewChat();
} else {
// Load most recent chat
const mostRecentChatId = Object.keys(chatHistory).sort((a, b) => {
return chatHistory[b].timestamp - chatHistory[a].timestamp;
})[0];
loadChat(mostRecentChatId);
}
// Global click listener to close any open options menus
window.addEventListener("click", () => {
document.querySelectorAll(".chat-options-menu").forEach((menu) => {
menu.style.display = "none";
});
});
}
// NEW: Display pending file preview immediately after selection
function displayPendingFilePreview(file) {
const previewContainer = document.getElementById("pending-file-preview");
const reader = new FileReader();
reader.onload = function (e) {
let previewHTML = "";
if (file.type.startsWith("image/")) {
previewHTML = `<img src="${e.target.result}" alt="${file.name}" style="max-width: 100px; max-height: 100px;"/>`;
} else if (
file.type.startsWith("text/") ||
file.type === "application/json"
) {
let content = e.target.result;
if (content.length > 200) {
content = content.substring(0, 200) + "...";
}
previewHTML = `<pre style="white-space: pre-wrap; font-size: 12px;">${escapeHtml(
content
)}</pre>`;
} else {
previewHTML = `<div style="font-size: 12px;">${file.name}</div>`;
}
previewContainer.innerHTML = previewHTML;
previewContainer.style.display = "block";
};
if (file.type.startsWith("image/")) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
}
// NEW: Process pending file when sending message
function processPendingFile() {
return new Promise((resolve, reject) => {
const file = pendingFile;
if (!file) {
resolve();
return;
}
const reader = new FileReader();
reader.onload = function (e) {
let previewHTML = "";
if (file.type.startsWith("image/")) {
previewHTML = `<img src="${e.target.result}" alt="${file.name}" style="max-width:100%;"/>`;
} else if (
file.type.startsWith("text/") ||
file.type === "application/json"
) {
previewHTML = `<pre style="white-space: pre-wrap;">${escapeHtml(
e.target.result
)}</pre>`;
} else {
previewHTML = `<div>Uploaded file: ${file.name}</div>`;
}
// Append file preview as a user message
addFileMessageToUI("user", previewHTML);
// Save file data in chat history
if (!chatHistory[currentChatId]) {
createNewChat();
}
chatHistory[currentChatId].messages.push({
role: "user",
content: previewHTML,
file: {
name: file.name,
type: file.type,
content: e.target.result
}
});
// Clear pending file preview
const previewContainer = document.getElementById(
"pending-file-preview"
);
previewContainer.style.display = "none";
previewContainer.innerHTML = "";
pendingFile = null;
resolve();
};
if (file.type.startsWith("image/")) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
});
}
// Helper to escape HTML (for text files)
function escapeHtml(text) {
var map = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
return text.replace(/[&<>"']/g, function (m) {
return map[m];
});
}
// Function to add file message (with HTML preview) to UI
function addFileMessageToUI(sender, htmlContent) {
const messageDiv = document.createElement("div");
messageDiv.className = `message ${sender}`;
const messageContent = document.createElement("div");
messageContent.className = "message-content";
messageContent.innerHTML = htmlContent;
messageDiv.appendChild(messageContent);
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Function to handle sending a message
async function handleSendMessage() {
if (isTyping) {
alert("Please wait until the current response is completed.");
return;
}
const message = userInput.value.trim();
// Only proceed if there is text or a pending file
if (!message && !pendingFile) return;
// Clear input and reset height
userInput.value = "";
userInput.style.height = "auto";
// If there's text, add it as a user message
if (message) {
addMessageToUI("user", message);
if (!chatHistory[currentChatId]) {
createNewChat();
}
chatHistory[currentChatId].messages.push({
role: "user",
content: message
});
if (chatHistory[currentChatId].messages.length === 1) {
const title =
message.split(" ").slice(0, 4).join(" ") +
(message.split(" ").length > 4 ? "..." : "");
chatHistory[currentChatId].title = title;
currentChatTitle.textContent = title;
updateChatHistorySidebar();
}
}
// If a file was selected, process it now
if (pendingFile) {
await processPendingFile();
}
saveChatHistory();
try {
showTypingIndicator();
// Stream the AI response and update UI letter-by-letter
const response = await getAIResponse(currentChatId);
// The UI is updated during streaming. Save the final text.
chatHistory[currentChatId].messages.push({
role: "assistant",
content: response
});
saveChatHistory();
} catch (error) {
removeTypingIndicator();
addMessageToUIWithTypingEffect(
"ai",
`Sorry, I encountered an error: ${error.message}`
);
}
}
// UPDATED: Function to get AI response from OpenRouter API with streaming and letter-by-letter effect.
// While streaming, we update the message content using getStableRendering so that if a code block is open,
// it is rendered as a code block immediately.
async function getAIResponse(chatId) {
isTyping = true;
stopGeneration = false;
regenerateResponseButton.style.display = "none";
stopResponseButton.style.display = "inline-block";
try {
const response = await fetch(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: MODEL,
messages: chatHistory[chatId].messages,
stream: true
})
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || "Failed to get response");
}
// Create a message container for the streaming response
const messageDiv = document.createElement("div");
messageDiv.className = "message ai";
const messageContent = document.createElement("div");
messageContent.className = "message-content";
messageDiv.appendChild(messageContent);
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let typingBuffer = "";
let accumulatedText = "";
let isProcessingBuffer = false;
function processBuffer() {
if (typingBuffer.length > 0 && !stopGeneration) {
accumulatedText += typingBuffer[0];
typingBuffer = typingBuffer.slice(1);
// Update message content using the fixed getStableRendering
messageContent.innerHTML = getStableRendering(accumulatedText);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
setTimeout(processBuffer, typingSpeed);
} else {
isProcessingBuffer = false;
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const jsonStr = line.slice("data: ".length).trim();
if (jsonStr === "[DONE]") break;
try {
const obj = JSON.parse(jsonStr);
if (obj.choices && obj.choices[0] && obj.choices[0].delta) {
const delta = obj.choices[0].delta;
const text = (delta.content || "") + (delta.reasoning || "");
if (text) {
typingBuffer += text;
if (!isProcessingBuffer) {
isProcessingBuffer = true;
processBuffer();
}
}
}
} catch (e) {
// Ignore JSON parse errors
}
}
}
}
// Wait until typingBuffer is fully processed
while (isProcessingBuffer) {
await new Promise((resolve) => setTimeout(resolve, typingSpeed));
}
isTyping = false;
removeTypingIndicator();
stopResponseButton.style.display = "none";
regenerateResponseButton.style.display = "inline-block";
// Replace the streaming message with a fully formatted one for proper code block rendering and copy buttons.
messageDiv.remove();
addFormattedMessageToUI("ai", accumulatedText);
return accumulatedText;
} catch (error) {
isTyping = false;
removeTypingIndicator();
console.error("API Error:", error);
throw error;
}
}
// Function to add message to UI with typing effect (used for non-streaming messages)
function addMessageToUIWithTypingEffect(sender, content) {
removeTypingIndicator();
const messageDiv = document.createElement("div");
messageDiv.className = `message ${sender}`;
const messageContent = document.createElement("div");
messageContent.className = "message-content";
if (sender === "user") {
messageContent.textContent = content;
messageDiv.appendChild(messageContent);
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return;
}
messageDiv.appendChild(messageContent);
messagesContainer.appendChild(messageDiv);
const processedContent = processMarkdownContent(content);
// When AI response starts, reset the stop flag and show the stop button
if (sender === "ai") {
stopGeneration = false;
stopResponseButton.style.display = "inline-block";
}
startTypingEffect(messageContent, processedContent, 0);
}
// Function to add user message to UI immediately
function addMessageToUI(sender, content) {
const messageDiv = document.createElement("div");
messageDiv.className = `message ${sender}`;
const messageContent = document.createElement("div");
messageContent.className = "message-content";
messageContent.textContent = content;
messageDiv.appendChild(messageContent);
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Function to process markdown and identify code blocks
function processMarkdownContent(content) {
const segments = [];
let currentPos = 0;
const codeBlockRegex = /```([\w]*)\n([\s\S]*?)\n```/g;
let match;
while ((match = codeBlockRegex.exec(content)) !== null) {
if (match.index > currentPos) {
segments.push({
type: "text",
content: content.substring(currentPos, match.index)
});
}
segments.push({
type: "code",
language: match[1] || "plaintext",
content: match[2]
});
currentPos = match.index + match[0].length;
}
if (currentPos < content.length) {
segments.push({
type: "text",
content: content.substring(currentPos)
});
}
return segments;
}
// Function to start typing effect (used by non-streaming messages)
function startTypingEffect(messageContent, segments, segmentIndex) {
if (segmentIndex >= segments.length) {
isTyping = false;
stopResponseButton.style.display = "none";
return;
}
const segment = segments[segmentIndex];
if (segment.type === "code") {
const preElement = document.createElement("pre");
const codeElement = document.createElement("code");
if (segment.language) {
codeElement.className = `language-${segment.language}`;
}
codeElement.classList.add("hljs");
const copyButtonContainer = document.createElement("div");
copyButtonContainer.className = "code-copy-container";
const copyButton = document.createElement("button");
copyButton.className = "code-copy-button";
copyButton.innerHTML = '<i class="fas fa-copy"></i>';
copyButton.title = "Copy code";
copyButton.addEventListener("click", () => {
navigator.clipboard.writeText(segment.content).then(() => {
copyButton.innerHTML = '<i class="fas fa-check"></i>';
copyButton.classList.add("copied");
setTimeout(() => {
copyButton.innerHTML = '<i class="fas fa-copy"></i>';
copyButton.classList.remove("copied");
}, 2000);
});
});
copyButtonContainer.appendChild(copyButton);
preElement.appendChild(copyButtonContainer);
preElement.appendChild(codeElement);
messageContent.appendChild(preElement);
typeCodeContent(codeElement, segment.content, 0, () => {
hljs.highlightElement(codeElement);
startTypingEffect(messageContent, segments, segmentIndex + 1);
});
} else {
const textDiv = document.createElement("div");
messageContent.appendChild(textDiv);
typeTextContent(textDiv, segment.content, 0, () => {
startTypingEffect(messageContent, segments, segmentIndex + 1);
});
}
}
// Function to type code content letter by letter
function typeCodeContent(element, content, index, callback) {
if (stopGeneration) {
callback();
return;
}
if (index < content.length) {
element.textContent += content[index];
messagesContainer.scrollTop = messagesContainer.scrollHeight;
letterTimeout = setTimeout(() => {
typeCodeContent(element, content, index + 1, callback);
}, typingSpeed);
} else {
callback();
}
}
// Function to type text content letter by letter
function typeTextContent(element, content, index, callback) {
if (stopGeneration) {
callback();
return;
}
if (index < content.length) {
let currentText = content.substring(0, index + 1);
element.innerHTML = marked.parse(currentText);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
letterTimeout = setTimeout(() => {
typeTextContent(element, content, index + 1, callback);
}, typingSpeed);
} else {
callback();
}
}
// Function to show typing indicator
function showTypingIndicator() {
const typingDiv = document.createElement("div");
typingDiv.className = "typing-indicator";
typingDiv.id = "typing-indicator";
for (let i = 0; i < 3; i++) {
const dot = document.createElement("div");
dot.className = "typing-dot";
typingDiv.appendChild(dot);
}
messagesContainer.appendChild(typingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Function to remove typing indicator
function removeTypingIndicator() {
const typingIndicator = document.getElementById("typing-indicator");
if (typingIndicator) {
typingIndicator.remove();
}
}
// Function to create a new chat
function createNewChat() {
const chatId = "chat_" + Date.now();
chatHistory[chatId] = {
id: chatId,
title: "New Conversation",
timestamp: Date.now(),
messages: []
};
currentChatId = chatId;
currentChatTitle.textContent = "New Conversation";
messagesContainer.innerHTML = `
<div class="intro-message">
<h1>Welcome to NeoChat AI</h1>
<p>Ask me anything. I'm powered by deepseek-r1.</p>
<div class="suggestion-chips">
<button class="suggestion-chip">Tell me a story</button>
<button class="suggestion-chip">Explain quantum computing</button>
<button class="suggestion-chip">Write a poem</button>
<button class="suggestion-chip">Help me learn JavaScript</button>
</div>
</div>
`;
document.querySelectorAll(".suggestion-chip").forEach((chip) => {
chip.addEventListener("click", () => {
userInput.value = chip.textContent;
handleSendMessage();
});
});
saveChatHistory();
updateChatHistorySidebar();
// Clear pending file and its preview when a new chat is created
pendingFile = null;
const previewContainer = document.getElementById("pending-file-preview");
if (previewContainer) {
previewContainer.innerHTML = "";
previewContainer.style.display = "none";
}
}
// Function to load a chat
function loadChat(chatId) {
if (!chatHistory[chatId]) return;
currentChatId = chatId;
currentChatTitle.textContent = chatHistory[chatId].title;
messagesContainer.innerHTML = "";
chatHistory[chatId].messages.forEach((message) => {
// If the user message has a file property, render using innerHTML
if (message.role === "user") {
if (message.file) {
addFileMessageToUI("user", message.content);
} else {
addMessageToUI("user", message.content);
}
} else {
addFormattedMessageToUI("ai", message.content);
}
});
updateActiveChatInSidebar();
}
// Function to add formatted message to UI without typing effect
function addFormattedMessageToUI(sender, content) {
const messageDiv = document.createElement("div");
messageDiv.className = `message ${sender}`;
const messageContent = document.createElement("div");
messageContent.className = "message-content";
const processedContent = processMarkdownContent(content);
processedContent.forEach((segment) => {
if (segment.type === "code") {
const preElement = document.createElement("pre");
const codeElement = document.createElement("code");
if (segment.language) {
codeElement.className = `language-${segment.language}`;
}
codeElement.classList.add("hljs");
const copyButtonContainer = document.createElement("div");
copyButtonContainer.className = "code-copy-container";
const copyButton = document.createElement("button");
copyButton.className = "code-copy-button";
copyButton.innerHTML = '<i class="fas fa-copy"></i>';
copyButton.title = "Copy code";
copyButton.addEventListener("click", () => {
navigator.clipboard.writeText(segment.content).then(() => {
copyButton.innerHTML = '<i class="fas fa-check"></i>';
copyButton.classList.add("copied");
setTimeout(() => {
copyButton.innerHTML = '<i class="fas fa-copy"></i>';
copyButton.classList.remove("copied");
}, 2000);
});
});
copyButtonContainer.appendChild(copyButton);
preElement.appendChild(copyButtonContainer);
codeElement.textContent = segment.content;
preElement.appendChild(codeElement);
messageContent.appendChild(preElement);
hljs.highlightElement(codeElement);
} else {
const textDiv = document.createElement("div");
textDiv.innerHTML = marked.parse(segment.content);
while (textDiv.firstChild) {
messageContent.appendChild(textDiv.firstChild);
}
}
});
messageDiv.appendChild(messageContent);
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Function to save chat history to localStorage
function saveChatHistory() {
localStorage.setItem("chatHistory", JSON.stringify(chatHistory));
updateChatHistorySidebar();
}
// Function to update chat history sidebar with options (delete/rename)
function updateChatHistorySidebar() {
chatHistoryContainer.innerHTML = "";
const sortedChatIds = Object.keys(chatHistory).sort((a, b) => {
return chatHistory[b].timestamp - chatHistory[a].timestamp;
});
sortedChatIds.forEach((chatId) => {
const chat = chatHistory[chatId];
const chatItem = document.createElement("div");
chatItem.className = `chat-history-item ${
chatId === currentChatId ? "active" : ""
}`;
chatItem.dataset.chatId = chatId;
// Create icon element
const icon = document.createElement("i");
icon.className = "fas fa-comment";
// Create span for chat title using textContent for safety
const titleSpan = document.createElement("span");
titleSpan.textContent = chat.title;
chatItem.appendChild(icon);
chatItem.appendChild(titleSpan);
// When clicking the chat item (outside the options button) load the chat
chatItem.addEventListener("click", () => {
loadChat(chatId);
});
// Create the options button (three dots)
const optionsButton = document.createElement("button");
optionsButton.className = "chat-options-button";
optionsButton.innerHTML = '<i class="fas fa-ellipsis-v"></i>';
// Stop propagation so clicking this does not trigger the parent click event
optionsButton.addEventListener("click", (e) => {
e.stopPropagation();
// Toggle the options menu
optionsMenu.style.display =
optionsMenu.style.display === "none" ? "block" : "none";
});
// Create the options menu (hidden by default)
const optionsMenu = document.createElement("div");
optionsMenu.className = "chat-options-menu";
optionsMenu.style.display = "none";
optionsMenu.innerHTML = `
<div class="chat-options-item delete-chat">Delete</div>
<div class="chat-options-item rename-chat">Rename</div>
`;
// Handle deletion of a chat
optionsMenu
.querySelector(".delete-chat")
.addEventListener("click", (e) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this chat?")) {
delete chatHistory[chatId];
if (currentChatId === chatId) {
createNewChat();
}
saveChatHistory();
updateChatHistorySidebar();
}
});
// Handle renaming a chat
optionsMenu
.querySelector(".rename-chat")
.addEventListener("click", (e) => {
e.stopPropagation();
const newName = prompt("Enter new name for this chat:", chat.title);
if (newName) {
chat.title = newName;
saveChatHistory();
updateChatHistorySidebar();
if (currentChatId === chatId) {
currentChatTitle.textContent = newName;
}
}
});
// Append the options button and menu to the chat item
chatItem.appendChild(optionsButton);
chatItem.appendChild(optionsMenu);
// Append the chat item to the history container
chatHistoryContainer.appendChild(chatItem);
});
}
// Function to update active chat in sidebar
function updateActiveChatInSidebar() {
document.querySelectorAll(".chat-history-item").forEach((item) => {
item.classList.remove("active");
if (item.dataset.chatId === currentChatId) {
item.classList.add("active");
}
});
}
// Function to clear all chat history
function clearAllHistory() {
if (
confirm(
"Are you sure you want to clear all chat history? This cannot be undone."
)
) {
chatHistory = {};
localStorage.removeItem("chatHistory");
createNewChat();
}
}
// Function to toggle theme
function toggleTheme() {
if (currentTheme === "light") {
document.body.classList.add("dark-mode");
currentTheme = "dark";
toggleThemeButton.innerHTML =
'<i class="fas fa-sun"></i><span>Light Mode</span>';
} else {
document.body.classList.remove("dark-mode");
currentTheme = "light";
toggleThemeButton.innerHTML =
'<i class="fas fa-moon"></i><span>Dark Mode</span>';
}
localStorage.setItem("theme", currentTheme);
}
// Function to export current chat
function exportCurrentChat() {
if (!chatHistory[currentChatId]) return;
const chat = chatHistory[currentChatId];
let exportText = `# ${chat.title}\n\n`;
chat.messages.forEach((message) => {
const role = message.role === "user" ? "You" : "NeoChat AI";
exportText += `## ${role}:\n${message.content}\n\n`;
});
const blob = new Blob([exportText], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${chat.title.replace(/[^\w\s]/gi, "")}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Function to regenerate the last AI response
function regenerateLastResponse() {
if (
chatHistory[currentChatId] &&
chatHistory[currentChatId].messages.length > 0
) {
const lastMessage =
chatHistory[currentChatId].messages[
chatHistory[currentChatId].messages.length - 1
];
if (lastMessage.role === "assistant") {
chatHistory[currentChatId].messages.pop();
const messageElements = document.querySelectorAll(".message.ai");
if (messageElements.length > 0) {
messageElements[messageElements.length - 1].remove();
}
saveChatHistory();
showTypingIndicator();
getAIResponse(currentChatId)
.then((response) => {
chatHistory[currentChatId].messages.push({
role: "assistant",
content: response
});
saveChatHistory();
})
.catch((error) => {
removeTypingIndicator();
addMessageToUIWithTypingEffect(
"ai",
`Sorry, I encountered an error: ${error.message}`
);
});
}
}
}
// Function to auto-resize textarea
function autoResizeTextarea() {
userInput.style.height = "auto";
userInput.style.height = userInput.scrollHeight + "px";
}
});
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.