aibow/main.go

203 lines
5.5 KiB
Go

package main
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"google.golang.org/genai"
)
var geminiClient *genai.Client
func main() {
loadDotEnv(".env")
ctx := context.Background()
client, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: os.Getenv("GEMINI_API_KEY"),
Backend: genai.BackendGeminiAPI,
})
if err != nil {
log.Fatalf("failed to create genai client: %v", err)
}
geminiClient = client
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
http.HandleFunc("/api/video-meta", handleVideoMeta)
http.HandleFunc("/api/search", handleSearch)
http.HandleFunc("/api/chat", handleChat)
http.HandleFunc("/api/unlock", handleUnlock)
http.HandleFunc("/api/watch", handleWatch)
http.Handle("/", http.FileServer(http.Dir("web")))
log.Printf("listening on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}
func handleVideoMeta(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
meta, err := fetchVideoMeta(id, os.Getenv("YOUTUBE_API_KEY"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta)
}
func handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "missing q", http.StatusBadRequest)
return
}
results, err := searchVideos(query, os.Getenv("YOUTUBE_API_KEY"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
type chatRequest struct {
VideoTitle string `json:"videoTitle"`
VideoDescription string `json:"videoDescription"`
History []chatTurn `json:"history"`
Message string `json:"message"`
UnlockCode string `json:"unlockCode"`
Persona string `json:"persona"`
}
type chatStreamEvent struct {
Type string `json:"type"` // "status" | "final" | "error"
Text string `json:"text,omitempty"`
Reply string `json:"reply,omitempty"`
Model string `json:"model,omitempty"`
Videos []searchResult `json:"videos,omitempty"`
AutoPlayVideoID string `json:"autoPlayVideoId,omitempty"`
}
func handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
flusher, _ := w.(http.Flusher)
encoder := json.NewEncoder(w)
writeEvent := func(ev chatStreamEvent) {
encoder.Encode(ev)
if flusher != nil {
flusher.Flush()
}
}
model := selectModel(req.UnlockCode, os.Getenv("UNLOCK_CODE"))
outcome, err := chatReply(r.Context(), geminiClient, model, os.Getenv("YOUTUBE_API_KEY"), req.VideoTitle, req.VideoDescription, req.Persona, req.History, req.Message, func(status string) {
writeEvent(chatStreamEvent{Type: "status", Text: status})
})
if err != nil {
writeEvent(chatStreamEvent{Type: "error", Text: err.Error()})
return
}
writeEvent(chatStreamEvent{
Type: "final",
Reply: outcome.Reply,
Model: outcome.Model,
Videos: outcome.Videos,
AutoPlayVideoID: outcome.AutoPlayVideoID,
})
}
type watchResponse struct {
Reaction string `json:"reaction"`
}
func handleWatch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "bad multipart form", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("clip")
if err != nil {
http.Error(w, "missing clip", http.StatusBadRequest)
return
}
defer file.Close()
clipBytes, err := io.ReadAll(file)
if err != nil {
http.Error(w, "failed to read clip", http.StatusBadRequest)
return
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "video/webm"
}
model := selectModel(r.FormValue("unlockCode"), os.Getenv("UNLOCK_CODE"))
reaction, err := watchReaction(r.Context(), geminiClient, model, clipBytes, mimeType, r.FormValue("videoTitle"), r.FormValue("videoDescription"), r.FormValue("persona"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(watchResponse{Reaction: reaction})
}
type unlockRequest struct {
Code string `json:"code"`
}
type unlockResponse struct {
Ok bool `json:"ok"`
Model string `json:"model"`
}
func handleUnlock(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req unlockRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
model := selectModel(req.Code, os.Getenv("UNLOCK_CODE"))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(unlockResponse{Ok: model == modelGemini, Model: model})
}