summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/components/Sidebar.tsx967
-rw-r--r--frontend/src/store/flowStore.ts24
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 &middot; 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,