From 3a12f2ff4d2eb05afda6091128ccb1ea74ba7ebb Mon Sep 17 00:00:00 2001 From: Surajshivam-123 Date: Wed, 26 Aug 2026 10:12:31 +0530 Subject: [PATCH 1/4] real-time-Gemini-LLM-streaming-and-typing-indicators-for-bot-debates --- backend/controllers/debatevsbot_controller.go | 68 ++++++++++++++---- backend/routes/debatevsbot.go | 2 +- backend/services/debatevsbot.go | 37 ++++++---- backend/services/gemini.go | 29 ++++++++ frontend/src/Pages/DebateRoom.tsx | 66 +++++++++++++++--- frontend/src/services/vsbot.ts | 69 +++++++++++++++++-- 6 files changed, 228 insertions(+), 43 deletions(-) diff --git a/backend/controllers/debatevsbot_controller.go b/backend/controllers/debatevsbot_controller.go index 95710990..29497dd9 100644 --- a/backend/controllers/debatevsbot_controller.go +++ b/backend/controllers/debatevsbot_controller.go @@ -3,7 +3,9 @@ package controllers import ( "context" "encoding/json" + "fmt" "log" + "net/http" "strings" "time" @@ -119,7 +121,8 @@ func CreateDebate(c *gin.Context) { c.JSON(200, response) } -func SendDebateMessage(c *gin.Context) { + +func SendDebateMessageStream(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { c.JSON(401, gin.H{"error": "Authorization token required"}) @@ -139,10 +142,48 @@ func SendDebateMessage(c *gin.Context) { return } - // Generate bot response with the additional context field. - botResponse := services.GenerateBotResponse(req.BotName, req.BotLevel, req.Topic, req.History, req.Stance, req.Context, 150) + // Set headers for Server-Sent Events + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.Flush() + + flusher, _ := c.Writer.(http.Flusher) + + sendEvent := func(eventType string, data interface{}) { + payload, err := json.Marshal(data) + if err != nil { + return + } + fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, payload) + if flusher != nil { + flusher.Flush() + } + } + + // Stream bot response in real-time + botResponse, err := services.StreamBotResponse( + c.Request.Context(), + req.BotName, + req.BotLevel, + req.Topic, + req.History, + req.Stance, + req.Context, + 150, + func(chunk string) error { + sendEvent("chunk", gin.H{"text": chunk}) + return nil + }, + ) + + if err != nil { + log.Printf("Stream error: %v", err) + } - // Update debate history with the bot's response. + // Update debate history with the bot's response updatedHistory := append(req.History, models.Message{ Sender: "Bot", Text: botResponse, @@ -164,17 +205,18 @@ func SendDebateMessage(c *gin.Context) { debate.ID = primitive.NewObjectID() } if err := db.SaveDebateVsBot(debate); err != nil { + log.Printf("Error saving debate vs bot: %v", err) } - response := DebateMessageResponse{ - DebateId: debate.ID.Hex(), - BotName: req.BotName, - BotLevel: req.BotLevel, - Topic: req.Topic, - Stance: req.Stance, - Response: botResponse, - } - c.JSON(200, response) + // Send completion event + sendEvent("done", gin.H{ + "debateId": debate.ID.Hex(), + "botName": req.BotName, + "botLevel": req.BotLevel, + "topic": req.Topic, + "stance": req.Stance, + "response": botResponse, + }) } func JudgeDebate(c *gin.Context) { diff --git a/backend/routes/debatevsbot.go b/backend/routes/debatevsbot.go index 80888c23..221ed6ab 100644 --- a/backend/routes/debatevsbot.go +++ b/backend/routes/debatevsbot.go @@ -11,7 +11,7 @@ func SetupDebateVsBotRoutes(router *gin.RouterGroup) { vsbot := router.Group("/vsbot") { vsbot.POST("/create", controllers.CreateDebate) - vsbot.POST("/debate", controllers.SendDebateMessage) + vsbot.POST("/debate", controllers.SendDebateMessageStream) vsbot.POST("/judge", controllers.JudgeDebate) vsbot.POST("/concede", controllers.ConcedeDebate) } diff --git a/backend/services/debatevsbot.go b/backend/services/debatevsbot.go index 0446b66e..8dbe499b 100644 --- a/backend/services/debatevsbot.go +++ b/backend/services/debatevsbot.go @@ -265,31 +265,40 @@ Please provide your full argument.`, ) } -// GenerateBotResponse generates a response from the debate bot using the Gemini client library. -// It uses the bot’s personality to handle errors and responses vividly. -func GenerateBotResponse(botName, botLevel, topic string, history []models.Message, stance, extraContext string, maxWords int) string { + +// StreamBotResponse streams the bot's response chunks through onChunk callback in real-time. +// It returns the full accumulated response. +func StreamBotResponse(ctx context.Context, botName, botLevel, topic string, history []models.Message, stance, extraContext string, maxWords int, onChunk func(string) error) (string, error) { if geminiClient == nil { - return personalityErrorResponse(botName, "My systems are offline, it seems.") + errResp := personalityErrorResponse(botName, "My systems are offline, it seems.") + _ = onChunk(errResp) + return errResp, nil } bot := GetBotPersonality(botName) // Construct prompt with enhanced personality integration prompt := constructPrompt(bot, topic, history, stance, extraContext, maxWords) - ctx := context.Background() - response, err := generateDefaultModelText(ctx, prompt) + var fullResponse strings.Builder + err := generateDefaultModelStream(ctx, prompt, func(chunk string) error { + fullResponse.WriteString(chunk) + return onChunk(chunk) + }) + if err != nil { - return personalityErrorResponse(botName, "A glitch in my logic, there is.") - } - if response == "" { - return personalityErrorResponse(botName, "Lost in translation, my thoughts are.") - } - if strings.Contains(strings.ToLower(response), "clarify") { - return personalityClarificationRequest(botName) + if fullResponse.Len() == 0 { + errResp := personalityErrorResponse(botName, "A glitch in my logic, there is.") + _ = onChunk(errResp) + return errResp, nil + } + return fullResponse.String(), err } - return response + + cleaned := cleanModelOutput(fullResponse.String()) + return cleaned, nil } + // personalityErrorResponse returns a personality-specific error message func personalityErrorResponse(botName, defaultMsg string) string { // Dynamically construct error message using bot personality diff --git a/backend/services/gemini.go b/backend/services/gemini.go index 985439d5..7a4e0abe 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -51,3 +51,32 @@ func cleanModelOutput(text string) string { func generateDefaultModelText(ctx context.Context, prompt string) (string, error) { return generateModelText(ctx, defaultGeminiModel, prompt) } + +func generateDefaultModelStream(ctx context.Context, prompt string, onChunk func(string) error) error { + if geminiClient == nil { + return errors.New("gemini client not initialized") + } + + config := &genai.GenerateContentConfig{ + SafetySettings: []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockThresholdBlockNone}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockThresholdBlockNone}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockThresholdBlockNone}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockThresholdBlockNone}, + }, + } + + for resp, err := range geminiClient.Models.GenerateContentStream(ctx, defaultGeminiModel, genai.Text(prompt), config) { + if err != nil { + return err + } + chunkText := resp.Text() + if chunkText != "" { + if err := onChunk(chunkText); err != nil { + return err + } + } + } + return nil +} + diff --git a/frontend/src/Pages/DebateRoom.tsx b/frontend/src/Pages/DebateRoom.tsx index 2e57c2ae..a4d55ac4 100644 --- a/frontend/src/Pages/DebateRoom.tsx +++ b/frontend/src/Pages/DebateRoom.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Button } from "../components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { sendDebateMessage, judgeDebate, concedeDebate } from "@/services/vsbot"; +import { sendDebateMessageStream, judgeDebate, concedeDebate } from "@/services/vsbot"; import JudgmentPopup from "@/components/JudgementPopup"; import { Mic, MicOff } from "lucide-react"; import { useAtom } from "jotai"; @@ -250,6 +250,9 @@ const DebateRoom: React.FC = () => { const [judgmentData, setJudgmentData] = useState(null); const [isRecognizing, setIsRecognizing] = useState(false); const [nextTurnPending, setNextTurnPending] = useState(false); + const [streamingBotText, setStreamingBotText] = useState(""); + const [isBotThinking, setIsBotThinking] = useState(false); + const [isBotStreaming, setIsBotStreaming] = useState(false); const timerRef = useRef(null); const botTurnRef = useRef(false); const messagesEndRef = useRef(null); @@ -418,7 +421,7 @@ const DebateRoom: React.FC = () => { useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [state.messages]); + }, [state.messages, streamingBotText, isBotThinking]); const getPhaseInstructions = (phaseIndex: number) => { switch (phaseIndex) { @@ -526,14 +529,28 @@ const DebateRoom: React.FC = () => { : "Provide your answer"; } - const { response } = await sendDebateMessage({ - botLevel: debateData.botLevel, - topic: debateData.topic, - history: state.messages, - botName: debateData.botName, - stance: state.botStance, - context, - }); + setIsBotThinking(true); + setIsBotStreaming(true); + setStreamingBotText(""); + + const { response } = await sendDebateMessageStream( + { + botLevel: debateData.botLevel, + topic: debateData.topic, + history: state.messages, + botName: debateData.botName, + stance: state.botStance, + context, + }, + (_chunk, accumulated) => { + setIsBotThinking(false); + setStreamingBotText(accumulated); + } + ); + + setIsBotThinking(false); + setIsBotStreaming(false); + setStreamingBotText(""); const botMessage: Message = { sender: "Bot", @@ -554,6 +571,10 @@ const DebateRoom: React.FC = () => { }); } catch (error) { console.error("Bot error:", error); + setIsBotThinking(false); + setIsBotStreaming(false); + setStreamingBotText(""); + // Even on error, advance turn to prevent getting stuck setState((prev) => { const errorMessage: Message = { @@ -676,6 +697,31 @@ setPopup({ show: false, message: "" }); {msg.text} ))} + {sender === "Bot" && (isBotThinking || isBotStreaming) && ( +
+ + {phases[state.currentPhase]?.name || "In Progress"} + + {isBotThinking && ( +
+
+ + + +
+ + {debateData.botName} is typing... + +
+ )} + {isBotStreaming && streamingBotText && ( +
+ {streamingBotText} + +
+ )} +
+ )}
); diff --git a/frontend/src/services/vsbot.ts b/frontend/src/services/vsbot.ts index 0e48f82e..fc1089ed 100644 --- a/frontend/src/services/vsbot.ts +++ b/frontend/src/services/vsbot.ts @@ -79,8 +79,12 @@ export const createDebate = async (data: DebateRequest): Promise }; }; -// Function to send a message in an existing debate -export const sendDebateMessage = async (data: DebateRequest): Promise<{ response: string }> => { + +// Function to send a message and stream the bot response token-by-token +export const sendDebateMessageStream = async ( + data: DebateRequest, + onChunk?: (chunk: string, accumulated: string) => void +): Promise<{ response: string; debateId?: string }> => { const token = getAuthToken(); const response = await fetch(`${baseURL}/vsbot/debate`, { method: "POST", @@ -93,13 +97,68 @@ export const sendDebateMessage = async (data: DebateRequest): Promise<{ response }); if (!response.ok) { - throw new Error("Failed to send debate message"); + throw new Error("Failed to send debate message stream"); } - const result = await response.json(); - return { response: result.response }; // Adjusted to return bot's response directly + if (!response.body) { + throw new Error("ReadableStream not supported on response"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let accumulatedText = ""; + let debateId = ""; + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() || ""; + + for (const block of blocks) { + if (!block.trim()) continue; + const lines = block.split("\n"); + let eventType = "message"; + let dataStr = ""; + + for (const line of lines) { + if (line.startsWith("event:")) { + eventType = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataStr = line.slice(5).trim(); + } + } + + if (dataStr) { + try { + const parsed = JSON.parse(dataStr); + if (eventType === "chunk" && parsed.text) { + accumulatedText += parsed.text; + if (onChunk) { + onChunk(parsed.text, accumulatedText); + } + } else if (eventType === "done") { + if (parsed.response) { + accumulatedText = parsed.response; + } + if (parsed.debateId) { + debateId = parsed.debateId; + } + } + } catch (e) { + console.error("Failed to parse SSE payload:", dataStr, e); + } + } + } + } + + return { response: accumulatedText, debateId }; }; + export const concedeDebate = async (debateId: string, history: DebateMessage[] = []): Promise => { const token = getAuthToken(); const response = await fetch(`${baseURL}/vsbot/concede`, { From a8aab69f0ddb61167730528dd0722f133c4d44f3 Mon Sep 17 00:00:00 2001 From: Surajshivam-123 Date: Wed, 26 Aug 2026 10:48:53 +0530 Subject: [PATCH 2/4] coderabbit changes --- backend/controllers/debatevsbot_controller.go | 2 ++ frontend/src/services/vsbot.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/backend/controllers/debatevsbot_controller.go b/backend/controllers/debatevsbot_controller.go index 29497dd9..2e1e7e1f 100644 --- a/backend/controllers/debatevsbot_controller.go +++ b/backend/controllers/debatevsbot_controller.go @@ -181,6 +181,8 @@ func SendDebateMessageStream(c *gin.Context) { if err != nil { log.Printf("Stream error: %v", err) + sendEvent("error", gin.H{"error": err.Error()}) + return } // Update debate history with the bot's response diff --git a/frontend/src/services/vsbot.ts b/frontend/src/services/vsbot.ts index fc1089ed..87fe72c4 100644 --- a/frontend/src/services/vsbot.ts +++ b/frontend/src/services/vsbot.ts @@ -109,6 +109,7 @@ export const sendDebateMessageStream = async ( let accumulatedText = ""; let debateId = ""; let buffer = ""; + let receivedDone = false; while (true) { const { value, done } = await reader.read(); @@ -132,6 +133,19 @@ export const sendDebateMessageStream = async ( } } + if (eventType === "error") { + let errorMsg = "Stream error"; + if (dataStr) { + try { + const parsed = JSON.parse(dataStr); + errorMsg = parsed.error || parsed.message || dataStr; + } catch { + errorMsg = dataStr; + } + } + throw new Error(errorMsg); + } + if (dataStr) { try { const parsed = JSON.parse(dataStr); @@ -141,6 +155,7 @@ export const sendDebateMessageStream = async ( onChunk(parsed.text, accumulatedText); } } else if (eventType === "done") { + receivedDone = true; if (parsed.response) { accumulatedText = parsed.response; } @@ -155,6 +170,10 @@ export const sendDebateMessageStream = async ( } } + if (!receivedDone) { + throw new Error("Stream ended before receiving done event"); + } + return { response: accumulatedText, debateId }; }; From 17bca9fa6fd3482722d98dcc5b8d297cda20e9c1 Mon Sep 17 00:00:00 2001 From: Surajshivam-123 Date: Wed, 26 Aug 2026 12:05:19 +0530 Subject: [PATCH 3/4] removed bug --- backend/services/debatevsbot.go | 6 +++++- frontend/src/Pages/DebateRoom.tsx | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/services/debatevsbot.go b/backend/services/debatevsbot.go index 8dbe499b..686e80df 100644 --- a/backend/services/debatevsbot.go +++ b/backend/services/debatevsbot.go @@ -185,7 +185,6 @@ Your debating style must strictly adhere to the following guidelines: Your stance is: %s. %s %s -%s Provide an opening statement that embodies your persona and stance. [Your opening argument] %s %s`, @@ -295,6 +294,11 @@ func StreamBotResponse(ctx context.Context, botName, botLevel, topic string, his } cleaned := cleanModelOutput(fullResponse.String()) + if cleaned == "" { + errResp := personalityErrorResponse(botName, "A glitch in my logic, there is.") + _ = onChunk(errResp) + return errResp, nil + } return cleaned, nil } diff --git a/frontend/src/Pages/DebateRoom.tsx b/frontend/src/Pages/DebateRoom.tsx index a4d55ac4..4286e931 100644 --- a/frontend/src/Pages/DebateRoom.tsx +++ b/frontend/src/Pages/DebateRoom.tsx @@ -483,6 +483,7 @@ const DebateRoom: React.FC = () => { }; const handleNextTurn = () => { + if (isBotThinking || isBotStreaming) return; setState((prev) => { advanceTurn(prev); return prev; @@ -824,9 +825,10 @@ setPopup({ show: false, message: "" }); {bot.rating ? `Rating: ${bot.rating}` : "Ready to argue!"} - {nextTurnPending && ( + {nextTurnPending && !isBotThinking && !isBotStreaming && (