From cb59ecf3ac3b38ba883fc74bf810ae9e82e2a469 Mon Sep 17 00:00:00 2001 From: YurenHao0426 Date: Fri, 13 Feb 2026 23:08:05 +0000 Subject: Add LLM Debate mode for multi-round iterative model discussions Implements a debate feature alongside Council mode where 2-6 models engage in multi-round discussions with configurable judge modes (external judge, self-convergence, display-only), debate formats (free discussion, structured opposition, iterative improvement, custom), and early termination conditions. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/Sidebar.tsx | 695 +++++++++++++++++++++++++++++++++++- frontend/src/store/flowStore.ts | 27 ++ 2 files changed, 708 insertions(+), 14 deletions(-) (limited to 'frontend') diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 64cd79a..aeb164b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useReactFlow } from 'reactflow'; import useFlowStore from '../store/flowStore'; import { useAuthStore } from '../store/authStore'; -import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData, CouncilMemberConfig } from '../store/flowStore'; +import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData, CouncilMemberConfig, DebateData, DebateRound } from '../store/flowStore'; import type { Edge } from 'reactflow'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -17,7 +17,7 @@ const preprocessLaTeX = (content: string): string => { .replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => `$${math}$`); }; -import { Play, Settings, Info, ChevronLeft, ChevronRight, ChevronDown, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users } from 'lucide-react'; +import { Play, Settings, Info, ChevronLeft, ChevronRight, ChevronDown, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users, MessageSquare } from 'lucide-react'; interface SidebarProps { isOpen: boolean; @@ -109,6 +109,13 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { const [quickChatCouncilData, setQuickChatCouncilData] = useState(null); const [quickChatCouncilConfigOpen, setQuickChatCouncilConfigOpen] = useState(false); + // Debate mode states + const [debateStage, setDebateStage] = useState(''); + const [debateStreamBuffer, setDebateStreamBuffer] = useState(''); + const [debateTab, setDebateTab] = useState<'final' | 'timeline' | 'per-model'>('timeline'); + const [debateTimelineExpandedRounds, setDebateTimelineExpandedRounds] = useState>(new Set()); + const [debatePerModelSelected, setDebatePerModelSelected] = useState(''); + const selectedNode = nodes.find((n) => n.id === selectedNodeId); // Reset stream buffer and modal states when node changes @@ -640,6 +647,263 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { } }; + // Debate mode: multi-round LLM debate execution + const handleRunDebate = async () => { + if (!selectedNode) return; + const debateModels: CouncilMemberConfig[] = selectedNode.data.debateModels || []; + if (debateModels.length < 2) return; + + const tracesCheck = checkActiveTracesComplete(); + if (!tracesCheck.complete) return; + + const runningNodeId = selectedNode.id; + const runningPrompt = selectedNode.data.userPrompt; + const querySentAt = Date.now(); + const judgeMode = selectedNode.data.debateJudgeMode || 'external_judge'; + const debateFormat = selectedNode.data.debateFormat || 'free_discussion'; + const maxRounds = selectedNode.data.debateMaxRounds || 5; + + updateNodeData(runningNodeId, { + status: 'loading', + response: '', + querySentAt, + debateData: { + rounds: [], + finalVerdict: null, + config: { judgeMode, format: debateFormat, maxRounds }, + }, + }); + setStreamBuffer(''); + setDebateStreamBuffer(''); + setDebateStage('Starting debate...'); + setStreamingNodeId(runningNodeId); + + const context = getActiveContext(runningNodeId); + const projectPath = currentBlueprintPath || 'untitled'; + const traceNodeIds = new Set(); + traceNodeIds.add(runningNodeId); + const visited = new Set(); + const queue = [runningNodeId]; + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + if (visited.has(currentNodeId)) continue; + visited.add(currentNodeId); + const incomingEdges = edges.filter(e => e.target === currentNodeId); + for (const edge of incomingEdges) { + if (!visited.has(edge.source)) { + traceNodeIds.add(edge.source); + queue.push(edge.source); + } + } + } + const scopes = Array.from(traceNodeIds).map(nodeId => `${projectPath}/${nodeId}`); + + const attachedFiles = selectedNode.data.attachedFileIds || []; + const effectivePrompt = runningPrompt?.trim() + ? runningPrompt + : attachedFiles.length > 0 + ? 'Please analyze the attached files.' + : ''; + + try { + const judgeModelConfig = selectedNode.data.judgeModel || debateModels[0]; + const response = await fetch(`/api/run_debate_stream?user=${encodeURIComponent(user?.username || 'test')}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ + node_id: runningNodeId, + incoming_contexts: [{ messages: context }], + user_prompt: effectivePrompt, + debate_models: debateModels.map((cfg) => ({ + model_name: cfg.model, + temperature: cfg.temperature ?? null, + reasoning_effort: cfg.reasoningEffort ?? null, + enable_google_search: cfg.enableWebSearch ?? null, + })), + judge_model: judgeMode === 'external_judge' ? { + model_name: judgeModelConfig.model, + temperature: judgeModelConfig.temperature ?? null, + reasoning_effort: judgeModelConfig.reasoningEffort ?? null, + enable_google_search: judgeModelConfig.enableWebSearch ?? null, + } : null, + judge_mode: judgeMode, + debate_format: debateFormat, + custom_format_prompt: selectedNode.data.debateCustomPrompt || null, + max_rounds: maxRounds, + system_prompt: selectedNode.data.systemPrompt || null, + temperature: selectedNode.data.temperature, + reasoning_effort: selectedNode.data.reasoningEffort || 'medium', + enable_google_search: selectedNode.data.enableGoogleSearch !== false, + merge_strategy: selectedNode.data.mergeStrategy || 'smart', + attached_file_ids: attachedFiles, + scopes, + }), + }); + + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let sseBuffer = ''; + const debateRounds: DebateRound[] = []; + let currentRound = 0; + let currentRoundResponses: Array<{ model: string; response: string }> = []; + let finalModel = ''; + let finalFull = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + sseBuffer += decoder.decode(value, { stream: true }); + + const parts = sseBuffer.split('\n\n'); + sseBuffer = parts.pop() || ''; + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith('data: ')) continue; + let evt: any; + try { + evt = JSON.parse(line.slice(6)); + } catch { continue; } + + switch (evt.type) { + case 'debate_start': + setDebateStage(`Debate started (${evt.data.models.length} models, max ${evt.data.max_rounds} rounds)`); + break; + case 'round_start': + currentRound = evt.data.round; + currentRoundResponses = []; + setDebateStage(`Round ${currentRound}/${maxRounds}: Collecting responses...`); + break; + case 'round_model_complete': + currentRoundResponses = [...currentRoundResponses, { model: evt.data.model, response: evt.data.response }]; + setDebateStage(`Round ${currentRound}/${maxRounds}: ${currentRoundResponses.length}/${debateModels.length} models done`); + break; + case 'round_complete': { + const roundData: DebateRound = { round: evt.data.round, responses: evt.data.responses }; + debateRounds.push(roundData); + updateNodeData(runningNodeId, { + debateData: { + rounds: [...debateRounds], + finalVerdict: null, + config: { judgeMode, format: debateFormat, maxRounds }, + }, + }); + break; + } + case 'judge_decision': { + const lastRound = debateRounds[debateRounds.length - 1]; + if (lastRound) { + lastRound.judgeDecision = { continue: evt.data.continue, reasoning: evt.data.reasoning }; + updateNodeData(runningNodeId, { + debateData: { + rounds: [...debateRounds], + finalVerdict: null, + config: { judgeMode, format: debateFormat, maxRounds }, + }, + }); + } + if (!evt.data.continue) { + setDebateStage('Judge stopped debate. Generating final verdict...'); + } else { + setDebateStage(`Judge: Continue to round ${currentRound + 1}...`); + } + break; + } + case 'convergence_check': { + const lastRound2 = debateRounds[debateRounds.length - 1]; + if (lastRound2) { + lastRound2.converged = evt.data.converged; + updateNodeData(runningNodeId, { + debateData: { + rounds: [...debateRounds], + finalVerdict: null, + config: { judgeMode, format: debateFormat, maxRounds }, + }, + }); + } + if (evt.data.converged) { + setDebateStage('Consensus reached!'); + } + break; + } + case 'final_start': + finalModel = evt.data.model; + setDebateStage('Judge synthesizing final verdict...'); + setDebateStreamBuffer(''); + break; + case 'final_chunk': + finalFull += evt.data.chunk; + setDebateStreamBuffer(finalFull); + setStreamBuffer(finalFull); + break; + case 'final_complete': { + finalModel = evt.data.model; + finalFull = evt.data.response; + const responseReceivedAt = Date.now(); + const debateData: DebateData = { + rounds: debateRounds, + finalVerdict: { model: finalModel, response: finalFull }, + config: { judgeMode, format: debateFormat, maxRounds }, + }; + const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt }; + const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: finalFull }; + updateNodeData(runningNodeId, { + status: 'success', + response: finalFull, + responseReceivedAt, + debateData, + messages: [...context, newUserMsg, newAssistantMsg] as any, + }); + setDebateStage(''); + generateTitle(runningNodeId, runningPrompt, finalFull); + break; + } + case 'debate_complete': { + // If no final verdict (display_only or self_convergence without explicit final_complete) + const currentNode = nodes.find(n => n.id === runningNodeId); + if (currentNode?.data.status === 'loading') { + const responseReceivedAt = Date.now(); + const lastRoundResp = debateRounds.length > 0 ? debateRounds[debateRounds.length - 1].responses : []; + const bestResponse = lastRoundResp.length > 0 + ? lastRoundResp.reduce((a, b) => a.response.length > b.response.length ? a : b).response + : ''; + const debateData: DebateData = { + rounds: debateRounds, + finalVerdict: finalFull ? { model: finalModel, response: finalFull } : null, + config: { judgeMode, format: debateFormat, maxRounds }, + }; + const displayResponse = finalFull || bestResponse; + const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt }; + const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: displayResponse }; + updateNodeData(runningNodeId, { + status: 'success', + response: displayResponse, + responseReceivedAt, + debateData, + messages: [...context, newUserMsg, newAssistantMsg] as any, + }); + setDebateStage(''); + if (displayResponse) generateTitle(runningNodeId, runningPrompt, displayResponse); + } + break; + } + case 'error': + updateNodeData(runningNodeId, { status: 'error' }); + setDebateStage(''); + break; + } + } + } + } catch (error) { + console.error(error); + updateNodeData(runningNodeId, { status: 'error' }); + setDebateStage(''); + } finally { + setStreamingNodeId(prev => prev === runningNodeId ? null : prev); + } + }; + const handleChange = (field: keyof NodeData, value: any) => { updateNodeData(selectedNode.id, { [field]: value }); }; @@ -1844,20 +2108,218 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => {
- +
+ + +
- {!selectedNode.data.councilMode ? ( + {selectedNode.data.debateMode ? ( + /* Debate mode: multi-model selector + judge config */ +
+
+ +
+ {(() => { + const debateModelsList = [ + { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4.5' }, + { value: 'claude-opus-4', label: 'claude-opus-4' }, + { value: 'claude-opus-4-5', label: 'claude-opus-4.5' }, + { value: 'claude-opus-4-6', label: 'claude-opus-4.6' }, + { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash' }, + { value: 'gemini-2.5-flash-lite', label: 'gemini-2.5-flash-lite' }, + { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' }, + { value: 'gpt-4.1', label: 'gpt-4.1' }, + { value: 'gpt-4o', label: 'gpt-4o' }, + { value: 'gpt-5', label: 'gpt-5' }, + { value: 'gpt-5-mini', label: 'gpt-5-mini' }, + { value: 'gpt-5-nano', label: 'gpt-5-nano' }, + { value: 'gpt-5.1', label: 'gpt-5.1' }, + { value: 'gpt-5.2', label: 'gpt-5.2' }, + { value: 'o3', label: 'o3', premium: true }, + ]; + const members: CouncilMemberConfig[] = selectedNode.data.debateModels || []; + const isReasoningModel = (v: string) => ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.2', 'gpt-5.2-pro', 'o3'].includes(v); + const updateDebateMember = (modelName: string, field: string, value: any) => { + const updated = [...members]; + const idx = updated.findIndex(c => c.model === modelName); + if (idx >= 0) { + updated[idx] = { ...updated[idx], [field]: value }; + handleChange('debateModels', updated); + } + }; + return debateModelsList.map(m => { + const selected = members.some(c => c.model === m.value); + const disabled = (m as any).premium && !canUsePremiumModels; + const cfg = members.find(c => c.model === m.value); + return ( +
+ + {selected && cfg && ( +
+ + {isReasoningModel(m.value) && ( + + )} +
+ )} +
+ ); + }); + })()} +
+
+ + {/* Judge Mode */} +
+ + +
+ + {/* Judge Model (only for external_judge) */} + {(selectedNode.data.debateJudgeMode || 'external_judge') === 'external_judge' && ( +
+ + +
+ )} + + {/* Debate Format */} +
+ + +
+ + {/* Custom Prompt (only for custom format) */} + {selectedNode.data.debateFormat === 'custom' && ( +
+ +