345 lines
10 KiB
Go
345 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Message represents one message in the conversation.
|
|
type Message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// ChatCompletionRequest is the payload sent to the OpenAI API.
|
|
type ChatCompletionRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []Message `json:"messages"`
|
|
}
|
|
|
|
// ChatCompletionResponseChoice is a single reply (choice) from the API.
|
|
type ChatCompletionResponseChoice struct {
|
|
Message Message `json:"message"`
|
|
}
|
|
|
|
// ChatCompletionResponse represents the API response.
|
|
type ChatCompletionResponse struct {
|
|
Choices []ChatCompletionResponseChoice `json:"choices"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
var (
|
|
openaiAPIKey string
|
|
indexTmpl *template.Template
|
|
workDir string // Path to the workspace directory.
|
|
absWorkDir string // Absolute workspace path.
|
|
)
|
|
|
|
func init() {
|
|
openaiAPIKey = os.Getenv("OPENAI_API_KEY")
|
|
if openaiAPIKey == "" {
|
|
log.Fatal("Environment variable OPENAI_API_KEY is not set")
|
|
}
|
|
workDir = os.Getenv("WORK_DIR")
|
|
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() {
|
|
http.HandleFunc("/", indexHandler)
|
|
http.HandleFunc("/chat", chatHandler)
|
|
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"
|
|
log.Println("Server starting on", addr)
|
|
log.Println("Workspace directory:", absWorkDir)
|
|
log.Fatal(http.ListenAndServe(addr, nil))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&clientRequest); err != nil {
|
|
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 {
|
|
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
func titleHandler(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"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&clientRequest); err != nil {
|
|
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
titleRequestMessages := []Message{
|
|
{
|
|
Role: "system",
|
|
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...)
|
|
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 {
|
|
http.Error(w, "Failed to create title request: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
//
|
|
// File system endpoints
|
|
//
|
|
|
|
// filesListHandler lists all files (recursively) in the workspace.
|
|
func filesListHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Only GET allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var files []string
|
|
err := filepath.WalkDir(absWorkDir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() {
|
|
rel, err := filepath.Rel(absWorkDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files = append(files, rel)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "Error listing files: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"files": files})
|
|
}
|
|
|
|
// fileHandler supports file read (GET) and write (POST) operations.
|
|
func fileHandler(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
handleFileRead(w, r)
|
|
case http.MethodPost:
|
|
handleFileWrite(w, r)
|
|
default:
|
|
http.Error(w, "Only GET and POST allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleFileRead(w http.ResponseWriter, r *http.Request) {
|
|
filename := r.URL.Query().Get("filename")
|
|
if filename == "" {
|
|
http.Error(w, "Missing filename", http.StatusBadRequest)
|
|
return
|
|
}
|
|
fullPath, err := secureFilePath(filename)
|
|
if err != nil {
|
|
http.Error(w, "Invalid filename: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
data, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
http.Error(w, "Error reading file: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
encoded := base64.StdEncoding.EncodeToString(data)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"content_b64": encoded})
|
|
}
|
|
|
|
func handleFileWrite(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
Filename string `json:"filename"`
|
|
ContentB64 string `json:"content_b64"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if payload.Filename == "" || payload.ContentB64 == "" {
|
|
http.Error(w, "Filename and content_b64 are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(payload.ContentB64)
|
|
if err != nil {
|
|
http.Error(w, "Error decoding Base64 content: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
fullPath, err := secureFilePath(payload.Filename)
|
|
if err != nil {
|
|
http.Error(w, "Invalid filename: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
dir := filepath.Dir(fullPath)
|
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
|
http.Error(w, "Error creating directory: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := os.WriteFile(fullPath, decoded, 0644); err != nil {
|
|
http.Error(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
func secureFilePath(relPath string) (string, error) {
|
|
relPath = filepath.ToSlash(relPath)
|
|
if strings.Contains(relPath, "..") {
|
|
return "", errors.New("relative paths outside the workspace are not allowed")
|
|
}
|
|
fullPath := filepath.Join(absWorkDir, relPath)
|
|
absFullPath, err := filepath.Abs(fullPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.HasPrefix(absFullPath, absWorkDir) {
|
|
return "", errors.New("attempt to access files outside the workspace")
|
|
}
|
|
return absFullPath, nil
|
|
}
|