good working version

This commit is contained in:
2025-02-04 00:20:30 -07:00
parent 1b807b6c93
commit aa460345f7

535
main.go
View File

@ -11,7 +11,7 @@ import (
"time" "time"
) )
// Message represents a single message in the conversation. // Message represents one message in the conversation.
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
@ -21,15 +21,14 @@ type Message struct {
type ChatCompletionRequest struct { type ChatCompletionRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
// no temperature field for "o3-mini"
} }
// ChatCompletionResponseChoice represents one of the returned choices. // ChatCompletionResponseChoice is a single reply (choice) from the API.
type ChatCompletionResponseChoice struct { type ChatCompletionResponseChoice struct {
Message Message `json:"message"` Message Message `json:"message"`
} }
// ChatCompletionResponse is the API response structure. // ChatCompletionResponse represents the API response.
type ChatCompletionResponse struct { type ChatCompletionResponse struct {
Choices []ChatCompletionResponseChoice `json:"choices"` Choices []ChatCompletionResponseChoice `json:"choices"`
Error *struct { Error *struct {
@ -48,35 +47,30 @@ 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.
indexTmpl = template.Must(template.New("index").Parse(indexHTML)) indexTmpl = template.Must(template.New("index").Parse(indexHTML))
} }
func main() { func main() {
http.HandleFunc("/", indexHandler) http.HandleFunc("/", indexHandler)
http.HandleFunc("/chat", chatHandler) http.HandleFunc("/chat", chatHandler)
http.HandleFunc("/title", titleHandler) // New endpoint for titling conversation http.HandleFunc("/title", titleHandler)
addr := ":8080" addr := ":8080"
log.Println("Server starting on", addr) log.Println("Server starting on", addr)
log.Fatal(http.ListenAndServe(addr, nil)) log.Fatal(http.ListenAndServe(addr, nil))
} }
// indexHandler serves the main HTML page.
func indexHandler(w http.ResponseWriter, r *http.Request) { func indexHandler(w http.ResponseWriter, r *http.Request) {
if err := indexTmpl.Execute(w, nil); err != nil { if err := indexTmpl.Execute(w, nil); err != nil {
http.Error(w, "Unable to load page", http.StatusInternalServerError) http.Error(w, "Unable to load page", http.StatusInternalServerError)
} }
} }
// chatHandler receives conversation messages from the client,
// calls the OpenAI API, and returns the assistant's reply.
func chatHandler(w http.ResponseWriter, r *http.Request) { func chatHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
return return
} }
var clientRequest struct { var clientRequest struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
} }
@ -84,18 +78,15 @@ func chatHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return return
} }
apiRequestData := ChatCompletionRequest{ apiRequestData := ChatCompletionRequest{
Model: "o3-mini", Model: "o3-mini",
Messages: clientRequest.Messages, Messages: clientRequest.Messages,
} }
requestBytes, err := json.Marshal(apiRequestData) requestBytes, err := json.Marshal(apiRequestData)
if err != nil { if err != nil {
http.Error(w, "Error marshalling request: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error marshalling request: "+err.Error(), http.StatusInternalServerError)
return return
} }
client := http.Client{Timeout: 60 * time.Second} client := http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(requestBytes)) req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(requestBytes))
if err != nil { if err != nil {
@ -104,54 +95,42 @@ func chatHandler(w http.ResponseWriter, r *http.Request) {
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+openaiAPIKey) req.Header.Set("Authorization", "Bearer "+openaiAPIKey)
apiResponse, err := client.Do(req) apiResponse, err := client.Do(req)
if err != nil { if err != nil {
http.Error(w, "Error calling OpenAI API: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error calling OpenAI API: "+err.Error(), http.StatusInternalServerError)
return return
} }
defer apiResponse.Body.Close() defer apiResponse.Body.Close()
bodyBytes, err := io.ReadAll(apiResponse.Body) bodyBytes, err := io.ReadAll(apiResponse.Body)
if err != nil { if err != nil {
http.Error(w, "Error reading API response: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error reading API response: "+err.Error(), http.StatusInternalServerError)
return return
} }
var completionResponse ChatCompletionResponse var completionResponse ChatCompletionResponse
if err := json.Unmarshal(bodyBytes, &completionResponse); err != nil { if err := json.Unmarshal(bodyBytes, &completionResponse); err != nil {
http.Error(w, "Error parsing API response: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error parsing API response: "+err.Error(), http.StatusInternalServerError)
return return
} }
if completionResponse.Error != nil { if completionResponse.Error != nil {
http.Error(w, "OpenAI API error: "+completionResponse.Error.Message, http.StatusInternalServerError) http.Error(w, "OpenAI API error: "+completionResponse.Error.Message, http.StatusInternalServerError)
return return
} }
if len(completionResponse.Choices) == 0 { if len(completionResponse.Choices) == 0 {
http.Error(w, "No choices returned from OpenAI API", http.StatusInternalServerError) http.Error(w, "No choices returned from OpenAI API", http.StatusInternalServerError)
return return
} }
responsePayload := map[string]string{ responsePayload := map[string]string{
"reply": completionResponse.Choices[0].Message.Content, "reply": completionResponse.Choices[0].Message.Content,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(responsePayload) json.NewEncoder(w).Encode(responsePayload)
} }
// titleHandler provides an automatic title for the conversation.
// It expects a POST with JSON: { messages: []Message } and then
// calls the OpenAI API with a special prompt to generate a title.
func titleHandler(w http.ResponseWriter, r *http.Request) { func titleHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
return return
} }
// Read the incoming JSON containing the conversation messages.
var clientRequest struct { var clientRequest struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
} }
@ -159,28 +138,22 @@ func titleHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return return
} }
// Prepend a system message that instructs the AI to generate
// a short title based on the conversation.
titleRequestMessages := []Message{ titleRequestMessages := []Message{
{ {
Role: "system", Role: "system",
Content: "You are an AI that generates conversation titles. Given the conversation below, provide a short, descriptive title (in no more than five words) with no additional commentary.", 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.",
}, },
} }
titleRequestMessages = append(titleRequestMessages, clientRequest.Messages...) titleRequestMessages = append(titleRequestMessages, clientRequest.Messages...)
apiRequestData := ChatCompletionRequest{ apiRequestData := ChatCompletionRequest{
Model: "o3-mini", Model: "o3-mini",
Messages: titleRequestMessages, Messages: titleRequestMessages,
} }
requestBytes, err := json.Marshal(apiRequestData) requestBytes, err := json.Marshal(apiRequestData)
if err != nil { if err != nil {
http.Error(w, "Error marshalling title request: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error marshalling title request: "+err.Error(), http.StatusInternalServerError)
return return
} }
client := http.Client{Timeout: 30 * time.Second} client := http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(requestBytes)) req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(requestBytes))
if err != nil { if err != nil {
@ -189,40 +162,33 @@ func titleHandler(w http.ResponseWriter, r *http.Request) {
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+openaiAPIKey) req.Header.Set("Authorization", "Bearer "+openaiAPIKey)
apiResponse, err := client.Do(req) apiResponse, err := client.Do(req)
if err != nil { if err != nil {
http.Error(w, "Error calling OpenAI API for title: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error calling OpenAI API for title: "+err.Error(), http.StatusInternalServerError)
return return
} }
defer apiResponse.Body.Close() defer apiResponse.Body.Close()
bodyBytes, err := io.ReadAll(apiResponse.Body) bodyBytes, err := io.ReadAll(apiResponse.Body)
if err != nil { if err != nil {
http.Error(w, "Error reading title API response: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error reading title API response: "+err.Error(), http.StatusInternalServerError)
return return
} }
var completionResponse ChatCompletionResponse var completionResponse ChatCompletionResponse
if err := json.Unmarshal(bodyBytes, &completionResponse); err != nil { if err := json.Unmarshal(bodyBytes, &completionResponse); err != nil {
http.Error(w, "Error parsing title API response: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Error parsing title API response: "+err.Error(), http.StatusInternalServerError)
return return
} }
if completionResponse.Error != nil { if completionResponse.Error != nil {
http.Error(w, "OpenAI API title error: "+completionResponse.Error.Message, http.StatusInternalServerError) http.Error(w, "OpenAI API title error: "+completionResponse.Error.Message, http.StatusInternalServerError)
return return
} }
if len(completionResponse.Choices) == 0 { if len(completionResponse.Choices) == 0 {
http.Error(w, "No title returned from OpenAI API", http.StatusInternalServerError) http.Error(w, "No title returned from OpenAI API", http.StatusInternalServerError)
return return
} }
responsePayload := map[string]string{ responsePayload := map[string]string{
"title": completionResponse.Choices[0].Message.Content, "title": completionResponse.Choices[0].Message.Content,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(responsePayload) json.NewEncoder(w).Encode(responsePayload)
} }
@ -234,82 +200,151 @@ const indexHTML = `
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ChatGPT Clone - Conversations</title> <title>ChatGPT Clone - Conversations</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS --> <!-- Bootstrap 5 CSS and offcanvas component support -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Highlight.js CSS --> <!-- 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"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
<style> <style>
body { background-color: #f7f7f7; } /* Use full viewport height */
.container-main { margin-top: 1rem; } html, body {
.sidebar { height: 100%;
max-height: 80vh; overflow-y: auto; background-color: #fff; margin: 0;
border-radius: 8px; padding: 0.5rem; box-shadow: 0 0 10px rgba(0,0,0,0.1);
} }
/* Main container uses flex layout in row direction on md+ and column on small screens */
.container-main {
height: 100vh;
display: flex;
flex-direction: row;
}
/* Sidebar styles (hidden on small screens) */
.sidebar {
width: 25%;
max-width: 300px;
background-color: #fff;
padding: 0.5rem;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
overflow-y: auto;
}
/* On small devices, hide the sidebar and use offcanvas instead */
@media (max-width: 767.98px) {
.sidebar {
display: none;
}
}
/* Chat container fills remaining space and uses column layout */
.chat-container { .chat-container {
background-color: #fff; border-radius: 8px; padding: 1rem; flex: 1;
box-shadow: 0 0 10px rgba(0,0,0,0.1); flex-grow: 1; display: flex;
flex-direction: column;
background-color: #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-color: #eee;
border: 1px solid #ddd;
border-radius: 5px;
padding: 0.5rem;
margin: 0.5rem 0;
}
.chat-input {
flex-shrink: 0;
} }
.message { .message {
padding: 0.75rem; border-radius: 5px; margin-bottom: 0.5rem; white-space: pre-wrap; padding: 0.75rem;
border-radius: 5px;
margin-bottom: 0.5rem;
white-space: pre-wrap;
} }
.user { background-color: #d1e7dd; text-align: right; } .user { background-color: #d1e7dd; text-align: right; }
.assistant { background-color: #f8d7da; text-align: left; } .assistant { background-color: #f8d7da; text-align: left; }
.chat-log {
max-height: 400px; overflow-y: auto; margin-bottom: 1rem;
background-color: #eee; border: 1px solid #ddd; border-radius: 5px; padding: 0.5rem;
}
pre { pre {
background-color: #2d2d2d; color: #f8f8f2; padding: 0.5rem; max-width: 100%;
border-radius: 5px; overflow-x: auto; box-sizing: border-box;
white-space: pre-wrap;
overflow-x: auto;
background-color: #2d2d2d;
color: #f8f8f2;
padding: 0.5rem;
border-radius: 5px;
} }
.conversation-item { .conversation-item {
padding: 0.5rem; cursor: pointer; border-bottom: 1px solid #ddd; position: relative; padding: 0.5rem;
cursor: pointer;
border-bottom: 1px solid #ddd;
position: relative;
} }
.conversation-item:hover, .conversation-item.active { background-color: #f0f0f0; } .conversation-item:hover,
/* Delete button styling */ .conversation-item.active { background-color: #f0f0f0; }
.delete-btn { .delete-btn {
position: absolute; right: 5px; top: 5px; background: transparent; position: absolute;
border: none; color: #dc3545; font-size: 1rem; right: 5px;
top: 5px;
background: transparent;
border: none;
color: #dc3545;
font-size: 1rem;
} }
/* Style the textarea for multi-line messages */
textarea#messageInput { textarea#messageInput {
resize: none; height: 80px; overflow-y: auto; resize: none;
height: 80px;
overflow-y: auto;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container container-main"> <!-- Offcanvas for mobile conversation list -->
<div class="row"> <div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasConversations" aria-labelledby="offcanvasConversationsLabel">
<!-- Sidebar: Conversation List --> <div class="offcanvas-header">
<div class="col-md-3"> <h5 class="offcanvas-title" id="offcanvasConversationsLabel">Conversations</h5>
<div class="sidebar"> <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
<h5>Conversations</h5> </div>
<button id="newConvoBtn" class="btn btn-sm btn-primary mb-2">New Conversation</button> <div class="offcanvas-body" id="offcanvasConversationList" style="overflow-y:auto;"></div>
<div id="conversationList"></div> </div>
</div>
<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>
<!-- Chat Container --> <div class="chat-header">
<div class="col-md-9"> <h4 id="chatTitle">Conversation</h4>
<div class="chat-container d-flex flex-column"> </div>
<h4 id="chatTitle">Conversation</h4> <div id="chatLog" class="chat-log"></div>
<div id="chatLog" class="chat-log"></div> <div class="chat-input">
<!-- Changed input to textarea for multi-line messages --> <form id="chatForm">
<form id="chatForm" class="mt-auto"> <div class="input-group">
<div class="input-group"> <textarea id="messageInput" class="form-control" placeholder="Type your message here" required></textarea>
<textarea id="messageInput" class="form-control" placeholder="Type your message here" required></textarea> <button class="btn btn-primary" type="submit">Send</button>
<button class="btn btn-primary" type="submit">Send</button> </div>
</div> </form>
</form>
</div>
</div> </div>
</div> </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 --> <!-- 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/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 src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script> <script>
// Configure marked to use highlight.js for code blocks.
marked.setOptions({ marked.setOptions({
highlight: function(code, lang) { highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
@ -318,199 +353,173 @@ const indexHTML = `
return hljs.highlightAuto(code).value; return hljs.highlightAuto(code).value;
} }
}); });
// --- Conversation Management --- // --- Conversation Management ---
// Each conversation is an object: { id: string, title: string, messages: [ {role, content} ] }
// Stored in localStorage under key "conversations"
let conversations = []; let conversations = [];
let currentConversation = null; let currentConversation = null;
// Generate unique conversation IDs.
function generateId() { function generateId() {
return 'c-' + Date.now() + '-' + Math.floor(Math.random()*1000); return 'c-' + Date.now() + '-' + Math.floor(Math.random() * 1000);
} }
function loadConversations() { function loadConversations() {
const loaded = localStorage.getItem('conversations'); const loaded = localStorage.getItem('conversations');
if (loaded) { conversations = loaded ? JSON.parse(loaded) : [];
conversations = JSON.parse(loaded);
} else {
conversations = [];
}
renderConversationList(); renderConversationList();
} }
function saveConversations() { function saveConversations() {
localStorage.setItem('conversations', JSON.stringify(conversations)); localStorage.setItem('conversations', JSON.stringify(conversations));
} }
function createNewConversation() { function createNewConversation() {
const newConvo = { const newConvo = {
id: generateId(), id: generateId(),
title: 'New Conversation', title: 'New Conversation',
messages: [ messages: [
{ role: "system", content: "You are ChatGPT, a helpful assistant. When providing code, please always wrap the code in markdown triple backticks so that it renders correctly." } { 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); conversations.push(newConvo);
currentConversation = newConvo; currentConversation = newConvo;
saveConversations(); saveConversations();
renderConversationList(); renderConversationList();
renderChatLog(); renderChatLog();
document.getElementById('chatTitle').textContent = "Conversation (" + newConvo.id + ")"; document.getElementById('chatTitle').textContent = "Conversation (" + newConvo.id + ")";
} }
function deleteConversation(id) {
function deleteConversation(id) { if (!confirm("Are you sure you want to delete this conversation?")) return;
if (!confirm("Are you sure you want to delete this conversation?")) return; conversations = conversations.filter(convo => convo.id !== id);
conversations = conversations.filter(convo => convo.id !== id); if (currentConversation && currentConversation.id === id) {
if (currentConversation && currentConversation.id === id) { currentConversation = conversations.length > 0 ? conversations[0] : null;
currentConversation = conversations.length > 0 ? conversations[0] : null; if (!currentConversation) {
if (!currentConversation) { createNewConversation(); } createNewConversation();
else { document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")"; } } else {
} document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
saveConversations(); }
renderConversationList(); }
renderChatLog(); saveConversations();
} renderConversationList();
renderChatLog();
function renderConversationList() { }
const listElem = document.getElementById('conversationList'); function renderConversationList() {
listElem.innerHTML = ""; // Render for sidebar (md+)
conversations.forEach(convo => { const listElem = document.getElementById('conversationList');
const div = document.createElement("div"); listElem.innerHTML = "";
div.className = "conversation-item" + (currentConversation && currentConversation.id === convo.id ? " active" : ""); // Also render for offcanvas (mobile)
div.textContent = convo.title || convo.id; const offcanvasElem = document.getElementById('offcanvasConversationList');
offcanvasElem.innerHTML = "";
// Create delete button. conversations.forEach(convo => {
const deleteBtn = document.createElement("button"); const createConvoItem = (container) => {
deleteBtn.className = "delete-btn"; const div = document.createElement("div");
deleteBtn.innerHTML = "&times;"; div.className = "conversation-item" + (currentConversation && currentConversation.id === convo.id ? " active" : "");
deleteBtn.onclick = function(e) { e.stopPropagation(); deleteConversation(convo.id); }; div.textContent = convo.title || convo.id;
div.appendChild(deleteBtn); const deleteBtn = document.createElement("button");
deleteBtn.className = "delete-btn";
// Click on conversation selects it. deleteBtn.innerHTML = "&times;";
div.onclick = () => { deleteBtn.onclick = function(e) { e.stopPropagation(); deleteConversation(convo.id); };
currentConversation = convo; div.appendChild(deleteBtn);
document.getElementById('chatTitle').textContent = "Conversation (" + convo.id + ")"; div.onclick = () => {
renderConversationList(); currentConversation = convo;
renderChatLog(); document.getElementById('chatTitle').textContent = "Conversation (" + convo.id + ")";
}; renderConversationList();
listElem.appendChild(div); renderChatLog();
}); // Hide offcanvas after selection on mobile
} var offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('offcanvasConversations'));
if(offcanvas) offcanvas.hide();
function renderChatLog() { };
const chatLog = document.getElementById('chatLog'); container.appendChild(div);
chatLog.innerHTML = ""; };
if (!currentConversation) return; createConvoItem(listElem);
currentConversation.messages.forEach(msg => { createConvoItem(offcanvasElem);
appendMessage(msg.role, msg.content, false); });
}); }
chatLog.scrollTop = chatLog.scrollHeight; function renderChatLog() {
} const chatLog = document.getElementById('chatLog');
chatLog.innerHTML = "";
// Append message to chat log. if (!currentConversation) return;
// "update" determines if the message is added to the conversation. currentConversation.messages.forEach(msg => {
function appendMessage(role, content, update = true) { appendMessage(msg.role, msg.content, false);
const chatLog = document.getElementById('chatLog'); });
const messageElem = document.createElement("div"); chatLog.scrollTop = chatLog.scrollHeight;
messageElem.className = "message " + role; }
messageElem.innerHTML = marked.parse(content); function appendMessage(role, content, update = true) {
chatLog.appendChild(messageElem); const chatLog = document.getElementById('chatLog');
chatLog.scrollTop = chatLog.scrollHeight; const messageElem = document.createElement("div");
if (update && currentConversation) { messageElem.className = "message " + role;
currentConversation.messages.push({ role: role, content: content }); messageElem.innerHTML = marked.parse(content);
// Auto-update title if still "New Conversation" after the assistant's reply. chatLog.appendChild(messageElem);
if (role === "assistant" && currentConversation.title === "New Conversation") { chatLog.scrollTop = chatLog.scrollHeight;
autoTitleConversation(); if (update && currentConversation) {
} currentConversation.messages.push({ role: role, content: content });
saveConversations(); if (role === "assistant" && currentConversation.title === "New Conversation") {
} autoTitleConversation();
return messageElem; }
} saveConversations();
}
// Call the /title endpoint to auto-generate a conversation title, return messageElem;
// then update the conversation and UI. }
async function autoTitleConversation() { async function autoTitleConversation() {
try { try {
const response = await fetch("/title", { const response = await fetch("/title", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: currentConversation.messages }) body: JSON.stringify({ messages: currentConversation.messages })
}); });
if (!response.ok) { if (!response.ok) throw new Error("Title API error: " + response.status);
throw new Error("Title API error: " + response.status); const data = await response.json();
} const newTitle = data.title.trim();
const data = await response.json(); if(newTitle) {
// Use the returned title (trim it) to update the conversation. currentConversation.title = newTitle;
const newTitle = data.title.trim(); document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + "): " + newTitle;
if(newTitle) { renderConversationList();
currentConversation.title = newTitle; saveConversations();
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + "): " + newTitle; }
renderConversationList(); } catch (error) {
saveConversations(); console.error("Error auto-titling conversation:", error);
} }
} 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) {
// --- Chat Form Submission --- e.preventDefault();
const chatForm = document.getElementById('chatForm'); chatForm.requestSubmit();
const messageInput = document.getElementById('messageInput'); }
});
// Allow Shift+Enter for newlines. chatForm.addEventListener('submit', async function(e) {
messageInput.addEventListener('keydown', function(e) { e.preventDefault();
if (e.key === "Enter" && !e.shiftKey) { const userMessage = messageInput.value.trim();
e.preventDefault(); if (!userMessage || !currentConversation) return;
chatForm.requestSubmit(); appendMessage("user", userMessage);
} messageInput.value = "";
}); const typingIndicator = appendMessage("assistant", "Typing...", false);
try {
chatForm.addEventListener('submit', async function(e) { const response = await fetch("/chat", {
e.preventDefault(); method: "POST",
const userMessage = messageInput.value.trim(); headers: { "Content-Type": "application/json" },
if (!userMessage || !currentConversation) return; body: JSON.stringify({ messages: currentConversation.messages })
});
appendMessage("user", userMessage); if (!response.ok) throw new Error("Server error: " + response.status);
messageInput.value = ""; const data = await response.json();
typingIndicator.remove();
// Add temporary typing indicator. appendMessage("assistant", data.reply);
const typingIndicator = appendMessage("assistant", "Typing...", false); } catch (error) {
typingIndicator.remove();
try { appendMessage("assistant", "Error: " + error.message);
const response = await fetch("/chat", { }
method: "POST", });
headers: { "Content-Type": "application/json" }, document.getElementById('newConvoBtn').addEventListener("click", function() {
body: JSON.stringify({ messages: currentConversation.messages }) createNewConversation();
}); });
if (!response.ok) { document.getElementById('newConvoBtnMobile').addEventListener("click", function() {
throw new Error("Server error: " + response.status); createNewConversation();
} });
const data = await response.json(); loadConversations();
typingIndicator.remove(); if(conversations.length === 0) {
appendMessage("assistant", data.reply); createNewConversation();
} catch (error) { } else {
typingIndicator.remove(); currentConversation = conversations[0];
appendMessage("assistant", "Error: " + error.message); document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
} renderChatLog();
}); }
</script>
// New Conversation button.
document.getElementById('newConvoBtn').addEventListener("click", function() {
createNewConversation();
});
// --- Initialization ---
loadConversations();
if (conversations.length === 0) {
createNewConversation();
} else {
currentConversation = conversations[0];
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
renderChatLog();
}
</script>
</body> </body>
</html> </html>
` `