diff --git a/main.go b/main.go index b018995..96e1bfc 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( "time" ) -// Message represents a single message in the conversation. +// Message represents one message in the conversation. type Message struct { Role string `json:"role"` Content string `json:"content"` @@ -21,15 +21,14 @@ type Message struct { type ChatCompletionRequest struct { Model string `json:"model"` 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 { Message Message `json:"message"` } -// ChatCompletionResponse is the API response structure. +// ChatCompletionResponse represents the API response. type ChatCompletionResponse struct { Choices []ChatCompletionResponseChoice `json:"choices"` Error *struct { @@ -48,35 +47,30 @@ func init() { if openaiAPIKey == "" { 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)) } func main() { http.HandleFunc("/", indexHandler) http.HandleFunc("/chat", chatHandler) - http.HandleFunc("/title", titleHandler) // New endpoint for titling conversation - + http.HandleFunc("/title", titleHandler) addr := ":8080" log.Println("Server starting on", addr) log.Fatal(http.ListenAndServe(addr, nil)) } -// indexHandler serves the main HTML page. func indexHandler(w http.ResponseWriter, r *http.Request) { if err := indexTmpl.Execute(w, nil); err != nil { 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) { if r.Method != http.MethodPost { http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) return } - var clientRequest struct { 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) return } - apiRequestData := ChatCompletionRequest{ Model: "o3-mini", Messages: clientRequest.Messages, } - requestBytes, err := json.Marshal(apiRequestData) if err != nil { http.Error(w, "Error marshalling request: "+err.Error(), http.StatusInternalServerError) return } - client := http.Client{Timeout: 60 * time.Second} req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(requestBytes)) 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("Authorization", "Bearer "+openaiAPIKey) - apiResponse, err := client.Do(req) if err != nil { http.Error(w, "Error calling OpenAI API: "+err.Error(), http.StatusInternalServerError) return } defer apiResponse.Body.Close() - bodyBytes, err := io.ReadAll(apiResponse.Body) if err != nil { http.Error(w, "Error reading API response: "+err.Error(), http.StatusInternalServerError) return } - var completionResponse ChatCompletionResponse if err := json.Unmarshal(bodyBytes, &completionResponse); err != nil { http.Error(w, "Error parsing API response: "+err.Error(), http.StatusInternalServerError) return } - if completionResponse.Error != nil { http.Error(w, "OpenAI API error: "+completionResponse.Error.Message, http.StatusInternalServerError) return } - if len(completionResponse.Choices) == 0 { http.Error(w, "No choices returned from OpenAI API", http.StatusInternalServerError) return } - responsePayload := map[string]string{ "reply": completionResponse.Choices[0].Message.Content, } - w.Header().Set("Content-Type", "application/json") 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) { if r.Method != http.MethodPost { http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) return } - - // Read the incoming JSON containing the conversation messages. var clientRequest struct { 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) return } - - // Prepend a system message that instructs the AI to generate - // a short title based on the conversation. titleRequestMessages := []Message{ { 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...) - apiRequestData := ChatCompletionRequest{ Model: "o3-mini", Messages: titleRequestMessages, } - requestBytes, err := json.Marshal(apiRequestData) if err != nil { http.Error(w, "Error marshalling title request: "+err.Error(), http.StatusInternalServerError) return } - client := http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(requestBytes)) 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("Authorization", "Bearer "+openaiAPIKey) - apiResponse, err := client.Do(req) if err != nil { http.Error(w, "Error calling OpenAI API for title: "+err.Error(), http.StatusInternalServerError) return } defer apiResponse.Body.Close() - bodyBytes, err := io.ReadAll(apiResponse.Body) if err != nil { http.Error(w, "Error reading title API response: "+err.Error(), http.StatusInternalServerError) return } - var completionResponse ChatCompletionResponse if err := json.Unmarshal(bodyBytes, &completionResponse); err != nil { http.Error(w, "Error parsing title API response: "+err.Error(), http.StatusInternalServerError) return } - if completionResponse.Error != nil { http.Error(w, "OpenAI API title error: "+completionResponse.Error.Message, http.StatusInternalServerError) return } - if len(completionResponse.Choices) == 0 { http.Error(w, "No title returned from OpenAI API", http.StatusInternalServerError) return } - responsePayload := map[string]string{ "title": completionResponse.Choices[0].Message.Content, } - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(responsePayload) } @@ -234,82 +200,151 @@ const indexHTML = ` ChatGPT Clone - Conversations - + - + -
-
- -
- + +
+
+
Conversations
+ +
+
+
+ +
+ + + +
+ +
+ +
- -
-
-

Conversation

-
- -
-
- - -
-
-
+
+

Conversation

+
+
+
+
+
+ + +
+
- + + + + { 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(); + } + `