diff options
| author | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 23:08:05 +0000 |
|---|---|---|
| committer | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 23:08:05 +0000 |
| commit | cb59ecf3ac3b38ba883fc74bf810ae9e82e2a469 (patch) | |
| tree | d0cab16f3ddb7708528ceb3cbb126d9437aed91b /frontend/src/components | |
| parent | 2adacdbfa1d1049a0497e55f2b3ed00551bf876f (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 695 |
1 files changed, 681 insertions, 14 deletions
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<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const [quickChatCouncilData, setQuickChatCouncilData] = useState<CouncilData | null>(null); const [quickChatCouncilConfigOpen, setQuickChatCouncilConfigOpen] = useState(false); + // Debate mode states + const [debateStage, setDebateStage] = useState<string>(''); + const [debateStreamBuffer, setDebateStreamBuffer] = useState(''); + const [debateTab, setDebateTab] = useState<'final' | 'timeline' | 'per-model'>('timeline'); + const [debateTimelineExpandedRounds, setDebateTimelineExpandedRounds] = useState<Set<number>>(new Set()); + const [debatePerModelSelected, setDebatePerModelSelected] = useState<string>(''); + 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<SidebarProps> = ({ 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<string>(); + traceNodeIds.add(runningNodeId); + const visited = new Set<string>(); + 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<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <div> <div className="flex items-center justify-between mb-1"> <label className="block text-sm font-medium text-gray-700">Model</label> - <button - onClick={() => handleChange('councilMode', !selectedNode.data.councilMode)} - className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${ - selectedNode.data.councilMode - ? 'bg-amber-100 text-amber-700 border border-amber-300 dark:bg-amber-900/50 dark:text-amber-300 dark:border-amber-700' - : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' - }`} - > - <Users size={11} /> - Council {selectedNode.data.councilMode ? 'ON' : 'OFF'} - </button> + <div className="flex gap-1"> + <button + onClick={() => { + const next = !selectedNode.data.councilMode; + handleChange('councilMode', next); + if (next) handleChange('debateMode', false); + }} + className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${ + selectedNode.data.councilMode + ? 'bg-amber-100 text-amber-700 border border-amber-300 dark:bg-amber-900/50 dark:text-amber-300 dark:border-amber-700' + : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' + }`} + > + <Users size={11} /> + Council {selectedNode.data.councilMode ? 'ON' : 'OFF'} + </button> + <button + onClick={() => { + const next = !selectedNode.data.debateMode; + handleChange('debateMode', next); + if (next) handleChange('councilMode', false); + }} + className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${ + selectedNode.data.debateMode + ? 'bg-cyan-100 text-cyan-700 border border-cyan-300 dark:bg-cyan-900/50 dark:text-cyan-300 dark:border-cyan-700' + : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' + }`} + > + <MessageSquare size={11} /> + Debate {selectedNode.data.debateMode ? 'ON' : 'OFF'} + </button> + </div> </div> - {!selectedNode.data.councilMode ? ( + {selectedNode.data.debateMode ? ( + /* Debate mode: multi-model selector + judge config */ + <div className={`space-y-2 p-2 rounded border ${isDark ? 'bg-gray-900 border-cyan-800/50' : 'bg-cyan-50/50 border-cyan-200'}`}> + <div> + <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Debate Participants (pick 2-6)</label> + <div className="grid grid-cols-1 gap-0.5 max-h-40 overflow-y-auto"> + {(() => { + 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 ( + <div key={m.value}> + <label className={`flex items-center gap-1.5 text-xs py-0.5 px-1 rounded cursor-pointer ${ + disabled ? 'opacity-40 cursor-not-allowed' : selected + ? isDark ? 'bg-cyan-900/40 text-cyan-200' : 'bg-cyan-100 text-cyan-800' + : isDark ? 'hover:bg-gray-800 text-gray-300' : 'hover:bg-gray-100 text-gray-700' + }`}> + <input + type="checkbox" + checked={selected} + disabled={disabled} + onChange={() => { + const next = selected + ? members.filter(c => c.model !== m.value) + : [...members, { model: m.value }]; + handleChange('debateModels', next); + }} + className="w-3 h-3 accent-cyan-500" + /> + {m.label} + </label> + {selected && cfg && ( + <div className={`flex items-center gap-2 ml-5 mt-0.5 mb-1 px-1 py-0.5 text-xs rounded ${isDark ? 'bg-gray-800/60' : 'bg-gray-100/80'}`}> + <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + T: + <input + type="number" + min={0} max={2} step={0.1} + value={cfg.temperature ?? selectedNode.data.temperature} + disabled={isReasoningModel(m.value)} + onChange={(e) => updateDebateMember(m.value, 'temperature', parseFloat(e.target.value) || 0)} + className={`w-12 border rounded px-1 py-0 text-xs ${isReasoningModel(m.value) ? 'opacity-50 cursor-not-allowed' : ''} ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`} + /> + </label> + {isReasoningModel(m.value) && ( + <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + Effort: + <select + value={cfg.reasoningEffort ?? selectedNode.data.reasoningEffort ?? 'medium'} + onChange={(e) => updateDebateMember(m.value, 'reasoningEffort', e.target.value)} + className={`border rounded px-1 py-0 text-xs ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`} + > + <option value="low">Low</option> + <option value="medium">Med</option> + <option value="high">High</option> + </select> + </label> + )} + </div> + )} + </div> + ); + }); + })()} + </div> + </div> + + {/* Judge Mode */} + <div> + <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Judge Mode</label> + <select + value={selectedNode.data.debateJudgeMode || 'external_judge'} + onChange={(e) => handleChange('debateJudgeMode', e.target.value)} + className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`} + > + <option value="external_judge">External Judge</option> + <option value="self_convergence">Self-Convergence</option> + <option value="display_only">Display Only</option> + </select> + </div> + + {/* Judge Model (only for external_judge) */} + {(selectedNode.data.debateJudgeMode || 'external_judge') === 'external_judge' && ( + <div> + <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Judge Model</label> + <select + value={selectedNode.data.judgeModel?.model || (selectedNode.data.debateModels || [])[0]?.model || ''} + onChange={(e) => { + const existing = (selectedNode.data.debateModels || []).find((c: CouncilMemberConfig) => c.model === e.target.value); + handleChange('judgeModel', existing ? { ...existing } : { model: e.target.value }); + }} + className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`} + > + {[ + { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4.5' }, + { value: 'claude-opus-4-6', label: 'claude-opus-4.6' }, + { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash' }, + { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' }, + { value: 'gpt-5', label: 'gpt-5' }, + { value: 'gpt-5.1', label: 'gpt-5.1' }, + { value: 'gpt-5.2', label: 'gpt-5.2' }, + ].map(m => ( + <option key={m.value} value={m.value}>{m.label}</option> + ))} + </select> + </div> + )} + + {/* Debate Format */} + <div> + <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Debate Format</label> + <select + value={selectedNode.data.debateFormat || 'free_discussion'} + onChange={(e) => handleChange('debateFormat', e.target.value)} + className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`} + > + <option value="free_discussion">Free Discussion</option> + <option value="structured_opposition">Structured Opposition</option> + <option value="iterative_improvement">Iterative Improvement</option> + <option value="custom">Custom Prompt</option> + </select> + </div> + + {/* Custom Prompt (only for custom format) */} + {selectedNode.data.debateFormat === 'custom' && ( + <div> + <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Custom Prompt</label> + <textarea + value={selectedNode.data.debateCustomPrompt || ''} + onChange={(e) => handleChange('debateCustomPrompt', e.target.value)} + placeholder="Use {history}, {round}, {model_name}, {question} as placeholders" + rows={3} + className={`w-full border rounded-md p-1.5 text-xs resize-y ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500' : 'bg-white border-gray-300 placeholder-gray-400'}`} + /> + </div> + )} + + {/* Max Rounds */} + <div> + <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Max Rounds</label> + <input + type="number" + min={1} max={10} + value={selectedNode.data.debateMaxRounds || 5} + onChange={(e) => handleChange('debateMaxRounds', Math.max(1, Math.min(10, parseInt(e.target.value) || 5)))} + className={`w-20 border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`} + /> + </div> + </div> + ) : !selectedNode.data.councilMode ? ( /* Single model selector */ <select value={selectedNode.data.model} @@ -2500,6 +2962,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { Inspired by <a href="https://github.com/karpathy/llm-council" target="_blank" rel="noopener noreferrer" className={`underline decoration-dotted ${isDark ? 'text-gray-500 hover:text-gray-400' : 'text-gray-500 hover:text-gray-600'}`}>karpathy/llm-council</a> </div> </> + ) : selectedNode.data.debateMode ? ( + <button + onClick={handleRunDebate} + disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.debateModels || []).length < 2} + className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${ + selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.debateModels || []).length < 2 + ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400' + : 'bg-cyan-600 text-white hover:bg-cyan-700' + }`} + > + {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <MessageSquare size={16} />} + {selectedNode.data.status === 'loading' && debateStage ? debateStage : `Run Debate (${(selectedNode.data.debateModels || []).length})`} + </button> ) : ( <button onClick={handleRun} @@ -2713,6 +3188,198 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { )} </div> + ) : selectedNode.data.debateMode && selectedNode.data.debateData && (selectedNode.data.debateData.rounds.length > 0 || selectedNode.data.status === 'loading') ? ( + <div> + {/* Debate tab bar */} + <div className={`flex gap-0.5 mb-2 text-xs border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + {(selectedNode.data.debateData.config.judgeMode !== 'display_only' + ? (['final', 'timeline', 'per-model'] as const) + : (['timeline', 'per-model'] as const) + ).map(tab => ( + <button + key={tab} + onClick={() => setDebateTab(tab)} + className={`px-3 py-1.5 rounded-t transition-colors capitalize ${ + debateTab === tab + ? isDark ? 'bg-gray-800 text-cyan-300 border-b-2 border-cyan-400' : 'bg-white text-cyan-700 border-b-2 border-cyan-500' + : isDark ? 'text-gray-500 hover:text-gray-300' : 'text-gray-400 hover:text-gray-600' + }`} + > + {tab === 'final' ? 'Final Answer' : tab === 'timeline' ? 'Timeline' : 'Per-Model'} + </button> + ))} + </div> + + {/* Final Answer tab */} + {debateTab === 'final' && ( + <div> + {selectedNode.data.debateData.finalVerdict ? ( + <> + <div className={`text-xs mb-1 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>Judge: {selectedNode.data.debateData.finalVerdict.model}</div> + <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${ + isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900' + }`}> + {rawTextMode ? ( + <pre className="whitespace-pre-wrap break-words">{selectedNode.data.debateData.finalVerdict.response}</pre> + ) : ( + <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}> + {preprocessLaTeX(selectedNode.data.debateData.finalVerdict.response)} + </ReactMarkdown> + )} + </div> + </> + ) : selectedNode.data.status === 'loading' ? ( + <div className={`p-3 rounded-md border min-h-[150px] text-sm ${ + isDark ? 'bg-gray-900 border-gray-700' : 'bg-gray-50 border-gray-200' + }`}> + {debateStreamBuffer ? ( + rawTextMode ? ( + <pre className="whitespace-pre-wrap break-words">{debateStreamBuffer}</pre> + ) : ( + <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}> + {preprocessLaTeX(debateStreamBuffer)} + </ReactMarkdown> + ) + ) : ( + <div className={`flex items-center gap-2 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}> + <Loader2 className="animate-spin" size={14} /> Waiting for final verdict... + </div> + )} + </div> + ) : ( + <div className={`p-3 rounded border min-h-[100px] flex items-center justify-center text-sm ${isDark ? 'bg-gray-900 border-gray-700 text-gray-500' : 'bg-gray-50 border-gray-200 text-gray-400'}`}> + No final verdict + </div> + )} + </div> + )} + + {/* Debate Timeline tab */} + {debateTab === 'timeline' && ( + <div className="space-y-2 max-h-[500px] overflow-y-auto"> + {selectedNode.data.debateData.rounds.map((round) => { + const isExpanded = debateTimelineExpandedRounds.has(round.round); + return ( + <div key={round.round} className={`rounded border ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <button + onClick={() => { + const next = new Set(debateTimelineExpandedRounds); + if (isExpanded) next.delete(round.round); else next.add(round.round); + setDebateTimelineExpandedRounds(next); + }} + className={`w-full flex items-center justify-between px-3 py-2 text-xs font-semibold ${ + isDark ? 'bg-gray-800 text-gray-300 hover:bg-gray-750' : 'bg-gray-50 text-gray-700 hover:bg-gray-100' + }`} + > + <span>Round {round.round} ({round.responses.length} responses)</span> + <div className="flex items-center gap-2"> + {round.judgeDecision && ( + <span className={`px-1.5 py-0.5 rounded text-[10px] ${ + round.judgeDecision.continue + ? isDark ? 'bg-green-900/50 text-green-300' : 'bg-green-100 text-green-700' + : isDark ? 'bg-red-900/50 text-red-300' : 'bg-red-100 text-red-700' + }`}> + {round.judgeDecision.continue ? 'Continue' : 'Stop'} + </span> + )} + {round.converged !== undefined && ( + <span className={`px-1.5 py-0.5 rounded text-[10px] ${ + round.converged + ? isDark ? 'bg-green-900/50 text-green-300' : 'bg-green-100 text-green-700' + : isDark ? 'bg-yellow-900/50 text-yellow-300' : 'bg-yellow-100 text-yellow-700' + }`}> + {round.converged ? 'Converged' : 'Divergent'} + </span> + )} + <ChevronDown size={12} className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} /> + </div> + </button> + {isExpanded && ( + <div className={`p-2 space-y-2 ${isDark ? 'bg-gray-900' : 'bg-white'}`}> + {round.responses.map((resp, ri) => ( + <div key={ri} className={`rounded border p-2 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <div className={`text-xs font-semibold mb-1 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}>{resp.model}</div> + <div className={`text-xs prose prose-sm max-w-none ${isDark ? 'prose-invert text-gray-300' : 'text-gray-700'}`}> + {rawTextMode ? ( + <pre className="whitespace-pre-wrap break-words">{resp.response}</pre> + ) : ( + <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}> + {preprocessLaTeX(resp.response)} + </ReactMarkdown> + )} + </div> + </div> + ))} + {round.judgeDecision && ( + <div className={`rounded border p-2 text-xs ${isDark ? 'border-amber-800/50 bg-amber-900/20 text-amber-200' : 'border-amber-200 bg-amber-50 text-amber-800'}`}> + <div className="font-semibold mb-1">Judge Decision</div> + <div className="whitespace-pre-wrap">{round.judgeDecision.reasoning}</div> + </div> + )} + </div> + )} + </div> + ); + })} + {selectedNode.data.status === 'loading' && debateStage && ( + <div className={`flex items-center gap-2 p-2 text-xs ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}> + <Loader2 className="animate-spin" size={12} /> {debateStage} + </div> + )} + </div> + )} + + {/* Per-Model View tab */} + {debateTab === 'per-model' && ( + <div> + {(() => { + const allModels = new Set<string>(); + selectedNode.data.debateData!.rounds.forEach(r => r.responses.forEach(resp => allModels.add(resp.model))); + const modelList = Array.from(allModels); + const selectedModel = debatePerModelSelected || modelList[0] || ''; + return ( + <> + <div className={`flex gap-0.5 mb-2 flex-wrap`}> + {modelList.map(model => ( + <button + key={model} + onClick={() => setDebatePerModelSelected(model)} + className={`px-2 py-0.5 text-xs rounded transition-colors ${ + (debatePerModelSelected || modelList[0]) === model + ? isDark ? 'bg-cyan-900/50 text-cyan-300' : 'bg-cyan-100 text-cyan-700' + : isDark ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' + }`} + > + {model} + </button> + ))} + </div> + <div className="space-y-2 max-h-[400px] overflow-y-auto"> + {selectedNode.data.debateData!.rounds.map(round => { + const resp = round.responses.find(r => r.model === selectedModel); + if (!resp) return null; + return ( + <div key={round.round} className={`rounded border p-2 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <div className={`text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Round {round.round}</div> + <div className={`text-xs prose prose-sm max-w-none ${isDark ? 'prose-invert text-gray-300' : 'text-gray-700'}`}> + {rawTextMode ? ( + <pre className="whitespace-pre-wrap break-words">{resp.response}</pre> + ) : ( + <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}> + {preprocessLaTeX(resp.response)} + </ReactMarkdown> + )} + </div> + </div> + ); + })} + </div> + </> + ); + })()} + </div> + )} + </div> ) : isEditing ? ( <div className="space-y-2"> <textarea |
