Compare commits
2 Commits
main
...
file-manag
Author | SHA1 | Date | |
---|---|---|---|
45e6d5a600 | |||
5c5db96f8f |
446
index.html
Normal file
446
index.html
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>ChatGPT Clone - Conversations</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Highlight.js CSS for code highlighting -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
.container-main { height: 100vh; display: flex; flex-direction: row; }
|
||||||
|
.sidebar {
|
||||||
|
width: 25%; max-width: 300px; background: #fff; padding: 0.5rem; box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.settings { margin-bottom: 1rem; }
|
||||||
|
.chat-container {
|
||||||
|
flex: 1; display: flex; flex-direction: column; background: #fff; padding: 1rem;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.chat-header { flex-shrink: 0; }
|
||||||
|
.chat-log {
|
||||||
|
flex: 1; min-height: 0; overflow-y: auto; background: #eee; border: 1px solid #ddd;
|
||||||
|
border-radius: 5px; padding: 0.5rem; margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.chat-input { flex-shrink: 0; }
|
||||||
|
.message { padding: 0.75rem; border-radius: 5px; margin-bottom: 0.5rem; white-space: pre-wrap; }
|
||||||
|
.user { background: #d1e7dd; text-align: right; }
|
||||||
|
.assistant { background: #f8d7da; text-align: left; }
|
||||||
|
pre { max-width: 100%; box-sizing: border-box; white-space: pre-wrap; overflow-x: auto;
|
||||||
|
background: #2d2d2d; color: #f8f8f2; padding: 0.5rem; border-radius: 5px; }
|
||||||
|
.code-buttons { text-align: right; margin-top: 5px; }
|
||||||
|
.conversation-item { padding: 0.5rem; cursor: pointer; border-bottom: 1px solid #ddd; position: relative; }
|
||||||
|
.conversation-item:hover, .conversation-item.active { background: #f0f0f0; }
|
||||||
|
.delete-btn { position: absolute; right: 5px; top: 5px; background: transparent; border: none; color: #dc3545; font-size: 1rem; }
|
||||||
|
textarea#messageInput { resize: none; height: 80px; overflow-y: auto; }
|
||||||
|
.file-cmd { margin-top: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Offcanvas for mobile -->
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasConversations" aria-labelledby="offcanvasConversationsLabel">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title" id="offcanvasConversationsLabel">Conversations</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body" id="offcanvasConversationList" style="overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container-main">
|
||||||
|
<div class="sidebar" id="conversationListContainer">
|
||||||
|
<h5>Conversations</h5>
|
||||||
|
<button id="newConvoBtn" class="btn btn-sm btn-primary mb-2">New Conversation</button>
|
||||||
|
<!-- Settings area for the system prompt -->
|
||||||
|
<div class="settings">
|
||||||
|
<h6>System Prompt</h6>
|
||||||
|
<textarea id="systemPrompt" style="width:100%; height:100px;"></textarea>
|
||||||
|
<button id="savePrompt" class="btn btn-sm btn-secondary mt-1">Save Prompt</button>
|
||||||
|
</div>
|
||||||
|
<div id="conversationList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="d-block d-md-none mb-2">
|
||||||
|
<button class="btn btn-sm btn-secondary" data-bs-toggle="offcanvas" data-bs-target="#offcanvasConversations">Open Conversations</button>
|
||||||
|
<button id="newConvoBtnMobile" class="btn btn-sm btn-primary ms-2">New Conversation</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-header">
|
||||||
|
<h4 id="chatTitle">Conversation</h4>
|
||||||
|
</div>
|
||||||
|
<div id="chatLog" class="chat-log"></div>
|
||||||
|
<div class="chat-input">
|
||||||
|
<form id="chatForm">
|
||||||
|
<div class="input-group">
|
||||||
|
<textarea id="messageInput" class="form-control" placeholder="Type your message here" required></textarea>
|
||||||
|
<button class="btn btn-primary" type="submit">Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bootstrap and libraries -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
||||||
|
<script>
|
||||||
|
marked.setOptions({
|
||||||
|
highlight: function(code, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(code, { language: lang }).value;
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Conversation Management ---
|
||||||
|
let conversations = [];
|
||||||
|
let currentConversation = null;
|
||||||
|
const defaultSystemPrompt = "You are ChatGPT, a helpful assistant. When providing code, wrap all code blocks with three backticks. Additionally, if you need to perform a file operation, return a JSON object (wrapped in a code block) with a key 'file_command' and the necessary arguments. For example, to list files use:\n```json\n{\"file_command\": \"list\", \"arguments\": {}}\n```\nto read a file use:\n```json\n{\"file_command\": \"read\", \"arguments\": {\"filename\": \"example.txt\"}}\n```\nand to write a file use:\n```json\n{\"file_command\": \"write\", \"arguments\": {\"filename\": \"example.txt\", \"content_b64\": \"<BASE64_ENCODED_CONTENT>\"}}\n```\nOnce approved, the result of your file command will be sent as a user's response.";
|
||||||
|
|
||||||
|
// Load saved system prompt from localStorage or default.
|
||||||
|
function loadSystemPrompt() {
|
||||||
|
const sp = localStorage.getItem('systemPrompt');
|
||||||
|
document.getElementById('systemPrompt').value = sp || defaultSystemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('savePrompt').addEventListener("click", function() {
|
||||||
|
const newPrompt = document.getElementById('systemPrompt').value;
|
||||||
|
localStorage.setItem('systemPrompt', newPrompt);
|
||||||
|
alert("System prompt saved!");
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return 'c-' + Date.now() + '-' + Math.floor(Math.random() * 1000);
|
||||||
|
}
|
||||||
|
function loadConversations() {
|
||||||
|
const loaded = localStorage.getItem('conversations');
|
||||||
|
conversations = loaded ? JSON.parse(loaded) : [];
|
||||||
|
renderConversationList();
|
||||||
|
}
|
||||||
|
function saveConversations() {
|
||||||
|
localStorage.setItem('conversations', JSON.stringify(conversations));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new conversation using the current system prompt.
|
||||||
|
function createNewConversation() {
|
||||||
|
const sp = localStorage.getItem('systemPrompt') || defaultSystemPrompt;
|
||||||
|
const newConvo = {
|
||||||
|
id: generateId(),
|
||||||
|
title: 'New Conversation',
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: sp }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
conversations.push(newConvo);
|
||||||
|
currentConversation = newConvo;
|
||||||
|
saveConversations();
|
||||||
|
renderConversationList();
|
||||||
|
renderChatLog();
|
||||||
|
document.getElementById('chatTitle').textContent = "Conversation (" + newConvo.id + ")";
|
||||||
|
}
|
||||||
|
function deleteConversation(id) {
|
||||||
|
if (!confirm("Are you sure you want to delete this conversation?")) return;
|
||||||
|
conversations = conversations.filter(convo => convo.id !== id);
|
||||||
|
if (currentConversation && currentConversation.id === id) {
|
||||||
|
currentConversation = conversations.length > 0 ? conversations[0] : null;
|
||||||
|
if (!currentConversation) createNewConversation();
|
||||||
|
else document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
|
||||||
|
}
|
||||||
|
saveConversations();
|
||||||
|
renderConversationList();
|
||||||
|
renderChatLog();
|
||||||
|
}
|
||||||
|
function renderConversationList() {
|
||||||
|
const listElem = document.getElementById('conversationList');
|
||||||
|
listElem.innerHTML = "";
|
||||||
|
const offcanvasElem = document.getElementById('offcanvasConversationList');
|
||||||
|
offcanvasElem.innerHTML = "";
|
||||||
|
conversations.forEach(convo => {
|
||||||
|
const createConvoItem = (container) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "conversation-item" + (currentConversation && currentConversation.id === convo.id ? " active" : "");
|
||||||
|
div.textContent = convo.title || convo.id;
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "delete-btn";
|
||||||
|
deleteBtn.innerHTML = "×";
|
||||||
|
deleteBtn.onclick = function(e) { e.stopPropagation(); deleteConversation(convo.id); };
|
||||||
|
div.appendChild(deleteBtn);
|
||||||
|
div.onclick = () => {
|
||||||
|
currentConversation = convo;
|
||||||
|
document.getElementById('chatTitle').textContent = "Conversation (" + convo.id + ")";
|
||||||
|
renderConversationList();
|
||||||
|
renderChatLog();
|
||||||
|
let offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('offcanvasConversations'));
|
||||||
|
if (offcanvas) offcanvas.hide();
|
||||||
|
};
|
||||||
|
container.appendChild(div);
|
||||||
|
};
|
||||||
|
createConvoItem(listElem);
|
||||||
|
createConvoItem(offcanvasElem);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderChatLog() {
|
||||||
|
const chatLog = document.getElementById('chatLog');
|
||||||
|
chatLog.innerHTML = "";
|
||||||
|
if (!currentConversation) return;
|
||||||
|
currentConversation.messages.forEach(msg => {
|
||||||
|
const messageElem = appendMessage(msg.role, msg.content, false);
|
||||||
|
addButtonsToCodeBlocks(messageElem);
|
||||||
|
});
|
||||||
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
|
}
|
||||||
|
function appendMessage(role, content, update = true) {
|
||||||
|
const chatLog = document.getElementById('chatLog');
|
||||||
|
const messageElem = document.createElement("div");
|
||||||
|
messageElem.className = "message " + role;
|
||||||
|
messageElem.innerHTML = marked.parse(content);
|
||||||
|
chatLog.appendChild(messageElem);
|
||||||
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
|
if (update && currentConversation) {
|
||||||
|
currentConversation.messages.push({ role: role, content: content });
|
||||||
|
if (role === "assistant" && currentConversation.title === "New Conversation") autoTitleConversation();
|
||||||
|
saveConversations();
|
||||||
|
}
|
||||||
|
if (role === "assistant") {
|
||||||
|
checkForFileCommand(content, messageElem);
|
||||||
|
}
|
||||||
|
addButtonsToCodeBlocks(messageElem);
|
||||||
|
return messageElem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated: Attach "Copy" and "Save" buttons to every code block in the element.
|
||||||
|
function addButtonsToCodeBlocks(element) {
|
||||||
|
const codeBlocks = element.querySelectorAll("pre");
|
||||||
|
codeBlocks.forEach(block => {
|
||||||
|
// Check if this specific block already has a next sibling that is our button container.
|
||||||
|
if (block.nextElementSibling && block.nextElementSibling.classList.contains("code-buttons")) {
|
||||||
|
return; // Skip if already added.
|
||||||
|
}
|
||||||
|
const containerDiv = document.createElement("div");
|
||||||
|
containerDiv.className = "code-buttons";
|
||||||
|
const copyBtn = document.createElement("button");
|
||||||
|
copyBtn.className = "btn btn-sm btn-outline-secondary me-2";
|
||||||
|
copyBtn.textContent = "Copy";
|
||||||
|
copyBtn.onclick = () => {
|
||||||
|
navigator.clipboard.writeText(block.innerText).then(() => {
|
||||||
|
copyBtn.textContent = "Copied!";
|
||||||
|
setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
containerDiv.appendChild(copyBtn);
|
||||||
|
const saveBtn = document.createElement("button");
|
||||||
|
saveBtn.className = "btn btn-sm btn-outline-primary";
|
||||||
|
saveBtn.textContent = "Save";
|
||||||
|
saveBtn.onclick = () => {
|
||||||
|
const defaultName = guessFileName(block);
|
||||||
|
const filename = prompt("Enter filename", defaultName);
|
||||||
|
if (!filename) return;
|
||||||
|
const fileContent = block.innerText;
|
||||||
|
const contentB64 = btoa(fileContent);
|
||||||
|
fetch("/file", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename: filename, content_b64: contentB64 })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => { alert("File saved with status: " + data.status); })
|
||||||
|
.catch(err => { alert("File save error: " + err.message); });
|
||||||
|
};
|
||||||
|
containerDiv.appendChild(saveBtn);
|
||||||
|
block.parentNode.insertBefore(containerDiv, block.nextSibling);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessFileName(block) {
|
||||||
|
const codeElem = block.querySelector("code");
|
||||||
|
let language = "";
|
||||||
|
if (codeElem && codeElem.className) {
|
||||||
|
const match = codeElem.className.match(/language-([a-zA-Z]+)/);
|
||||||
|
if (match) {
|
||||||
|
language = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ext = "txt";
|
||||||
|
if (language) {
|
||||||
|
const mapping = {
|
||||||
|
go: "go",
|
||||||
|
python: "py",
|
||||||
|
javascript: "js",
|
||||||
|
js: "js",
|
||||||
|
java: "java",
|
||||||
|
c: "c",
|
||||||
|
"c++": "cpp",
|
||||||
|
cpp: "cpp",
|
||||||
|
html: "html",
|
||||||
|
css: "css",
|
||||||
|
json: "json",
|
||||||
|
bash: "sh",
|
||||||
|
shell: "sh",
|
||||||
|
rust: "rs"
|
||||||
|
};
|
||||||
|
ext = mapping[language.toLowerCase()] || "txt";
|
||||||
|
}
|
||||||
|
return "code." + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForFileCommand(content, messageElem) {
|
||||||
|
let regex = /```(?:json)?\s*([\s\S]*?)\s*```/gm;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
try {
|
||||||
|
let obj = JSON.parse(match[1]);
|
||||||
|
if (obj.file_command) {
|
||||||
|
const promptDiv = document.createElement("div");
|
||||||
|
promptDiv.className = "file-cmd alert alert-info";
|
||||||
|
promptDiv.textContent = "The assistant requires a file operation: " + obj.file_command;
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "btn btn-sm btn-primary ms-2";
|
||||||
|
btn.textContent = "Approve";
|
||||||
|
btn.onclick = () => { performFileCommand(obj); btn.disabled = true; };
|
||||||
|
promptDiv.appendChild(btn);
|
||||||
|
messageElem.appendChild(promptDiv);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse file command JSON:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performFileCommand(cmdObj) {
|
||||||
|
let fileCmd = cmdObj.file_command;
|
||||||
|
let args = cmdObj.arguments || {};
|
||||||
|
let result = "";
|
||||||
|
if (fileCmd === "list") {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/files");
|
||||||
|
if (!response.ok) throw new Error("Error listing files.");
|
||||||
|
const data = await response.json();
|
||||||
|
result = "File List:\n```\n" + JSON.stringify(data.files, null, 2) + "\n```";
|
||||||
|
} catch (e) {
|
||||||
|
result = "File list error: " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fileCmd === "read") {
|
||||||
|
if (!args.filename) {
|
||||||
|
result = "Missing filename for read command.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/file?filename=" + encodeURIComponent(args.filename));
|
||||||
|
if (!response.ok) throw new Error("Error reading file.");
|
||||||
|
const data = await response.json();
|
||||||
|
result = "Contents of " + args.filename + ":\n```\n" + atob(data.content_b64) + "\n```";
|
||||||
|
} catch (e) {
|
||||||
|
result = "File read error: " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fileCmd === "write") {
|
||||||
|
if (!args.filename || !args.content_b64) {
|
||||||
|
result = "Missing filename or content_b64 for write command.";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/file", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename: args.filename, content_b64: args.content_b64 })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Error writing file.");
|
||||||
|
const data = await response.json();
|
||||||
|
result = "File write status for " + args.filename + ": " + data.status;
|
||||||
|
} catch (e) {
|
||||||
|
result = "File write error: " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result = "Unknown file command: " + fileCmd;
|
||||||
|
}
|
||||||
|
sendFileResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFileResponse(responseMsg) {
|
||||||
|
appendMessage("user", responseMsg);
|
||||||
|
const typingIndicator = appendMessage("assistant", "Typing...", false);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: currentConversation.messages })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Server error: " + response.status);
|
||||||
|
const data = await response.json();
|
||||||
|
typingIndicator.remove();
|
||||||
|
appendMessage("assistant", data.reply);
|
||||||
|
} catch (error) {
|
||||||
|
typingIndicator.remove();
|
||||||
|
appendMessage("assistant", "Error: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoTitleConversation() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/title", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: currentConversation.messages })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Title API error: " + response.status);
|
||||||
|
const data = await response.json();
|
||||||
|
const newTitle = data.title.trim();
|
||||||
|
if (newTitle) {
|
||||||
|
currentConversation.title = newTitle;
|
||||||
|
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + "): " + newTitle;
|
||||||
|
renderConversationList();
|
||||||
|
saveConversations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error auto-titling conversation:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatForm = document.getElementById('chatForm');
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
messageInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
chatForm.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chatForm.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const userMessage = messageInput.value.trim();
|
||||||
|
if (!userMessage || !currentConversation) return;
|
||||||
|
appendMessage("user", userMessage);
|
||||||
|
messageInput.value = "";
|
||||||
|
const typingIndicator = appendMessage("assistant", "Typing...", false);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: currentConversation.messages })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Server error: " + response.status);
|
||||||
|
const data = await response.json();
|
||||||
|
typingIndicator.remove();
|
||||||
|
appendMessage("assistant", data.reply);
|
||||||
|
} catch (error) {
|
||||||
|
typingIndicator.remove();
|
||||||
|
appendMessage("assistant", "Error: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('newConvoBtn').addEventListener("click", createNewConversation);
|
||||||
|
document.getElementById('newConvoBtnMobile').addEventListener("click", createNewConversation);
|
||||||
|
loadSystemPrompt();
|
||||||
|
loadConversations();
|
||||||
|
if (conversations.length === 0) createNewConversation();
|
||||||
|
else {
|
||||||
|
currentConversation = conversations[0];
|
||||||
|
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
|
||||||
|
renderChatLog();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
485
main.go
485
main.go
@ -2,12 +2,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -40,6 +45,8 @@ type ChatCompletionResponse struct {
|
|||||||
var (
|
var (
|
||||||
openaiAPIKey string
|
openaiAPIKey string
|
||||||
indexTmpl *template.Template
|
indexTmpl *template.Template
|
||||||
|
workDir string // Path to the workspace directory.
|
||||||
|
absWorkDir string // Absolute workspace path.
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -47,16 +54,42 @@ func init() {
|
|||||||
if openaiAPIKey == "" {
|
if openaiAPIKey == "" {
|
||||||
log.Fatal("Environment variable OPENAI_API_KEY is not set")
|
log.Fatal("Environment variable OPENAI_API_KEY is not set")
|
||||||
}
|
}
|
||||||
// Parse the HTML template with responsive layout updates.
|
workDir = os.Getenv("WORK_DIR")
|
||||||
indexTmpl = template.Must(template.New("index").Parse(indexHTML))
|
if workDir == "" {
|
||||||
|
workDir = "./workspace"
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
absWorkDir, err = filepath.Abs(workDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error getting absolute path for workspace: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(absWorkDir, os.ModePerm); err != nil {
|
||||||
|
log.Fatalf("Error creating workspace directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the HTML template from an external file.
|
||||||
|
tmplBytes, err := os.ReadFile("index.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error reading index.html: %v", err)
|
||||||
|
}
|
||||||
|
indexTmpl, err = template.New("index").Parse(string(tmplBytes))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error parsing index.html template: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
http.HandleFunc("/", indexHandler)
|
http.HandleFunc("/", indexHandler)
|
||||||
http.HandleFunc("/chat", chatHandler)
|
http.HandleFunc("/chat", chatHandler)
|
||||||
http.HandleFunc("/title", titleHandler)
|
http.HandleFunc("/title", titleHandler)
|
||||||
|
|
||||||
|
// File system endpoints (sandboxed within absWorkDir)
|
||||||
|
http.HandleFunc("/files", filesListHandler) // GET: list files.
|
||||||
|
http.HandleFunc("/file", fileHandler) // GET for read; POST for write.
|
||||||
|
|
||||||
addr := ":8080"
|
addr := ":8080"
|
||||||
log.Println("Server starting on", addr)
|
log.Println("Server starting on", addr)
|
||||||
|
log.Println("Workspace directory:", absWorkDir)
|
||||||
log.Fatal(http.ListenAndServe(addr, nil))
|
log.Fatal(http.ListenAndServe(addr, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +174,7 @@ func titleHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
titleRequestMessages := []Message{
|
titleRequestMessages := []Message{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "You are an AI that generates conversation titles. Based on the following conversation, please provide a short, descriptive title of no more than five words with no extra commentary.",
|
Content: "You are an AI that generates conversation titles. Based on the conversation, please provide a short, descriptive title (five words maximum) with no extra commentary.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
titleRequestMessages = append(titleRequestMessages, clientRequest.Messages...)
|
titleRequestMessages = append(titleRequestMessages, clientRequest.Messages...)
|
||||||
@ -193,333 +226,119 @@ func titleHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(responsePayload)
|
json.NewEncoder(w).Encode(responsePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexHTML = `
|
//
|
||||||
<!DOCTYPE html>
|
// File system endpoints
|
||||||
<html lang="en">
|
//
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
// filesListHandler lists all files (recursively) in the workspace.
|
||||||
<title>ChatGPT Clone - Conversations</title>
|
func filesListHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
if r.Method != http.MethodGet {
|
||||||
<!-- Bootstrap 5 CSS and offcanvas component support -->
|
http.Error(w, "Only GET allowed", http.StatusMethodNotAllowed)
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
return
|
||||||
<!-- Highlight.js CSS for code highlighting -->
|
}
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
|
var files []string
|
||||||
<style>
|
err := filepath.WalkDir(absWorkDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
/* Use full viewport height */
|
if err != nil {
|
||||||
html, body {
|
return err
|
||||||
height: 100%;
|
}
|
||||||
margin: 0;
|
if !d.IsDir() {
|
||||||
}
|
rel, err := filepath.Rel(absWorkDir, path)
|
||||||
/* Main container uses flex layout in row direction on md+ and column on small screens */
|
if err != nil {
|
||||||
.container-main {
|
return err
|
||||||
height: 100vh;
|
}
|
||||||
display: flex;
|
files = append(files, rel)
|
||||||
flex-direction: row;
|
}
|
||||||
}
|
return nil
|
||||||
/* Sidebar styles (hidden on small screens) */
|
})
|
||||||
.sidebar {
|
if err != nil {
|
||||||
width: 25%;
|
http.Error(w, "Error listing files: "+err.Error(), http.StatusInternalServerError)
|
||||||
max-width: 300px;
|
return
|
||||||
background-color: #fff;
|
}
|
||||||
padding: 0.5rem;
|
w.Header().Set("Content-Type", "application/json")
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
json.NewEncoder(w).Encode(map[string]interface{}{"files": files})
|
||||||
overflow-y: auto;
|
}
|
||||||
}
|
|
||||||
/* On small devices, hide the sidebar and use offcanvas instead */
|
// fileHandler supports file read (GET) and write (POST) operations.
|
||||||
@media (max-width: 767.98px) {
|
func fileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
.sidebar {
|
switch r.Method {
|
||||||
display: none;
|
case http.MethodGet:
|
||||||
}
|
handleFileRead(w, r)
|
||||||
}
|
case http.MethodPost:
|
||||||
/* Chat container fills remaining space and uses column layout */
|
handleFileWrite(w, r)
|
||||||
.chat-container {
|
default:
|
||||||
flex: 1;
|
http.Error(w, "Only GET and POST allowed", http.StatusMethodNotAllowed)
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
}
|
||||||
background-color: #fff;
|
|
||||||
padding: 1rem;
|
func handleFileRead(w http.ResponseWriter, r *http.Request) {
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
filename := r.URL.Query().Get("filename")
|
||||||
}
|
if filename == "" {
|
||||||
.chat-header {
|
http.Error(w, "Missing filename", http.StatusBadRequest)
|
||||||
flex-shrink: 0;
|
return
|
||||||
}
|
}
|
||||||
.chat-log {
|
fullPath, err := secureFilePath(filename)
|
||||||
flex: 1;
|
if err != nil {
|
||||||
min-height: 0;
|
http.Error(w, "Invalid filename: "+err.Error(), http.StatusBadRequest)
|
||||||
overflow-y: auto;
|
return
|
||||||
background-color: #eee;
|
}
|
||||||
border: 1px solid #ddd;
|
data, err := os.ReadFile(fullPath)
|
||||||
border-radius: 5px;
|
if err != nil {
|
||||||
padding: 0.5rem;
|
http.Error(w, "Error reading file: "+err.Error(), http.StatusInternalServerError)
|
||||||
margin: 0.5rem 0;
|
return
|
||||||
}
|
}
|
||||||
.chat-input {
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
flex-shrink: 0;
|
w.Header().Set("Content-Type", "application/json")
|
||||||
}
|
json.NewEncoder(w).Encode(map[string]string{"content_b64": encoded})
|
||||||
.message {
|
}
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 5px;
|
func handleFileWrite(w http.ResponseWriter, r *http.Request) {
|
||||||
margin-bottom: 0.5rem;
|
var payload struct {
|
||||||
white-space: pre-wrap;
|
Filename string `json:"filename"`
|
||||||
}
|
ContentB64 string `json:"content_b64"`
|
||||||
.user { background-color: #d1e7dd; text-align: right; }
|
}
|
||||||
.assistant { background-color: #f8d7da; text-align: left; }
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
pre {
|
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
max-width: 100%;
|
return
|
||||||
box-sizing: border-box;
|
}
|
||||||
white-space: pre-wrap;
|
if payload.Filename == "" || payload.ContentB64 == "" {
|
||||||
overflow-x: auto;
|
http.Error(w, "Filename and content_b64 are required", http.StatusBadRequest)
|
||||||
background-color: #2d2d2d;
|
return
|
||||||
color: #f8f8f2;
|
}
|
||||||
padding: 0.5rem;
|
decoded, err := base64.StdEncoding.DecodeString(payload.ContentB64)
|
||||||
border-radius: 5px;
|
if err != nil {
|
||||||
}
|
http.Error(w, "Error decoding Base64 content: "+err.Error(), http.StatusBadRequest)
|
||||||
.conversation-item {
|
return
|
||||||
padding: 0.5rem;
|
}
|
||||||
cursor: pointer;
|
fullPath, err := secureFilePath(payload.Filename)
|
||||||
border-bottom: 1px solid #ddd;
|
if err != nil {
|
||||||
position: relative;
|
http.Error(w, "Invalid filename: "+err.Error(), http.StatusBadRequest)
|
||||||
}
|
return
|
||||||
.conversation-item:hover,
|
}
|
||||||
.conversation-item.active { background-color: #f0f0f0; }
|
dir := filepath.Dir(fullPath)
|
||||||
.delete-btn {
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||||
position: absolute;
|
http.Error(w, "Error creating directory: "+err.Error(), http.StatusInternalServerError)
|
||||||
right: 5px;
|
return
|
||||||
top: 5px;
|
}
|
||||||
background: transparent;
|
if err := os.WriteFile(fullPath, decoded, 0644); err != nil {
|
||||||
border: none;
|
http.Error(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
|
||||||
color: #dc3545;
|
return
|
||||||
font-size: 1rem;
|
}
|
||||||
}
|
w.Header().Set("Content-Type", "application/json")
|
||||||
textarea#messageInput {
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||||
resize: none;
|
}
|
||||||
height: 80px;
|
|
||||||
overflow-y: auto;
|
func secureFilePath(relPath string) (string, error) {
|
||||||
}
|
relPath = filepath.ToSlash(relPath)
|
||||||
</style>
|
if strings.Contains(relPath, "..") {
|
||||||
</head>
|
return "", errors.New("relative paths outside the workspace are not allowed")
|
||||||
<body>
|
}
|
||||||
<!-- Offcanvas for mobile conversation list -->
|
fullPath := filepath.Join(absWorkDir, relPath)
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasConversations" aria-labelledby="offcanvasConversationsLabel">
|
absFullPath, err := filepath.Abs(fullPath)
|
||||||
<div class="offcanvas-header">
|
if err != nil {
|
||||||
<h5 class="offcanvas-title" id="offcanvasConversationsLabel">Conversations</h5>
|
return "", err
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
}
|
||||||
</div>
|
if !strings.HasPrefix(absFullPath, absWorkDir) {
|
||||||
<div class="offcanvas-body" id="offcanvasConversationList" style="overflow-y:auto;"></div>
|
return "", errors.New("attempt to access files outside the workspace")
|
||||||
</div>
|
}
|
||||||
|
return absFullPath, nil
|
||||||
<div class="container-main">
|
}
|
||||||
<!-- Sidebar for md+ screens -->
|
|
||||||
<div class="sidebar d-none d-md-block" id="conversationListContainer">
|
|
||||||
<h5>Conversations</h5>
|
|
||||||
<button id="newConvoBtn" class="btn btn-sm btn-primary mb-2">New Conversation</button>
|
|
||||||
<div id="conversationList"></div>
|
|
||||||
</div>
|
|
||||||
<!-- Chat container -->
|
|
||||||
<div class="chat-container">
|
|
||||||
<!-- On small screens, a button to open conversation offcanvas -->
|
|
||||||
<div class="d-block d-md-none mb-2">
|
|
||||||
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasConversations">
|
|
||||||
Open Conversations
|
|
||||||
</button>
|
|
||||||
<button id="newConvoBtnMobile" class="btn btn-sm btn-primary ms-2">New Conversation</button>
|
|
||||||
</div>
|
|
||||||
<div class="chat-header">
|
|
||||||
<h4 id="chatTitle">Conversation</h4>
|
|
||||||
</div>
|
|
||||||
<div id="chatLog" class="chat-log"></div>
|
|
||||||
<div class="chat-input">
|
|
||||||
<form id="chatForm">
|
|
||||||
<div class="input-group">
|
|
||||||
<textarea id="messageInput" class="form-control" placeholder="Type your message here" required></textarea>
|
|
||||||
<button class="btn btn-primary" type="submit">Send</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Include Bootstrap 5 JS bundle (includes Popper and offcanvas support) -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<!-- Marked & Highlight.js Libraries -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
|
||||||
<script>
|
|
||||||
marked.setOptions({
|
|
||||||
highlight: function(code, lang) {
|
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
|
||||||
return hljs.highlight(code, { language: lang }).value;
|
|
||||||
}
|
|
||||||
return hljs.highlightAuto(code).value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Conversation Management ---
|
|
||||||
let conversations = [];
|
|
||||||
let currentConversation = null;
|
|
||||||
function generateId() {
|
|
||||||
return 'c-' + Date.now() + '-' + Math.floor(Math.random() * 1000);
|
|
||||||
}
|
|
||||||
function loadConversations() {
|
|
||||||
const loaded = localStorage.getItem('conversations');
|
|
||||||
conversations = loaded ? JSON.parse(loaded) : [];
|
|
||||||
renderConversationList();
|
|
||||||
}
|
|
||||||
function saveConversations() {
|
|
||||||
localStorage.setItem('conversations', JSON.stringify(conversations));
|
|
||||||
}
|
|
||||||
function createNewConversation() {
|
|
||||||
const newConvo = {
|
|
||||||
id: generateId(),
|
|
||||||
title: 'New Conversation',
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: "You are ChatGPT, a helpful assistant. When providing code, please always wrap the code in three backticks so that it renders correctly." }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
conversations.push(newConvo);
|
|
||||||
currentConversation = newConvo;
|
|
||||||
saveConversations();
|
|
||||||
renderConversationList();
|
|
||||||
renderChatLog();
|
|
||||||
document.getElementById('chatTitle').textContent = "Conversation (" + newConvo.id + ")";
|
|
||||||
}
|
|
||||||
function deleteConversation(id) {
|
|
||||||
if (!confirm("Are you sure you want to delete this conversation?")) return;
|
|
||||||
conversations = conversations.filter(convo => convo.id !== id);
|
|
||||||
if (currentConversation && currentConversation.id === id) {
|
|
||||||
currentConversation = conversations.length > 0 ? conversations[0] : null;
|
|
||||||
if (!currentConversation) {
|
|
||||||
createNewConversation();
|
|
||||||
} else {
|
|
||||||
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saveConversations();
|
|
||||||
renderConversationList();
|
|
||||||
renderChatLog();
|
|
||||||
}
|
|
||||||
function renderConversationList() {
|
|
||||||
// Render for sidebar (md+)
|
|
||||||
const listElem = document.getElementById('conversationList');
|
|
||||||
listElem.innerHTML = "";
|
|
||||||
// Also render for offcanvas (mobile)
|
|
||||||
const offcanvasElem = document.getElementById('offcanvasConversationList');
|
|
||||||
offcanvasElem.innerHTML = "";
|
|
||||||
conversations.forEach(convo => {
|
|
||||||
const createConvoItem = (container) => {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.className = "conversation-item" + (currentConversation && currentConversation.id === convo.id ? " active" : "");
|
|
||||||
div.textContent = convo.title || convo.id;
|
|
||||||
const deleteBtn = document.createElement("button");
|
|
||||||
deleteBtn.className = "delete-btn";
|
|
||||||
deleteBtn.innerHTML = "×";
|
|
||||||
deleteBtn.onclick = function(e) { e.stopPropagation(); deleteConversation(convo.id); };
|
|
||||||
div.appendChild(deleteBtn);
|
|
||||||
div.onclick = () => {
|
|
||||||
currentConversation = convo;
|
|
||||||
document.getElementById('chatTitle').textContent = "Conversation (" + convo.id + ")";
|
|
||||||
renderConversationList();
|
|
||||||
renderChatLog();
|
|
||||||
// Hide offcanvas after selection on mobile
|
|
||||||
var offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('offcanvasConversations'));
|
|
||||||
if(offcanvas) offcanvas.hide();
|
|
||||||
};
|
|
||||||
container.appendChild(div);
|
|
||||||
};
|
|
||||||
createConvoItem(listElem);
|
|
||||||
createConvoItem(offcanvasElem);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function renderChatLog() {
|
|
||||||
const chatLog = document.getElementById('chatLog');
|
|
||||||
chatLog.innerHTML = "";
|
|
||||||
if (!currentConversation) return;
|
|
||||||
currentConversation.messages.forEach(msg => {
|
|
||||||
appendMessage(msg.role, msg.content, false);
|
|
||||||
});
|
|
||||||
chatLog.scrollTop = chatLog.scrollHeight;
|
|
||||||
}
|
|
||||||
function appendMessage(role, content, update = true) {
|
|
||||||
const chatLog = document.getElementById('chatLog');
|
|
||||||
const messageElem = document.createElement("div");
|
|
||||||
messageElem.className = "message " + role;
|
|
||||||
messageElem.innerHTML = marked.parse(content);
|
|
||||||
chatLog.appendChild(messageElem);
|
|
||||||
chatLog.scrollTop = chatLog.scrollHeight;
|
|
||||||
if (update && currentConversation) {
|
|
||||||
currentConversation.messages.push({ role: role, content: content });
|
|
||||||
if (role === "assistant" && currentConversation.title === "New Conversation") {
|
|
||||||
autoTitleConversation();
|
|
||||||
}
|
|
||||||
saveConversations();
|
|
||||||
}
|
|
||||||
return messageElem;
|
|
||||||
}
|
|
||||||
async function autoTitleConversation() {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/title", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ messages: currentConversation.messages })
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("Title API error: " + response.status);
|
|
||||||
const data = await response.json();
|
|
||||||
const newTitle = data.title.trim();
|
|
||||||
if(newTitle) {
|
|
||||||
currentConversation.title = newTitle;
|
|
||||||
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + "): " + newTitle;
|
|
||||||
renderConversationList();
|
|
||||||
saveConversations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error auto-titling conversation:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const chatForm = document.getElementById('chatForm');
|
|
||||||
const messageInput = document.getElementById('messageInput');
|
|
||||||
messageInput.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
chatForm.requestSubmit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
chatForm.addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const userMessage = messageInput.value.trim();
|
|
||||||
if (!userMessage || !currentConversation) return;
|
|
||||||
appendMessage("user", userMessage);
|
|
||||||
messageInput.value = "";
|
|
||||||
const typingIndicator = appendMessage("assistant", "Typing...", false);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ messages: currentConversation.messages })
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("Server error: " + response.status);
|
|
||||||
const data = await response.json();
|
|
||||||
typingIndicator.remove();
|
|
||||||
appendMessage("assistant", data.reply);
|
|
||||||
} catch (error) {
|
|
||||||
typingIndicator.remove();
|
|
||||||
appendMessage("assistant", "Error: " + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.getElementById('newConvoBtn').addEventListener("click", function() {
|
|
||||||
createNewConversation();
|
|
||||||
});
|
|
||||||
document.getElementById('newConvoBtnMobile').addEventListener("click", function() {
|
|
||||||
createNewConversation();
|
|
||||||
});
|
|
||||||
loadConversations();
|
|
||||||
if(conversations.length === 0) {
|
|
||||||
createNewConversation();
|
|
||||||
} else {
|
|
||||||
currentConversation = conversations[0];
|
|
||||||
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
|
|
||||||
renderChatLog();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
Reference in New Issue
Block a user