258 lines
7.1 KiB
Go
258 lines
7.1 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.HandleFunc("/api/voice-chat", handleVoiceChat)
|
|
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"
|
|
}
|
|
|
|
var recentReactions []chatTurn
|
|
if raw := r.FormValue("history"); raw != "" {
|
|
json.Unmarshal([]byte(raw), &recentReactions)
|
|
}
|
|
|
|
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"), recentReactions)
|
|
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 voiceChatResponse struct {
|
|
Transcript string `json:"transcript"`
|
|
Reply string `json:"reply"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
func handleVoiceChat(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
|
http.Error(w, "bad multipart form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
file, header, err := r.FormFile("audio")
|
|
if err != nil {
|
|
http.Error(w, "missing audio", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
audioBytes, err := io.ReadAll(file)
|
|
if err != nil {
|
|
http.Error(w, "failed to read audio", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
mimeType := header.Header.Get("Content-Type")
|
|
if mimeType == "" {
|
|
mimeType = "audio/webm"
|
|
}
|
|
|
|
var history []chatTurn
|
|
if raw := r.FormValue("history"); raw != "" {
|
|
json.Unmarshal([]byte(raw), &history)
|
|
}
|
|
|
|
model := selectModel(r.FormValue("unlockCode"), os.Getenv("UNLOCK_CODE"))
|
|
outcome, err := voiceChat(r.Context(), geminiClient, model, audioBytes, mimeType, r.FormValue("videoTitle"), r.FormValue("videoDescription"), r.FormValue("persona"), history)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(voiceChatResponse{Transcript: outcome.Transcript, Reply: outcome.Reply, Model: model})
|
|
}
|
|
|
|
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})
|
|
}
|