From 77be59bc0a6353e98846b9c9bfa2d566efea8b1f Mon Sep 17 00:00:00 2001 From: YurenHao0426 Date: Fri, 13 Feb 2026 21:43:34 +0000 Subject: Add LLM Council mode for multi-model consensus 3-stage council orchestration: parallel model queries (Stage 1), anonymous peer ranking (Stage 2), and streamed chairman synthesis (Stage 3). Includes scope-aware file resolution for Google/Claude providers so upstream file attachments are visible to all providers. - Backend: council.py orchestrator, /api/run_council_stream endpoint, query_model_full() non-streaming wrapper, resolve_provider() helper, resolve_scoped_file_ids() for Google/Claude scope parity with OpenAI - Frontend: council toggle UI, model checkbox selector, chairman picker, SSE event parsing, tabbed Stage 1/2/3 response display - Canvas: amber council node indicator with Users icon Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/Sidebar.tsx | 555 ++++++++++++++++++++++++++---- frontend/src/components/nodes/LLMNode.tsx | 36 +- 2 files changed, 512 insertions(+), 79 deletions(-) (limited to 'frontend/src/components') diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ac48c6f..4d7bb51 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 } from '../store/flowStore'; +import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData } 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, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck } from 'lucide-react'; +import { Play, Settings, Info, ChevronLeft, ChevronRight, 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'; interface SidebarProps { isOpen: boolean; @@ -93,6 +93,13 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { const [showMergePreview, setShowMergePreview] = useState(false); const [isSummarizingMerge, setIsSummarizingMerge] = useState(false); + // Council mode states + const [councilStage, setCouncilStage] = useState(''); + const [councilStreamBuffer, setCouncilStreamBuffer] = useState(''); + const [councilTab, setCouncilTab] = useState<'final' | 'responses' | 'rankings'>('final'); + const [councilResponseTab, setCouncilResponseTab] = useState(0); + const [councilRankingTab, setCouncilRankingTab] = useState<'aggregate' | number>('aggregate'); + const selectedNode = nodes.find((n) => n.id === selectedNodeId); // Reset stream buffer and modal states when node changes @@ -414,6 +421,178 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { } }; + // Council mode: 3-stage LLM council execution + const handleRunCouncil = async () => { + if (!selectedNode) return; + const councilModels = selectedNode.data.councilModels || []; + const chairmanModel = selectedNode.data.chairmanModel || councilModels[0]; + if (councilModels.length < 2) return; + + const tracesCheck = checkActiveTracesComplete(); + if (!tracesCheck.complete) return; + + const runningNodeId = selectedNode.id; + const runningPrompt = selectedNode.data.userPrompt; + const querySentAt = Date.now(); + + updateNodeData(runningNodeId, { + status: 'loading', + response: '', + querySentAt, + councilData: { stage1: null, stage2: null, stage3: null }, + }); + setStreamBuffer(''); + setCouncilStreamBuffer(''); + setCouncilStage('Starting council...'); + 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 response = await fetch(`/api/run_council_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, + council_models: councilModels.map((m: string) => ({ model_name: m })), + chairman_model: chairmanModel, + system_prompt: selectedNode.data.systemPrompt || null, + temperature: selectedNode.data.temperature, + reasoning_effort: selectedNode.data.reasoningEffort || 'medium', + 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 = ''; + let stage1Results: Array<{ model: string; response: string }> = []; + let stage2Data: any = null; + let stage3Full = ''; + let stage3Model = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + sseBuffer += decoder.decode(value, { stream: true }); + + // Parse SSE events (data: {...}\n\n) + 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 'stage1_start': + setCouncilStage('Stage 1: Collecting responses...'); + break; + case 'stage1_model_complete': + stage1Results = [...stage1Results, evt.data]; + setCouncilStage(`Stage 1: ${stage1Results.length}/${councilModels.length} models done`); + updateNodeData(runningNodeId, { + councilData: { stage1: [...stage1Results], stage2: null, stage3: null }, + }); + break; + case 'stage1_complete': + stage1Results = evt.data; + updateNodeData(runningNodeId, { + councilData: { stage1: stage1Results, stage2: null, stage3: null }, + }); + break; + case 'stage2_start': + setCouncilStage('Stage 2: Peer ranking...'); + break; + case 'stage2_complete': + stage2Data = evt.data; + updateNodeData(runningNodeId, { + councilData: { stage1: stage1Results, stage2: stage2Data, stage3: null }, + }); + break; + case 'stage3_start': + setCouncilStage('Stage 3: Chairman synthesizing...'); + setCouncilStreamBuffer(''); + break; + case 'stage3_chunk': + stage3Full += evt.data.chunk; + setCouncilStreamBuffer(stage3Full); + setStreamBuffer(stage3Full); + break; + case 'stage3_complete': + stage3Model = evt.data.model; + stage3Full = evt.data.response; + break; + case 'complete': { + const responseReceivedAt = Date.now(); + const councilData: CouncilData = { + stage1: stage1Results, + stage2: stage2Data, + stage3: { model: stage3Model, response: stage3Full }, + }; + const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt }; + const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: stage3Full }; + updateNodeData(runningNodeId, { + status: 'success', + response: stage3Full, + responseReceivedAt, + councilData, + messages: [...context, newUserMsg, newAssistantMsg] as any, + }); + setCouncilStage(''); + generateTitle(runningNodeId, runningPrompt, stage3Full); + break; + } + case 'error': + updateNodeData(runningNodeId, { status: 'error' }); + setCouncilStage(''); + break; + } + } + } + } catch (error) { + console.error(error); + updateNodeData(runningNodeId, { status: 'error' }); + setCouncilStage(''); + } finally { + setStreamingNodeId(prev => prev === runningNodeId ? null : prev); + } + }; + const handleChange = (field: keyof NodeData, value: any) => { updateNodeData(selectedNode.id, { [field]: value }); }; @@ -1451,55 +1630,136 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { {activeTab === 'interact' && (
- - +
+ + +
+ + {!selectedNode.data.councilMode ? ( + /* Single model selector */ + + ) : ( + /* Council mode: multi-model selector + chairman */ +
+
+ +
+ {[ + { 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 }, + ].map(m => { + const selected = (selectedNode.data.councilModels || []).includes(m.value); + const disabled = m.premium && !canUsePremiumModels; + return ( + + ); + })} +
+
+
+ + +
+
+ )}
{/* Trace Selector - Single Select */} @@ -1837,18 +2097,33 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => {
)} - + {selectedNode.data.councilMode ? ( + + ) : ( + + )}
@@ -1858,7 +2133,13 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { <>
- - {isEditing ? ( + + {/* Council tabbed response view */} + {selectedNode.data.councilMode && selectedNode.data.councilData && (selectedNode.data.councilData.stage1 || selectedNode.data.status === 'loading') ? ( +
+ {/* Council tab bar */} +
+ {(['final', 'responses', 'rankings'] as const).map(tab => ( + + ))} +
+ + {/* Final Answer tab (Stage 3) */} + {councilTab === 'final' && ( +
+ {selectedNode.data.councilData.stage3 ? ( +
Chairman: {selectedNode.data.councilData.stage3.model}
+ ) : selectedNode.data.status === 'loading' && councilStage.includes('Stage 3') ? ( +
Synthesizing...
+ ) : null} +
+ {rawTextMode ? ( +
{selectedNode.data.councilData.stage3?.response || councilStreamBuffer || ''}
+ ) : ( + + {preprocessLaTeX(selectedNode.data.councilData.stage3?.response || councilStreamBuffer || '')} + + )} +
+
+ )} + + {/* Individual Responses tab (Stage 1) */} + {councilTab === 'responses' && selectedNode.data.councilData.stage1 && ( +
+
+ {selectedNode.data.councilData.stage1.map((r, i) => ( + + ))} +
+ {(() => { + const r = selectedNode.data.councilData.stage1![councilResponseTab]; + if (!r) return null; + return ( +
+ {rawTextMode ? ( +
{r.response}
+ ) : ( + {preprocessLaTeX(r.response)} + )} +
+ ); + })()} +
+ )} + + {/* Rankings tab (Stage 2) */} + {councilTab === 'rankings' && ( +
+ {selectedNode.data.councilData.stage2 ? ( +
+ {/* Aggregate Rankings */} +
+
Aggregate Rankings
+ {selectedNode.data.councilData.stage2.aggregate_rankings.map((r, i) => ( +
+ #{i + 1} {r.model} + avg: {r.average_rank} ({r.rankings_count} votes) +
+ ))} +
+ {/* Individual ranker evaluations */} +
Individual Evaluations
+
+ + {selectedNode.data.councilData.stage2.rankings.map((r, i) => ( + + ))} +
+ {councilRankingTab !== 'aggregate' && (() => { + const r = selectedNode.data.councilData.stage2!.rankings[councilRankingTab as number]; + if (!r) return null; + // De-anonymize the ranking text + let text = r.ranking; + const mapping = selectedNode.data.councilData.stage2!.label_to_model; + for (const [label, model] of Object.entries(mapping)) { + text = text.replaceAll(label, `${label} [${model}]`); + } + return ( +
+ {preprocessLaTeX(text)} +
+ ); + })()} +
+ ) : selectedNode.data.status === 'loading' ? ( +
+ Waiting for rankings... +
+ ) : ( +
+ No ranking data +
+ )} +
+ )} +
+ ) : isEditing ? (