diff options
| author | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 22:46:06 +0000 |
|---|---|---|
| committer | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 22:46:06 +0000 |
| commit | 2adacdbfa1d1049a0497e55f2b3ed00551bf876f (patch) | |
| tree | 7bb712d5d85e42aff8b7afe5da56a496ca82d9bd /frontend/src | |
| parent | 77be59bc0a6353e98846b9c9bfa2d566efea8b1f (diff) | |
Add per-model council settings, Quick Chat council mode, and per-member trace selection
Council members now support individual temperature, reasoning effort, web search, and
context trace overrides. Quick Chat inherits council config from the source node and
streams through the 3-stage council pipeline. Blueprint loading migrates old string[]
council formats to CouncilMemberConfig[].
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 967 | ||||
| -rw-r--r-- | frontend/src/store/flowStore.ts | 24 |
2 files changed, 752 insertions, 239 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4d7bb51..64cd79a 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 } from '../store/flowStore'; +import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData, CouncilMemberConfig } 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, 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 } from 'lucide-react'; interface SidebarProps { isOpen: boolean; @@ -96,10 +96,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { // Council mode states const [councilStage, setCouncilStage] = useState<string>(''); const [councilStreamBuffer, setCouncilStreamBuffer] = useState(''); + const [openCtxDropdown, setOpenCtxDropdown] = useState<string | null>(null); const [councilTab, setCouncilTab] = useState<'final' | 'responses' | 'rankings'>('final'); const [councilResponseTab, setCouncilResponseTab] = useState(0); const [councilRankingTab, setCouncilRankingTab] = useState<'aggregate' | number>('aggregate'); + // Quick Chat council mode states + const [quickChatCouncilMode, setQuickChatCouncilMode] = useState(false); + const [quickChatCouncilModels, setQuickChatCouncilModels] = useState<CouncilMemberConfig[]>([]); + const [quickChatChairmanModel, setQuickChatChairmanModel] = useState<CouncilMemberConfig | null>(null); + const [quickChatCouncilStage, setQuickChatCouncilStage] = useState(''); + const [quickChatCouncilData, setQuickChatCouncilData] = useState<CouncilData | null>(null); + const [quickChatCouncilConfigOpen, setQuickChatCouncilConfigOpen] = useState(false); + const selectedNode = nodes.find((n) => n.id === selectedNodeId); // Reset stream buffer and modal states when node changes @@ -424,8 +433,8 @@ const Sidebar: React.FC<SidebarProps> = ({ 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]; + const councilModels: CouncilMemberConfig[] = selectedNode.data.councilModels || []; + const chairmanConfig: CouncilMemberConfig = selectedNode.data.chairmanModel || councilModels[0]; if (councilModels.length < 2) return; const tracesCheck = checkActiveTracesComplete(); @@ -473,6 +482,27 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { ? 'Please analyze the attached files.' : ''; + // Resolve context messages for a specific trace ID + const resolveTraceContext = (traceId: string): Message[] => { + const node = nodes.find(n => n.id === runningNodeId); + if (!node) return []; + // Search incoming traces + let trace: Trace | undefined = (node.data.traces || []).find((t: Trace) => t.id === traceId); + // Then outgoing traces + if (!trace) trace = (node.data.outgoingTraces || []).find((t: Trace) => t.id === traceId); + if (trace) { + const nodePrefix = `${runningNodeId}-`; + const isOriginated = trace.id === `trace-${runningNodeId}` || + trace.id.startsWith('fork-') || + (trace.id.startsWith('prepend-') && trace.id.includes(`-from-${runningNodeId}`)); + return isOriginated ? trace.messages.filter(m => !m.id?.startsWith(nodePrefix)) : [...trace.messages]; + } + // Check merged traces + const merged = (node.data.mergedTraces || []).find((m: MergedTrace) => m.id === traceId); + if (merged) return [...merged.messages]; + return []; + }; + try { const response = await fetch(`/api/run_council_stream?user=${encodeURIComponent(user?.username || 'test')}`, { method: 'POST', @@ -481,11 +511,28 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { node_id: runningNodeId, incoming_contexts: [{ messages: context }], user_prompt: effectivePrompt, - council_models: councilModels.map((m: string) => ({ model_name: m })), - chairman_model: chairmanModel, + council_models: councilModels.map((cfg) => { + const base: Record<string, any> = { + model_name: cfg.model, + temperature: cfg.temperature ?? null, + reasoning_effort: cfg.reasoningEffort ?? null, + enable_google_search: cfg.enableWebSearch ?? null, + }; + if (cfg.traceId) { + base.incoming_contexts = [{ messages: resolveTraceContext(cfg.traceId) }]; + } + return base; + }), + chairman_model: { + model_name: chairmanConfig.model, + temperature: chairmanConfig.temperature ?? null, + reasoning_effort: chairmanConfig.reasoningEffort ?? null, + enable_google_search: chairmanConfig.enableWebSearch ?? null, + }, 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, @@ -856,7 +903,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { // handleQuickChatSend will decide whether to overwrite (if empty) or create new node (if has response) setQuickChatLastNodeId(selectedNode.id); } - + + // Copy council settings from node if active + if (selectedNode.data.councilMode && selectedNode.data.councilModels && selectedNode.data.councilModels.length >= 2) { + setQuickChatCouncilMode(true); + setQuickChatCouncilModels([...selectedNode.data.councilModels]); + setQuickChatChairmanModel(selectedNode.data.chairmanModel || selectedNode.data.councilModels[0]); + setQuickChatCouncilConfigOpen(false); + } else { + setQuickChatCouncilMode(false); + } + setQuickChatCouncilData(null); + setQuickChatCouncilStage(''); + setQuickChatOpen(true); // If there's an unsent draft, put it in the input box setQuickChatInput(hasDraftPrompt ? selectedNode.data.userPrompt : ''); @@ -868,6 +927,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { setQuickChatMessages([]); setQuickChatAttachedFiles([]); setQuickChatSentFiles([]); + setQuickChatCouncilMode(false); + setQuickChatCouncilData(null); + setQuickChatCouncilStage(''); }; // Quick Chat file attachment helpers @@ -1221,69 +1283,201 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const tempAtSend = quickChatTemp; const effortAtSend = quickChatEffort; const webSearchAtSend = quickChatWebSearch; + const reasoningModels = ['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'gpt-5.2', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'o3']; - try { - // Determine provider - const isClaude = modelAtSend.includes('claude'); - const isOpenAI = modelAtSend.includes('gpt') || modelAtSend === 'o3'; - const provider = isClaude ? 'claude' : isOpenAI ? 'openai' : 'google'; - const reasoningModels = ['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'gpt-5.2', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'o3']; - const isReasoning = reasoningModels.includes(modelAtSend); + // Snapshot council config at send time + const isCouncilSend = quickChatCouncilMode && quickChatCouncilModels.length >= 2; + const councilModelsAtSend = isCouncilSend ? [...quickChatCouncilModels] : []; + const chairmanAtSend = isCouncilSend ? (quickChatChairmanModel || quickChatCouncilModels[0]) : null; + try { // Build scopes for file search (Quick Chat uses a temp scope) const projectPath = currentBlueprintPath || 'untitled'; const scopes = [`${projectPath}/quick_chat_temp`]; - // Call LLM API with current messages as context - const response = await fetch(`/api/run_node_stream?user=${encodeURIComponent(user?.username || 'test')}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, - body: JSON.stringify({ - node_id: 'quick_chat_temp', - incoming_contexts: [{ messages: messagesBeforeSend }], - user_prompt: userInput || 'Please analyze the attached files.', - attached_file_ids: attachedFilesCopy, - scopes, - merge_strategy: 'smart', - config: { - provider, - model_name: modelAtSend, - temperature: isReasoning ? 1 : tempAtSend, - enable_google_search: webSearchAtSend, - reasoning_effort: effortAtSend, - } - }) - }); + let fullResponse = ''; - if (!response.ok) { - const errText = await response.text(); - throw new Error(errText || `HTTP ${response.status}`); - } - if (!response.body) throw new Error('No response body'); + if (isCouncilSend && chairmanAtSend) { + // ========== COUNCIL MODE ========== + setQuickChatCouncilStage('Starting council...'); + setQuickChatCouncilData({ stage1: null, stage2: null, stage3: null }); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let fullResponse = ''; + 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: 'quick_chat_temp', + incoming_contexts: [{ messages: messagesBeforeSend }], + user_prompt: userInput || 'Please analyze the attached files.', + council_models: councilModelsAtSend.map(cfg => ({ + model_name: cfg.model, + temperature: cfg.temperature ?? null, + reasoning_effort: cfg.reasoningEffort ?? null, + enable_google_search: cfg.enableWebSearch ?? null, + })), + chairman_model: { + model_name: chairmanAtSend.model, + temperature: chairmanAtSend.temperature ?? null, + reasoning_effort: chairmanAtSend.reasoningEffort ?? null, + enable_google_search: chairmanAtSend.enableWebSearch ?? null, + }, + 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: 'smart', + attached_file_ids: attachedFilesCopy, + scopes, + }), + }); - // Stream response - while (true) { - const { value, done } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value); - fullResponse += chunk; - - // Update display in real-time - setQuickChatMessages(prev => { - const newMsgs = [...prev]; - const lastMsg = newMsgs[newMsgs.length - 1]; - if (lastMsg?.role === 'assistant') { - // Update existing assistant message - return [...newMsgs.slice(0, -1), { ...lastMsg, content: fullResponse }]; - } else { - // Add new assistant message - return [...newMsgs, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]; + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || `HTTP ${response.status}`); + } + if (!response.body) throw new Error('No response body'); + + 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 }); + 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': + setQuickChatCouncilStage('Stage 1: Collecting responses...'); + break; + case 'stage1_model_complete': + stage1Results = [...stage1Results, evt.data]; + setQuickChatCouncilStage(`Stage 1: ${stage1Results.length}/${councilModelsAtSend.length} models done`); + setQuickChatCouncilData({ stage1: [...stage1Results], stage2: null, stage3: null }); + break; + case 'stage1_complete': + stage1Results = evt.data; + setQuickChatCouncilData({ stage1: stage1Results, stage2: null, stage3: null }); + break; + case 'stage2_start': + setQuickChatCouncilStage('Stage 2: Peer ranking...'); + break; + case 'stage2_complete': + stage2Data = evt.data; + setQuickChatCouncilData({ stage1: stage1Results, stage2: stage2Data, stage3: null }); + break; + case 'stage3_start': + setQuickChatCouncilStage('Stage 3: Chairman synthesizing...'); + break; + case 'stage3_chunk': + stage3Full += evt.data.chunk; + // Stream chairman response into chat + setQuickChatMessages(prev => { + const newMsgs = [...prev]; + const lastMsg = newMsgs[newMsgs.length - 1]; + if (lastMsg?.role === 'assistant') { + return [...newMsgs.slice(0, -1), { ...lastMsg, content: stage3Full }]; + } else { + return [...newMsgs, { id: `qc_${Date.now()}_a`, role: 'assistant', content: stage3Full }]; + } + }); + break; + case 'stage3_complete': + stage3Model = evt.data.model; + stage3Full = evt.data.response; + break; + case 'complete': { + const finalData: CouncilData = { + stage1: stage1Results, + stage2: stage2Data, + stage3: { model: stage3Model, response: stage3Full }, + }; + setQuickChatCouncilData(finalData); + setQuickChatCouncilStage(''); + // Ensure final message is set + setQuickChatMessages(prev => { + const newMsgs = [...prev]; + const lastMsg = newMsgs[newMsgs.length - 1]; + if (lastMsg?.role === 'assistant') { + return [...newMsgs.slice(0, -1), { ...lastMsg, content: stage3Full }]; + } else { + return [...newMsgs, { id: `qc_${Date.now()}_a`, role: 'assistant', content: stage3Full }]; + } + }); + break; + } + case 'error': + setQuickChatCouncilStage(''); + throw new Error(evt.data?.message || 'Council error'); + } } + } + fullResponse = stage3Full; + } else { + // ========== SINGLE MODEL MODE ========== + // Determine provider + const isClaude = modelAtSend.includes('claude'); + const isOpenAI = modelAtSend.includes('gpt') || modelAtSend === 'o3'; + const provider = isClaude ? 'claude' : isOpenAI ? 'openai' : 'google'; + const isReasoning = reasoningModels.includes(modelAtSend); + + const response = await fetch(`/api/run_node_stream?user=${encodeURIComponent(user?.username || 'test')}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ + node_id: 'quick_chat_temp', + incoming_contexts: [{ messages: messagesBeforeSend }], + user_prompt: userInput || 'Please analyze the attached files.', + attached_file_ids: attachedFilesCopy, + scopes, + merge_strategy: 'smart', + config: { + provider, + model_name: modelAtSend, + temperature: isReasoning ? 1 : tempAtSend, + enable_google_search: webSearchAtSend, + reasoning_effort: effortAtSend, + } + }) }); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || `HTTP ${response.status}`); + } + if (!response.body) throw new Error('No response body'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value); + fullResponse += chunk; + + setQuickChatMessages(prev => { + const newMsgs = [...prev]; + const lastMsg = newMsgs[newMsgs.length - 1]; + if (lastMsg?.role === 'assistant') { + return [...newMsgs.slice(0, -1), { ...lastMsg, content: fullResponse }]; + } else { + return [...newMsgs, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]; + } + }); + } } // Determine whether to overwrite current node or create new one @@ -1295,18 +1489,25 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { if (!fromNodeHasResponse && fromNode) { // Overwrite the source node (it's empty) - updateNodeData(fromNodeId, { + const nodeUpdate: any = { userPrompt: userInput, response: fullResponse, - model: modelAtSend, - temperature: isReasoning ? 1 : tempAtSend, - reasoningEffort: effortAtSend, - enableGoogleSearch: webSearchAtSend, + model: isCouncilSend ? chairmanAtSend!.model : modelAtSend, + temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend), + reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend, + enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend, attachedFileIds: attachedFilesCopy, status: 'success', querySentAt: Date.now(), - responseReceivedAt: Date.now() - }); + responseReceivedAt: Date.now(), + }; + if (isCouncilSend) { + nodeUpdate.councilMode = true; + nodeUpdate.councilModels = councilModelsAtSend; + nodeUpdate.chairmanModel = chairmanAtSend; + nodeUpdate.councilData = quickChatCouncilData; + } + updateNodeData(fromNodeId, nodeUpdate); // Update trace to reflect current node now has content setQuickChatTrace(prev => prev ? { @@ -1328,19 +1529,15 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { y: sourceNode.position.y }; - const newNode = { - id: newNodeId, - type: 'llmNode', - position: newPos, - data: { - label: 'Quick Chat', - model: modelAtSend, - temperature: isReasoning ? 1 : tempAtSend, + const newNodeData: any = { + label: isCouncilSend ? 'Council Chat' : 'Quick Chat', + model: isCouncilSend ? chairmanAtSend!.model : modelAtSend, + temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend), systemPrompt: '', userPrompt: userInput, mergeStrategy: 'smart' as const, - reasoningEffort: effortAtSend, - enableGoogleSearch: webSearchAtSend, + reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend, + enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend, traces: [], outgoingTraces: [], forkedTraces: [], @@ -1351,7 +1548,20 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { status: 'success' as const, inputs: 1, querySentAt: Date.now(), - responseReceivedAt: Date.now() + responseReceivedAt: Date.now(), + }; + if (isCouncilSend) { + newNodeData.councilMode = true; + newNodeData.councilModels = councilModelsAtSend; + newNodeData.chairmanModel = chairmanAtSend; + newNodeData.councilData = quickChatCouncilData; + } + const newNode = { + id: newNodeId, + type: 'llmNode', + position: newPos, + data: { + ...newNodeData, } }; @@ -1553,13 +1763,15 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { } catch (error) { console.error('Quick chat error:', error); - setQuickChatMessages(prev => [...prev, { - id: `qc_err_${Date.now()}`, - role: 'assistant', - content: `Error: ${error}` + setQuickChatMessages(prev => [...prev, { + id: `qc_err_${Date.now()}`, + role: 'assistant', + content: `Error: ${error}` }]); + setQuickChatCouncilStage(''); } finally { setQuickChatLoading(false); + setQuickChatCouncilStage(''); // Refocus the input after sending setTimeout(() => { quickChatInputRef.current?.focus(); @@ -1697,66 +1909,239 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <div className={`space-y-2 p-2 rounded border ${isDark ? 'bg-gray-900 border-amber-800/50' : 'bg-amber-50/50 border-amber-200'}`}> <div> <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Council Members (pick 2-6)</label> - <div className="grid grid-cols-1 gap-0.5 max-h-48 overflow-y-auto"> - {[ - { 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 ( - <label key={m.value} 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-amber-900/40 text-amber-200' : 'bg-amber-100 text-amber-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 current = selectedNode.data.councilModels || []; - const next = selected - ? current.filter((v: string) => v !== m.value) - : [...current, m.value]; - handleChange('councilModels', next); - // Auto-set chairman to first selected if current chairman was removed - if (selected && selectedNode.data.chairmanModel === m.value && next.length > 0) { - handleChange('chairmanModel', next[0]); - } - }} - className="w-3 h-3 accent-amber-500" - /> - {m.label} - </label> - ); - })} + <div className="grid grid-cols-1 gap-0.5 max-h-40 overflow-y-auto"> + {(() => { + const councilModelsList = [ + { 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.councilModels || []; + 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 supportsWebSearch = (v: string) => v.startsWith('gemini-') || v.startsWith('gpt-5') || v === 'o3' || v === 'gpt-4o'; + const updateCouncilMember = (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('councilModels', updated); + } + }; + // Gather available complete traces for per-member context selection + const availableTraces: Array<{ id: string; label: string; color: string }> = []; + const incTraces = selectedNode.data.traces || []; + const outTraces = (selectedNode.data.outgoingTraces || []) as Trace[]; + const origTraces = outTraces.filter(t => { + if (t.id.startsWith('merged-')) return false; + return t.sourceNodeId === selectedNode.id || t.id.includes(`fork-${selectedNode.id}`) || t.id === `trace-${selectedNode.id}`; + }); + const trMap = new Map<string, Trace>(); + origTraces.forEach(t => trMap.set(t.id, t)); + incTraces.forEach(t => trMap.set(t.id, t)); + trMap.forEach((trace, id) => { + if (isTraceComplete(trace)) { + availableTraces.push({ id, label: `#${id.slice(-4)}`, color: trace.color }); + } + }); + (selectedNode.data.mergedTraces || []).forEach((mt: MergedTrace) => { + if (mt.messages && mt.messages.length > 0) { + availableTraces.push({ id: mt.id, label: `M#${mt.id.slice(-4)}`, color: mt.colors?.[0] || '#888' }); + } + }); + const defaultTraceId = selectedNode.data.activeTraceIds?.[0] || ''; + const defaultTraceColor = availableTraces.find(t => t.id === defaultTraceId)?.color + || trMap.get(defaultTraceId)?.color || '#888'; + return councilModelsList.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-amber-900/40 text-amber-200' : 'bg-amber-100 text-amber-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('councilModels', next); + // Auto-set chairman to first selected if current chairman was removed + if (selected && selectedNode.data.chairmanModel?.model === m.value && next.length > 0) { + handleChange('chairmanModel', { model: next[0].model }); + } + }} + className="w-3 h-3 accent-amber-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) => updateCouncilMember(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) => updateCouncilMember(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> + )} + {supportsWebSearch(m.value) && ( + <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + <input + type="checkbox" + checked={cfg.enableWebSearch ?? (selectedNode.data.enableGoogleSearch !== false)} + onChange={(e) => updateCouncilMember(m.value, 'enableWebSearch', e.target.checked)} + className="w-3 h-3 accent-amber-500" + /> + Web + </label> + )} + {availableTraces.length > 1 && (() => { + const selectedTrace = availableTraces.find(t => t.id === cfg.traceId); + return ( + <div className="relative"> + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + const key = `ctx-${m.value}`; + setOpenCtxDropdown(prev => prev === key ? null : key); + }} + className={`flex items-center gap-1 border rounded px-1 py-0 text-xs ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200 hover:bg-gray-600' : 'bg-white border-gray-300 hover:bg-gray-50'}`} + > + <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: selectedTrace ? selectedTrace.color : defaultTraceColor }} /> + {selectedTrace ? selectedTrace.label : 'Ctx'} + <ChevronDown size={8} /> + </button> + {openCtxDropdown === `ctx-${m.value}` && ( + <div className={`absolute z-50 top-full left-0 mt-1 min-w-[100px] border rounded shadow-lg py-0.5 ${isDark ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}`}> + <div + onClick={() => { updateCouncilMember(m.value, 'traceId', undefined); setOpenCtxDropdown(null); }} + className={`flex items-center gap-1.5 px-2 py-1 text-xs cursor-pointer ${!cfg.traceId ? 'font-semibold' : ''} ${isDark ? 'hover:bg-gray-700 text-gray-300' : 'hover:bg-gray-100 text-gray-700'}`} + > + <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: defaultTraceColor }} /> + Default + </div> + {availableTraces.map(t => ( + <div + key={t.id} + onClick={() => { updateCouncilMember(m.value, 'traceId', t.id); setOpenCtxDropdown(null); }} + className={`flex items-center gap-1.5 px-2 py-1 text-xs cursor-pointer ${cfg.traceId === t.id ? 'font-semibold' : ''} ${isDark ? 'hover:bg-gray-700 text-gray-300' : 'hover:bg-gray-100 text-gray-700'}`} + > + <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: t.color }} /> + {t.label} + </div> + ))} + </div> + )} + </div> + ); + })()} + </div> + )} + </div> + ); + }); + })()} </div> </div> <div> <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Chairman Model</label> <select - value={selectedNode.data.chairmanModel || (selectedNode.data.councilModels || [])[0] || ''} - onChange={(e) => handleChange('chairmanModel', e.target.value)} + value={selectedNode.data.chairmanModel?.model || (selectedNode.data.councilModels || [])[0]?.model || ''} + onChange={(e) => { + const existing = (selectedNode.data.councilModels || []).find((c: CouncilMemberConfig) => c.model === e.target.value); + handleChange('chairmanModel', 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' : 'border-gray-300'}`} > - {(selectedNode.data.councilModels || []).map((m: string) => ( - <option key={m} value={m}>{m}</option> + {(selectedNode.data.councilModels || []).map((c: CouncilMemberConfig) => ( + <option key={c.model} value={c.model}>{c.model}</option> ))} </select> + {(() => { + const chairCfg = selectedNode.data.chairmanModel; + if (!chairCfg) return null; + 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 supportsWebSearch = (v: string) => v.startsWith('gemini-') || v.startsWith('gpt-5') || v === 'o3' || v === 'gpt-4o'; + const updateChairman = (field: string, value: any) => { + handleChange('chairmanModel', { ...chairCfg, [field]: value }); + }; + return ( + <div className={`flex items-center gap-2 mt-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={chairCfg.temperature ?? selectedNode.data.temperature} + disabled={isReasoningModel(chairCfg.model)} + onChange={(e) => updateChairman('temperature', parseFloat(e.target.value) || 0)} + className={`w-12 border rounded px-1 py-0 text-xs ${isReasoningModel(chairCfg.model) ? 'opacity-50 cursor-not-allowed' : ''} ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`} + /> + </label> + {isReasoningModel(chairCfg.model) && ( + <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + Effort: + <select + value={chairCfg.reasoningEffort ?? selectedNode.data.reasoningEffort ?? 'medium'} + onChange={(e) => updateChairman('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> + )} + {supportsWebSearch(chairCfg.model) && ( + <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + <input + type="checkbox" + checked={chairCfg.enableWebSearch ?? (selectedNode.data.enableGoogleSearch !== false)} + onChange={(e) => updateChairman('enableWebSearch', e.target.checked)} + className="w-3 h-3 accent-amber-500" + /> + Web + </label> + )} + </div> + ); + })()} </div> </div> )} @@ -2098,18 +2483,23 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { )} {selectedNode.data.councilMode ? ( - <button - onClick={handleRunCouncil} - disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.councilModels || []).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.councilModels || []).length < 2 - ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400' - : 'bg-amber-600 text-white hover:bg-amber-700' - }`} - > - {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Users size={16} />} - {selectedNode.data.status === 'loading' && councilStage ? councilStage : `Run Council (${(selectedNode.data.councilModels || []).length})`} - </button> + <> + <button + onClick={handleRunCouncil} + disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.councilModels || []).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.councilModels || []).length < 2 + ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400' + : 'bg-amber-600 text-white hover:bg-amber-700' + }`} + > + {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Users size={16} />} + {selectedNode.data.status === 'loading' && councilStage ? councilStage : `Run Council (${(selectedNode.data.councilModels || []).length})`} + </button> + <div className={`text-center text-[10px] mt-0.5 leading-tight ${isDark ? 'text-gray-600' : 'text-gray-400'}`}> + 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> + </> ) : ( <button onClick={handleRun} @@ -2321,6 +2711,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { )} </div> )} + </div> ) : isEditing ? ( <div className="space-y-2"> @@ -2950,44 +3341,74 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { )} <span className={`text-xs font-mono ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>#{quickChatTrace.id.slice(-8)}</span> </div> - <div className="flex items-center gap-3"> - {/* Model Selector */} - <select - value={quickChatModel} - onChange={(e) => setQuickChatModel(e.target.value)} - className={`border rounded-md px-3 py-1.5 text-sm ${ - isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300 text-gray-900' + <div className="flex items-center gap-2"> + {/* Council Toggle */} + <button + onClick={() => { + const next = !quickChatCouncilMode; + setQuickChatCouncilMode(next); + if (next && quickChatCouncilModels.length === 0) { + setQuickChatCouncilConfigOpen(true); + } + }} + className={`p-1.5 rounded transition-colors ${quickChatCouncilMode + ? (isDark ? 'bg-amber-900/60 text-amber-300' : 'bg-amber-100 text-amber-700') + : (isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-500 hover:bg-gray-200') }`} + title={quickChatCouncilMode ? 'Council mode ON' : 'Enable council mode'} > - <optgroup label="Claude"> - <option value="claude-sonnet-4-5">claude-sonnet-4.5</option> - <option value="claude-opus-4">claude-opus-4</option> - <option value="claude-opus-4-5">claude-opus-4.5</option> - <option value="claude-opus-4-6">claude-opus-4.6</option> - </optgroup> - <optgroup label="Gemini"> - <option value="gemini-2.5-flash">gemini-2.5-flash</option> - <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option> - <option value="gemini-3-pro-preview">gemini-3-pro-preview</option> - </optgroup> - <optgroup label="OpenAI (Standard)"> - <option value="gpt-4.1">gpt-4.1</option> - <option value="gpt-4o">gpt-4o</option> - </optgroup> - <optgroup label="OpenAI (Reasoning)"> - <option value="gpt-5">gpt-5</option> - <option value="gpt-5-chat-latest">gpt-5-chat-latest</option> - <option value="gpt-5-mini">gpt-5-mini</option> - <option value="gpt-5-nano">gpt-5-nano</option> - <option value="gpt-5-pro" disabled={!canUsePremiumModels}>gpt-5-pro {!canUsePremiumModels && '🔒'}</option> - <option value="gpt-5.1">gpt-5.1</option> - <option value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option> - <option value="gpt-5.2">gpt-5.2</option> - <option value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option> - <option value="gpt-5.2-pro" disabled={!canUsePremiumModels}>gpt-5.2-pro {!canUsePremiumModels && '🔒'}</option> - <option value="o3" disabled={!canUsePremiumModels}>o3 {!canUsePremiumModels && '🔒'}</option> - </optgroup> - </select> + <Users size={16} /> + </button> + {/* Model Selector — hidden in council mode */} + {!quickChatCouncilMode ? ( + <select + value={quickChatModel} + onChange={(e) => setQuickChatModel(e.target.value)} + className={`border rounded-md px-3 py-1.5 text-sm ${ + isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300 text-gray-900' + }`} + > + <optgroup label="Claude"> + <option value="claude-sonnet-4-5">claude-sonnet-4.5</option> + <option value="claude-opus-4">claude-opus-4</option> + <option value="claude-opus-4-5">claude-opus-4.5</option> + <option value="claude-opus-4-6">claude-opus-4.6</option> + </optgroup> + <optgroup label="Gemini"> + <option value="gemini-2.5-flash">gemini-2.5-flash</option> + <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option> + <option value="gemini-3-pro-preview">gemini-3-pro-preview</option> + </optgroup> + <optgroup label="OpenAI (Standard)"> + <option value="gpt-4.1">gpt-4.1</option> + <option value="gpt-4o">gpt-4o</option> + </optgroup> + <optgroup label="OpenAI (Reasoning)"> + <option value="gpt-5">gpt-5</option> + <option value="gpt-5-chat-latest">gpt-5-chat-latest</option> + <option value="gpt-5-mini">gpt-5-mini</option> + <option value="gpt-5-nano">gpt-5-nano</option> + <option value="gpt-5-pro" disabled={!canUsePremiumModels}>gpt-5-pro {!canUsePremiumModels && '🔒'}</option> + <option value="gpt-5.1">gpt-5.1</option> + <option value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option> + <option value="gpt-5.2">gpt-5.2</option> + <option value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option> + <option value="gpt-5.2-pro" disabled={!canUsePremiumModels}>gpt-5.2-pro {!canUsePremiumModels && '🔒'}</option> + <option value="o3" disabled={!canUsePremiumModels}>o3 {!canUsePremiumModels && '🔒'}</option> + </optgroup> + </select> + ) : ( + <button + onClick={() => setQuickChatCouncilConfigOpen(!quickChatCouncilConfigOpen)} + className={`border rounded-md px-3 py-1.5 text-sm flex items-center gap-1.5 ${ + isDark ? 'bg-gray-700 border-amber-700/50 text-amber-300' : 'border-amber-300 text-amber-700 bg-amber-50' + }`} + > + <Users size={14} /> + Council ({quickChatCouncilModels.length}) + <ChevronRight size={12} className={`transition-transform ${quickChatCouncilConfigOpen ? 'rotate-90' : ''}`} /> + </button> + )} <button onClick={() => setRawTextMode(!rawTextMode)} className={`p-1 rounded ${rawTextMode @@ -3003,6 +3424,64 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { </div> </div> + {/* Council Config Panel (collapsible) */} + {quickChatCouncilMode && quickChatCouncilConfigOpen && ( + <div className={`px-4 py-2 border-b ${isDark ? 'border-gray-700 bg-gray-800/50' : 'border-gray-200 bg-amber-50/30'}`}> + <div className="grid grid-cols-2 gap-x-3 gap-y-0.5 max-h-36 overflow-y-auto text-xs"> + {[ + { 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' }, + ].map(m => { + const sel = quickChatCouncilModels.some(c => c.model === m.value); + return ( + <label key={m.value} className={`flex items-center gap-1 py-0.5 cursor-pointer ${sel + ? isDark ? 'text-amber-300' : 'text-amber-700' + : isDark ? 'text-gray-400' : 'text-gray-600' + }`}> + <input type="checkbox" checked={sel} onChange={() => { + const next = sel + ? quickChatCouncilModels.filter(c => c.model !== m.value) + : [...quickChatCouncilModels, { model: m.value }]; + setQuickChatCouncilModels(next); + if (sel && quickChatChairmanModel?.model === m.value && next.length > 0) { + setQuickChatChairmanModel({ model: next[0].model }); + } + }} className="w-3 h-3 accent-amber-500" /> + {m.label} + </label> + ); + })} + </div> + {quickChatCouncilModels.length > 0 && ( + <div className={`mt-1.5 pt-1.5 border-t flex items-center gap-2 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <span className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Chairman:</span> + <select + value={quickChatChairmanModel?.model || quickChatCouncilModels[0]?.model || ''} + onChange={(e) => setQuickChatChairmanModel({ model: e.target.value })} + className={`border rounded px-2 py-0.5 text-xs ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300'}`} + > + {quickChatCouncilModels.map(c => ( + <option key={c.model} value={c.model}>{c.model}</option> + ))} + </select> + </div> + )} + </div> + )} + {/* Chat Messages */} <div className={`flex-1 overflow-y-auto p-4 space-y-4 ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}> {quickChatMessages.length === 0 ? ( @@ -3102,10 +3581,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { )} {quickChatLoading && ( <div className="flex justify-start"> - <div className={`rounded-lg px-4 py-3 shadow-sm ${ + <div className={`rounded-lg px-4 py-3 shadow-sm flex items-center gap-2 ${ isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white border border-gray-200' }`}> - <Loader2 className="animate-spin text-blue-500" size={20} /> + <Loader2 className={`animate-spin ${quickChatCouncilMode ? 'text-amber-500' : 'text-blue-500'}`} size={20} /> + {quickChatCouncilStage && ( + <span className={`text-xs ${isDark ? 'text-amber-400' : 'text-amber-600'}`}>{quickChatCouncilStage}</span> + )} </div> </div> )} @@ -3116,56 +3598,65 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <div className={`px-4 py-2 border-t flex items-center gap-4 text-xs ${ isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-100 bg-white' }`}> - {/* Temperature (hide for reasoning models) */} - {!['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'gpt-5.2', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'o3'].includes(quickChatModel) && ( - <div className="flex items-center gap-2"> - <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Temp:</span> - <input - type="range" - min="0" - max="2" - step="0.1" - value={quickChatTemp} - onChange={(e) => setQuickChatTemp(parseFloat(e.target.value))} - className="w-20" - /> - <span className={`w-8 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>{quickChatTemp}</span> - </div> - )} - - {/* Reasoning Effort (only for reasoning models, except chat-latest) */} - {['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.2', 'gpt-5.2-pro', 'o3'].includes(quickChatModel) && ( - <div className="flex items-center gap-2"> - <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Effort:</span> - <select - value={quickChatEffort} - onChange={(e) => setQuickChatEffort(e.target.value as 'low' | 'medium' | 'high')} - className={`border rounded px-2 py-0.5 text-xs ${ - isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300' - }`} - > - <option value="low">Low</option> - <option value="medium">Medium</option> - <option value="high">High</option> - </select> - </div> - )} - - {/* Web Search */} - {(quickChatModel.startsWith('gemini') || quickChatModel.startsWith('gpt-5') || ['o3', 'gpt-4o'].includes(quickChatModel)) && ( - <label className="flex items-center gap-1 cursor-pointer"> - <input - type="checkbox" - checked={quickChatWebSearch} - onChange={(e) => setQuickChatWebSearch(e.target.checked)} - className="form-checkbox h-3 w-3" - /> - <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Web Search</span> - </label> + {quickChatCouncilMode ? ( + <span className={`flex items-center gap-1.5 ${isDark ? 'text-amber-400' : 'text-amber-600'}`}> + <Users size={12} /> + {quickChatCouncilModels.length} models · Chairman: {quickChatChairmanModel?.model || '—'} + </span> + ) : ( + <> + {/* Temperature (hide for reasoning models) */} + {!['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'gpt-5.2', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'o3'].includes(quickChatModel) && ( + <div className="flex items-center gap-2"> + <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Temp:</span> + <input + type="range" + min="0" + max="2" + step="0.1" + value={quickChatTemp} + onChange={(e) => setQuickChatTemp(parseFloat(e.target.value))} + className="w-20" + /> + <span className={`w-8 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>{quickChatTemp}</span> + </div> + )} + + {/* Reasoning Effort (only for reasoning models, except chat-latest) */} + {['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.2', 'gpt-5.2-pro', 'o3'].includes(quickChatModel) && ( + <div className="flex items-center gap-2"> + <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Effort:</span> + <select + value={quickChatEffort} + onChange={(e) => setQuickChatEffort(e.target.value as 'low' | 'medium' | 'high')} + className={`border rounded px-2 py-0.5 text-xs ${ + isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300' + }`} + > + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + </select> + </div> + )} + + {/* Web Search */} + {(quickChatModel.startsWith('gemini') || quickChatModel.startsWith('gpt-5') || ['o3', 'gpt-4o'].includes(quickChatModel)) && ( + <label className="flex items-center gap-1 cursor-pointer"> + <input + type="checkbox" + checked={quickChatWebSearch} + onChange={(e) => setQuickChatWebSearch(e.target.checked)} + className="form-checkbox h-3 w-3" + /> + <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Web Search</span> + </label> + )} + </> )} - + {/* File Attachment Buttons - Hidden for Gemini */} - {!quickChatModel.startsWith('gemini') && ( + {!quickChatModel.startsWith('gemini') && !quickChatCouncilMode && ( <div className="flex items-center gap-1 ml-auto"> <button onClick={() => quickChatUploadRef.current?.click()} @@ -3175,8 +3666,8 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { ? 'opacity-50 cursor-not-allowed' : '' } ${ - isDark - ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' + isDark + ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' : 'bg-gray-100 hover:bg-gray-200 text-gray-700' }`} title={quickChatUploading ? "Uploading..." : "Upload & Attach"} @@ -3190,8 +3681,8 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { setShowQuickChatAttachModal(true); }} className={`px-2 py-0.5 rounded text-xs flex items-center gap-1 ${ - isDark - ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' + isDark + ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' : 'bg-gray-100 hover:bg-gray-200 text-gray-700' }`} title="Attach Existing File" @@ -3272,8 +3763,12 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { /> <button onClick={handleQuickChatSend} - disabled={(!quickChatInput.trim() && quickChatAttachedFiles.length === 0) || quickChatLoading} - className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed flex items-center gap-2" + disabled={(!quickChatInput.trim() && quickChatAttachedFiles.length === 0) || quickChatLoading || (quickChatCouncilMode && quickChatCouncilModels.length < 2)} + className={`px-4 py-2 text-white rounded-lg disabled:cursor-not-allowed flex items-center gap-2 ${ + quickChatCouncilMode + ? 'bg-amber-600 hover:bg-amber-700 disabled:bg-amber-300' + : 'bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300' + }`} > {quickChatLoading ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />} </button> diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts index 944b846..f31720e 100644 --- a/frontend/src/store/flowStore.ts +++ b/frontend/src/store/flowStore.ts @@ -73,6 +73,14 @@ export interface MergedTrace { summarizedContent?: string; // For summary strategy, stores the LLM-generated summary } +export interface CouncilMemberConfig { + model: string; + temperature?: number; + reasoningEffort?: 'low' | 'medium' | 'high'; + enableWebSearch?: boolean; + traceId?: string; // Per-member trace selection for council context +} + export interface CouncilData { stage1: Array<{ model: string; response: string }> | null; stage2: { @@ -98,8 +106,8 @@ export interface NodeData { // Council mode councilMode?: boolean; - councilModels?: string[]; - chairmanModel?: string; + councilModels?: CouncilMemberConfig[]; + chairmanModel?: CouncilMemberConfig; councilData?: CouncilData; // Traces logic @@ -1607,8 +1615,18 @@ const useFlowStore = create<FlowState>((set, get) => { }, loadBlueprint: (doc: BlueprintDocument): ViewportState | undefined => { + // Migrate old string[] councilModels to CouncilMemberConfig[] + const migratedNodes = (doc.nodes || []).map((n: any) => { + if (n.data?.councilModels && n.data.councilModels.length > 0 && typeof n.data.councilModels[0] === 'string') { + n.data.councilModels = n.data.councilModels.map((m: string) => ({ model: m })); + } + if (n.data?.chairmanModel && typeof n.data.chairmanModel === 'string') { + n.data.chairmanModel = { model: n.data.chairmanModel }; + } + return n; + }); set({ - nodes: (doc.nodes || []) as LLMNode[], + nodes: migratedNodes as LLMNode[], edges: (doc.edges || []) as Edge[], selectedNodeId: null, lastViewport: doc.viewport || get().lastViewport, |
