good working version
This commit is contained in:
259
main.go
259
main.go
@ -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,67 +200,136 @@ 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>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body" id="offcanvasConversationList" style="overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-main">
|
||||||
|
<!-- Sidebar for md+ screens -->
|
||||||
|
<div class="sidebar d-none d-md-block" id="conversationListContainer">
|
||||||
<h5>Conversations</h5>
|
<h5>Conversations</h5>
|
||||||
<button id="newConvoBtn" class="btn btn-sm btn-primary mb-2">New Conversation</button>
|
<button id="newConvoBtn" class="btn btn-sm btn-primary mb-2">New Conversation</button>
|
||||||
<div id="conversationList"></div>
|
<div id="conversationList"></div>
|
||||||
</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">
|
|
||||||
<div class="chat-container d-flex flex-column">
|
|
||||||
<h4 id="chatTitle">Conversation</h4>
|
<h4 id="chatTitle">Conversation</h4>
|
||||||
|
</div>
|
||||||
<div id="chatLog" class="chat-log"></div>
|
<div id="chatLog" class="chat-log"></div>
|
||||||
<!-- Changed input to textarea for multi-line messages -->
|
<div class="chat-input">
|
||||||
<form id="chatForm" class="mt-auto">
|
<form id="chatForm">
|
||||||
<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>
|
||||||
@ -303,13 +338,13 @@ const indexHTML = `
|
|||||||
</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)) {
|
||||||
@ -320,36 +355,25 @@ const indexHTML = `
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- 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);
|
||||||
@ -359,46 +383,53 @@ 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) { createNewConversation(); }
|
if (!currentConversation) {
|
||||||
else { document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")"; }
|
createNewConversation();
|
||||||
|
} else {
|
||||||
|
document.getElementById('chatTitle').textContent = "Conversation (" + currentConversation.id + ")";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
saveConversations();
|
saveConversations();
|
||||||
renderConversationList();
|
renderConversationList();
|
||||||
renderChatLog();
|
renderChatLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderConversationList() {
|
function renderConversationList() {
|
||||||
|
// Render for sidebar (md+)
|
||||||
const listElem = document.getElementById('conversationList');
|
const listElem = document.getElementById('conversationList');
|
||||||
listElem.innerHTML = "";
|
listElem.innerHTML = "";
|
||||||
|
// Also render for offcanvas (mobile)
|
||||||
|
const offcanvasElem = document.getElementById('offcanvasConversationList');
|
||||||
|
offcanvasElem.innerHTML = "";
|
||||||
conversations.forEach(convo => {
|
conversations.forEach(convo => {
|
||||||
|
const createConvoItem = (container) => {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "conversation-item" + (currentConversation && currentConversation.id === convo.id ? " active" : "");
|
div.className = "conversation-item" + (currentConversation && currentConversation.id === convo.id ? " active" : "");
|
||||||
div.textContent = convo.title || convo.id;
|
div.textContent = convo.title || convo.id;
|
||||||
|
|
||||||
// Create delete button.
|
|
||||||
const deleteBtn = document.createElement("button");
|
const deleteBtn = document.createElement("button");
|
||||||
deleteBtn.className = "delete-btn";
|
deleteBtn.className = "delete-btn";
|
||||||
deleteBtn.innerHTML = "×";
|
deleteBtn.innerHTML = "×";
|
||||||
deleteBtn.onclick = function(e) { e.stopPropagation(); deleteConversation(convo.id); };
|
deleteBtn.onclick = function(e) { e.stopPropagation(); deleteConversation(convo.id); };
|
||||||
div.appendChild(deleteBtn);
|
div.appendChild(deleteBtn);
|
||||||
|
|
||||||
// Click on conversation selects it.
|
|
||||||
div.onclick = () => {
|
div.onclick = () => {
|
||||||
currentConversation = convo;
|
currentConversation = convo;
|
||||||
document.getElementById('chatTitle').textContent = "Conversation (" + convo.id + ")";
|
document.getElementById('chatTitle').textContent = "Conversation (" + convo.id + ")";
|
||||||
renderConversationList();
|
renderConversationList();
|
||||||
renderChatLog();
|
renderChatLog();
|
||||||
|
// Hide offcanvas after selection on mobile
|
||||||
|
var offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('offcanvasConversations'));
|
||||||
|
if(offcanvas) offcanvas.hide();
|
||||||
};
|
};
|
||||||
listElem.appendChild(div);
|
container.appendChild(div);
|
||||||
|
};
|
||||||
|
createConvoItem(listElem);
|
||||||
|
createConvoItem(offcanvasElem);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChatLog() {
|
function renderChatLog() {
|
||||||
const chatLog = document.getElementById('chatLog');
|
const chatLog = document.getElementById('chatLog');
|
||||||
chatLog.innerHTML = "";
|
chatLog.innerHTML = "";
|
||||||
@ -408,9 +439,6 @@ appendMessage(msg.role, msg.content, false);
|
|||||||
});
|
});
|
||||||
chatLog.scrollTop = chatLog.scrollHeight;
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append message to chat log.
|
|
||||||
// "update" determines if the message is added to the conversation.
|
|
||||||
function appendMessage(role, content, update = true) {
|
function appendMessage(role, content, update = true) {
|
||||||
const chatLog = document.getElementById('chatLog');
|
const chatLog = document.getElementById('chatLog');
|
||||||
const messageElem = document.createElement("div");
|
const messageElem = document.createElement("div");
|
||||||
@ -420,7 +448,6 @@ chatLog.appendChild(messageElem);
|
|||||||
chatLog.scrollTop = chatLog.scrollHeight;
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
if (update && currentConversation) {
|
if (update && currentConversation) {
|
||||||
currentConversation.messages.push({ role: role, content: content });
|
currentConversation.messages.push({ role: role, content: content });
|
||||||
// Auto-update title if still "New Conversation" after the assistant's reply.
|
|
||||||
if (role === "assistant" && currentConversation.title === "New Conversation") {
|
if (role === "assistant" && currentConversation.title === "New Conversation") {
|
||||||
autoTitleConversation();
|
autoTitleConversation();
|
||||||
}
|
}
|
||||||
@ -428,9 +455,6 @@ saveConversations();
|
|||||||
}
|
}
|
||||||
return messageElem;
|
return messageElem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the /title endpoint to auto-generate a conversation title,
|
|
||||||
// then update the conversation and UI.
|
|
||||||
async function autoTitleConversation() {
|
async function autoTitleConversation() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/title", {
|
const response = await fetch("/title", {
|
||||||
@ -438,11 +462,8 @@ 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 data = await response.json();
|
||||||
// Use the returned title (trim it) to update the conversation.
|
|
||||||
const newTitle = data.title.trim();
|
const newTitle = data.title.trim();
|
||||||
if(newTitle) {
|
if(newTitle) {
|
||||||
currentConversation.title = newTitle;
|
currentConversation.title = newTitle;
|
||||||
@ -454,39 +475,28 @@ saveConversations();
|
|||||||
console.error("Error auto-titling conversation:", error);
|
console.error("Error auto-titling conversation:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Chat Form Submission ---
|
|
||||||
const chatForm = document.getElementById('chatForm');
|
const chatForm = document.getElementById('chatForm');
|
||||||
const messageInput = document.getElementById('messageInput');
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
|
||||||
// Allow Shift+Enter for newlines.
|
|
||||||
messageInput.addEventListener('keydown', function(e) {
|
messageInput.addEventListener('keydown', function(e) {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
chatForm.requestSubmit();
|
chatForm.requestSubmit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
chatForm.addEventListener('submit', async function(e) {
|
chatForm.addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const userMessage = messageInput.value.trim();
|
const userMessage = messageInput.value.trim();
|
||||||
if (!userMessage || !currentConversation) return;
|
if (!userMessage || !currentConversation) return;
|
||||||
|
|
||||||
appendMessage("user", userMessage);
|
appendMessage("user", userMessage);
|
||||||
messageInput.value = "";
|
messageInput.value = "";
|
||||||
|
|
||||||
// Add temporary typing indicator.
|
|
||||||
const typingIndicator = appendMessage("assistant", "Typing...", false);
|
const typingIndicator = appendMessage("assistant", "Typing...", false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/chat", {
|
const response = await fetch("/chat", {
|
||||||
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("Server error: " + response.status);
|
||||||
throw new Error("Server error: " + response.status);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
typingIndicator.remove();
|
typingIndicator.remove();
|
||||||
appendMessage("assistant", data.reply);
|
appendMessage("assistant", data.reply);
|
||||||
@ -495,13 +505,12 @@ typingIndicator.remove();
|
|||||||
appendMessage("assistant", "Error: " + error.message);
|
appendMessage("assistant", "Error: " + error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// New Conversation button.
|
|
||||||
document.getElementById('newConvoBtn').addEventListener("click", function() {
|
document.getElementById('newConvoBtn').addEventListener("click", function() {
|
||||||
createNewConversation();
|
createNewConversation();
|
||||||
});
|
});
|
||||||
|
document.getElementById('newConvoBtnMobile').addEventListener("click", function() {
|
||||||
// --- Initialization ---
|
createNewConversation();
|
||||||
|
});
|
||||||
loadConversations();
|
loadConversations();
|
||||||
if(conversations.length === 0) {
|
if(conversations.length === 0) {
|
||||||
createNewConversation();
|
createNewConversation();
|
||||||
|
Reference in New Issue
Block a user