diff options
| author | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 21:43:34 +0000 |
|---|---|---|
| committer | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 21:43:34 +0000 |
| commit | 77be59bc0a6353e98846b9c9bfa2d566efea8b1f (patch) | |
| tree | c0cc008b4705eb50616e6656f8fbc0e5b3475307 | |
| parent | 30921396cb53f61eca90c85d692e0fc06d0f5ff4 (diff) | |
Add LLM Council mode for multi-model consensus
3-stage council orchestration: parallel model queries (Stage 1),
anonymous peer ranking (Stage 2), and streamed chairman synthesis
(Stage 3). Includes scope-aware file resolution for Google/Claude
providers so upstream file attachments are visible to all providers.
- Backend: council.py orchestrator, /api/run_council_stream endpoint,
query_model_full() non-streaming wrapper, resolve_provider() helper,
resolve_scoped_file_ids() for Google/Claude scope parity with OpenAI
- Frontend: council toggle UI, model checkbox selector, chairman picker,
SSE event parsing, tabbed Stage 1/2/3 response display
- Canvas: amber council node indicator with Users icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | backend/app/main.py | 162 | ||||
| -rw-r--r-- | backend/app/schemas.py | 18 | ||||
| -rw-r--r-- | backend/app/services/council.py | 322 | ||||
| -rw-r--r-- | backend/app/services/llm.py | 29 | ||||
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 555 | ||||
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 36 | ||||
| -rw-r--r-- | frontend/src/store/flowStore.ts | 28 |
7 files changed, 1059 insertions, 91 deletions
diff --git a/backend/app/main.py b/backend/app/main.py index d48ec89..9370a32 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,8 +8,9 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, FileResponse from fastapi import UploadFile, File, Form from pydantic import BaseModel -from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort -from app.services.llm import llm_streamer, generate_title, get_openai_client, get_anthropic_client +from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort, CouncilRunRequest +from app.services.llm import llm_streamer, generate_title, get_openai_client, get_anthropic_client, resolve_provider +from app.services.council import council_event_stream from app.auth import auth_router, get_current_user, get_current_user_optional, init_db, User, get_db from app.auth.utils import get_password_hash from dotenv import load_dotenv @@ -421,17 +422,19 @@ async def run_node_stream( tools.append(tool_def) logger.debug("openai file_search: vs_ids=%s refs=%s filters=%s", vs_ids, debug_refs, filters) elif request.config.provider == ModelProvider.GOOGLE: + scoped_ids = resolve_scoped_file_ids(username, request.scopes, non_image_file_ids) attachments = await prepare_attachments( user=username, target_provider=request.config.provider, - attached_ids=non_image_file_ids, + attached_ids=scoped_ids, llm_config=request.config, ) elif request.config.provider == ModelProvider.CLAUDE: + scoped_ids = resolve_scoped_file_ids(username, request.scopes, non_image_file_ids) attachments = await prepare_attachments( user=username, target_provider=request.config.provider, - attached_ids=non_image_file_ids, + attached_ids=scoped_ids, llm_config=request.config, ) @@ -442,6 +445,127 @@ async def run_node_stream( media_type="text/event-stream" ) +@app.post("/api/run_council_stream") +async def run_council_stream( + request: CouncilRunRequest, + user: str = DEFAULT_USER, + current_user: User | None = Depends(get_current_user_optional), +): + """ + Run the 3-stage LLM Council and stream SSE events. + """ + resolved = resolve_user(current_user, user) + username = resolved.username if resolved else DEFAULT_USER + + # Merge incoming contexts (same logic as run_node_stream) + raw_messages = [] + for ctx in request.incoming_contexts: + raw_messages.extend(ctx.messages) + if request.merge_strategy == MergeStrategy.SMART: + final_messages = smart_merge_messages(raw_messages) + else: + final_messages = raw_messages + execution_context = Context(messages=final_messages) + + # Extract images from attached files + images, non_image_file_ids = extract_image_attachments(username, request.attached_file_ids) + + openrouter_key = get_user_api_key(resolved, "openrouter") + + # Build LLMConfig + attachments for each council member + member_configs: list[LLMConfig] = [] + attachments_per_model: list[list[dict] | None] = [] + tools_per_model: list[list[dict] | None] = [] + + all_model_names = [m.model_name for m in request.council_models] + [request.chairman_model] + + for member in request.council_models: + provider = resolve_provider(member.model_name) + provider_str = provider.value + api_key = get_user_api_key(resolved, provider_str) + + config = LLMConfig( + provider=provider, + model_name=member.model_name, + temperature=request.temperature, + system_prompt=request.system_prompt, + api_key=api_key, + reasoning_effort=request.reasoning_effort, + ) + member_configs.append(config) + + # Prepare provider-specific file attachments + tools: list[dict] = [] + attachments: list[dict] = [] + + # For Google/Claude: resolve scope-based files so upstream attachments are visible + scoped_file_ids = resolve_scoped_file_ids(username, request.scopes, non_image_file_ids) + + if provider == ModelProvider.OPENAI: + vs_ids, debug_refs, filters = await prepare_openai_vector_search( + user=username, + attached_ids=non_image_file_ids, + scopes=request.scopes, + llm_config=config, + ) + if not vs_ids: + try: + client = get_openai_client(config.api_key) + vs_id = await ensure_user_vector_store(username, client) + if vs_id: + vs_ids = [vs_id] + except Exception: + pass + if vs_ids: + tool_def = {"type": "file_search", "vector_store_ids": vs_ids} + if filters: + tool_def["filters"] = filters + tools.append(tool_def) + elif provider == ModelProvider.GOOGLE: + attachments = await prepare_attachments( + user=username, + target_provider=provider, + attached_ids=scoped_file_ids, + llm_config=config, + ) + elif provider == ModelProvider.CLAUDE: + attachments = await prepare_attachments( + user=username, + target_provider=provider, + attached_ids=scoped_file_ids, + llm_config=config, + ) + + attachments_per_model.append(attachments or None) + tools_per_model.append(tools or None) + + # Build chairman config + chairman_provider = resolve_provider(request.chairman_model) + chairman_api_key = get_user_api_key(resolved, chairman_provider.value) + chairman_config = LLMConfig( + provider=chairman_provider, + model_name=request.chairman_model, + temperature=request.temperature, + system_prompt=request.system_prompt, + api_key=chairman_api_key, + reasoning_effort=request.reasoning_effort, + ) + + return StreamingResponse( + council_event_stream( + user_prompt=request.user_prompt, + context=execution_context, + member_configs=member_configs, + chairman_config=chairman_config, + attachments_per_model=attachments_per_model, + tools_per_model=tools_per_model, + openrouter_api_key=openrouter_key, + images=images, + ), + media_type="text/event-stream", + ) + + class TitleRequest(BaseModel): user_prompt: str response: str @@ -832,6 +956,36 @@ def save_files_index(user: str, items: List[FileMeta]): json.dump([item.model_dump() for item in items], f, ensure_ascii=False, indent=2) +def resolve_scoped_file_ids(user: str, scopes: List[str], explicit_ids: List[str]) -> List[str]: + """ + Resolve file IDs that are relevant to the given scopes (upstream nodes). + Combines scope-matched files with explicitly attached files. + This gives Google/Claude the same scope awareness that OpenAI gets via file_search. + """ + if not scopes and not explicit_ids: + return [] + + items = load_files_index(user) + result_ids: dict[str, bool] = {} + + # Add explicitly attached files first + for fid in explicit_ids: + result_ids[fid] = True + + # Add files whose scopes intersect with requested scopes (skip images) + if scopes: + for item in items: + if item.id in result_ids: + continue + if item.mime in IMAGE_MIME_TYPES: + continue + if item.scopes and any(s in scopes for s in item.scopes): + result_ids[item.id] = True + logger.debug("resolve_scoped_file_ids: scope match %s -> %s", item.name, item.id) + + return list(result_ids.keys()) + + async def _check_google_file_active(uri_or_name: str, api_key: str = None) -> bool: """Check if a Google file reference is still ACTIVE (not expired).""" key = api_key or os.getenv("GOOGLE_API_KEY") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8e5f12c..a527004 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -34,7 +34,7 @@ class LLMConfig(BaseModel): provider: ModelProvider model_name: str temperature: float = 0.7 - max_tokens: int = 1000 + max_tokens: Optional[int] = None system_prompt: Optional[str] = None api_key: Optional[str] = None # Optional override, usually from env enable_google_search: bool = False @@ -55,6 +55,22 @@ class NodeRunRequest(BaseModel): # Contains all project/node combinations in the current trace scopes: List[str] = Field(default_factory=list) +class CouncilMemberConfig(BaseModel): + model_name: str # e.g. "gpt-5", "claude-opus-4-6", "gemini-3-pro-preview" + +class CouncilRunRequest(BaseModel): + node_id: str + incoming_contexts: List[Context] = [] + user_prompt: str + council_models: List[CouncilMemberConfig] # 2-6 models + chairman_model: str # Model name for synthesis + system_prompt: Optional[str] = None + temperature: float = 0.7 + reasoning_effort: ReasoningEffort = ReasoningEffort.MEDIUM + merge_strategy: MergeStrategy = MergeStrategy.SMART + attached_file_ids: List[str] = Field(default_factory=list) + scopes: List[str] = Field(default_factory=list) + class NodeRunResponse(BaseModel): node_id: str output_context: Context diff --git a/backend/app/services/council.py b/backend/app/services/council.py new file mode 100644 index 0000000..d177f44 --- /dev/null +++ b/backend/app/services/council.py @@ -0,0 +1,322 @@ +"""3-stage LLM Council orchestration for ContextFlow.""" + +import asyncio +import json +import logging +import re +from collections import defaultdict +from typing import AsyncGenerator, Dict, List, Any, Optional, Tuple + +from app.schemas import Context, LLMConfig +from app.services.llm import query_model_full, llm_streamer + +logger = logging.getLogger("contextflow.council") + + +async def stage1_collect_responses( + user_prompt: str, + context: Context, + configs: List[LLMConfig], + attachments_per_model: Optional[List[Optional[List[Dict[str, Any]]]]] = None, + tools_per_model: Optional[List[Optional[List[Dict[str, Any]]]]] = None, + openrouter_api_key: Optional[str] = None, + images: Optional[List[Dict[str, Any]]] = None, +) -> AsyncGenerator[Dict[str, Any], None]: + """ + Stage 1: Query all council member models in parallel. + Yields events as each model completes. + Returns final list via stage1_complete event. + """ + async def _query_one(idx: int, config: LLMConfig) -> Dict[str, Any]: + atts = attachments_per_model[idx] if attachments_per_model else None + tls = tools_per_model[idx] if tools_per_model else None + try: + response = await query_model_full( + context, user_prompt, config, + attachments=atts, tools=tls, + openrouter_api_key=openrouter_api_key, images=images, + ) + return {"model": config.model_name, "response": response} + except Exception as e: + logger.error("Council stage1 failed for %s: %s", config.model_name, e) + return {"model": config.model_name, "response": f"[Error: {e}]"} + + # Launch all queries concurrently, yield as each completes + tasks = { + asyncio.ensure_future(_query_one(i, cfg)): i + for i, cfg in enumerate(configs) + } + results: List[Optional[Dict[str, Any]]] = [None] * len(configs) + + for coro in asyncio.as_completed(tasks.keys()): + result = await coro + idx = tasks[[t for t in tasks if t.done() and t.result() is result][0]] + results[idx] = result + yield result # caller sends stage1_model_complete event + + # Not yielded — caller collects via the individual yields + + +def _build_ranking_prompt(user_query: str, stage1_results: List[Dict[str, Any]]) -> str: + """Build the anonymized ranking prompt for Stage 2.""" + labels = [chr(65 + i) for i in range(len(stage1_results))] # A, B, C, ... + + responses_text = "\n\n".join([ + f"Response {label}:\n{result['response']}" + for label, result in zip(labels, stage1_results) + ]) + + return f"""You are evaluating different responses to the following question: + +Question: {user_query} + +Here are the responses from different models (anonymized): + +{responses_text} + +Your task: +1. First, evaluate each response individually. For each response, explain what it does well and what it does poorly. +2. Then, at the very end of your response, provide a final ranking. + +IMPORTANT: Your final ranking MUST be formatted EXACTLY as follows: +- Start with the line "FINAL RANKING:" (all caps, with colon) +- Then list the responses from best to worst as a numbered list +- Each line should be: number, period, space, then ONLY the response label (e.g., "1. Response A") +- Do not add any other text or explanations in the ranking section + +Example of the correct format for your ENTIRE response: + +Response A provides good detail on X but misses Y... +Response B is accurate but lacks depth on Z... +Response C offers the most comprehensive answer... + +FINAL RANKING: +1. Response C +2. Response A +3. Response B + +Now provide your evaluation and ranking:""" + + +def parse_ranking_from_text(ranking_text: str) -> List[str]: + """Parse the FINAL RANKING section from the model's response.""" + if "FINAL RANKING:" in ranking_text: + parts = ranking_text.split("FINAL RANKING:") + if len(parts) >= 2: + ranking_section = parts[1] + numbered_matches = re.findall(r'\d+\.\s*Response [A-Z]', ranking_section) + if numbered_matches: + return [re.search(r'Response [A-Z]', m).group() for m in numbered_matches] + matches = re.findall(r'Response [A-Z]', ranking_section) + return matches + + matches = re.findall(r'Response [A-Z]', ranking_text) + return matches + + +async def stage2_collect_rankings( + user_query: str, + stage1_results: List[Dict[str, Any]], + configs: List[LLMConfig], + openrouter_api_key: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], Dict[str, str]]: + """ + Stage 2: Each model ranks the anonymized responses. + Text-only prompts, no file attachments. + """ + labels = [chr(65 + i) for i in range(len(stage1_results))] + label_to_model = { + f"Response {label}": result['model'] + for label, result in zip(labels, stage1_results) + } + + ranking_prompt = _build_ranking_prompt(user_query, stage1_results) + empty_context = Context(messages=[]) + + async def _rank_one(config: LLMConfig) -> Dict[str, Any]: + try: + response = await query_model_full( + empty_context, ranking_prompt, config, + openrouter_api_key=openrouter_api_key, + ) + parsed = parse_ranking_from_text(response) + return { + "model": config.model_name, + "ranking": response, + "parsed_ranking": parsed, + } + except Exception as e: + logger.error("Council stage2 failed for %s: %s", config.model_name, e) + return { + "model": config.model_name, + "ranking": f"[Error: {e}]", + "parsed_ranking": [], + } + + results = await asyncio.gather(*[_rank_one(cfg) for cfg in configs]) + return list(results), label_to_model + + +def calculate_aggregate_rankings( + stage2_results: List[Dict[str, Any]], + label_to_model: Dict[str, str], +) -> List[Dict[str, Any]]: + """Calculate aggregate rankings across all models.""" + model_positions: Dict[str, List[int]] = defaultdict(list) + + for ranking in stage2_results: + parsed_ranking = ranking.get("parsed_ranking", []) + if not parsed_ranking: + parsed_ranking = parse_ranking_from_text(ranking.get("ranking", "")) + for position, label in enumerate(parsed_ranking, start=1): + if label in label_to_model: + model_name = label_to_model[label] + model_positions[model_name].append(position) + + aggregate = [] + for model, positions in model_positions.items(): + if positions: + avg_rank = sum(positions) / len(positions) + aggregate.append({ + "model": model, + "average_rank": round(avg_rank, 2), + "rankings_count": len(positions), + }) + + aggregate.sort(key=lambda x: x["average_rank"]) + return aggregate + + +def _build_chairman_prompt( + user_query: str, + stage1_results: List[Dict[str, Any]], + stage2_results: List[Dict[str, Any]], +) -> str: + """Build the chairman synthesis prompt for Stage 3.""" + stage1_text = "\n\n".join([ + f"Model: {result['model']}\nResponse: {result['response']}" + for result in stage1_results + ]) + + stage2_text = "\n\n".join([ + f"Model: {result['model']}\nRanking: {result['ranking']}" + for result in stage2_results + ]) + + return f"""You are the Chairman of an LLM Council. Multiple AI models have provided responses to a user's question, and then ranked each other's responses. + +Original Question: {user_query} + +STAGE 1 - Individual Responses: +{stage1_text} + +STAGE 2 - Peer Rankings: +{stage2_text} + +Your task as Chairman is to synthesize all of this information into a single, comprehensive, accurate answer to the user's original question. Consider: +- The individual responses and their insights +- The peer rankings and what they reveal about response quality +- Any patterns of agreement or disagreement + +Provide a clear, well-reasoned final answer that represents the council's collective wisdom:""" + + +async def stage3_stream_synthesis( + user_query: str, + stage1_results: List[Dict[str, Any]], + stage2_results: List[Dict[str, Any]], + chairman_config: LLMConfig, + openrouter_api_key: Optional[str] = None, +) -> AsyncGenerator[str, None]: + """ + Stage 3: Chairman synthesizes final answer. Streams text chunks. + """ + chairman_prompt = _build_chairman_prompt(user_query, stage1_results, stage2_results) + empty_context = Context(messages=[]) + + async for chunk in llm_streamer( + empty_context, chairman_prompt, chairman_config, + openrouter_api_key=openrouter_api_key, + ): + yield chunk + + +def _sse_event(data: dict) -> str: + """Format a dict as an SSE data line.""" + return f"data: {json.dumps(data)}\n\n" + + +async def council_event_stream( + user_prompt: str, + context: Context, + member_configs: List[LLMConfig], + chairman_config: LLMConfig, + attachments_per_model: Optional[List[Optional[List[Dict[str, Any]]]]] = None, + tools_per_model: Optional[List[Optional[List[Dict[str, Any]]]]] = None, + openrouter_api_key: Optional[str] = None, + images: Optional[List[Dict[str, Any]]] = None, +) -> AsyncGenerator[str, None]: + """ + Master orchestrator yielding SSE JSON events through the 3-stage council process. + """ + # === Stage 1 === + yield _sse_event({"type": "stage1_start"}) + + stage1_results: List[Dict[str, Any]] = [] + async for result in stage1_collect_responses( + user_prompt, context, member_configs, + attachments_per_model=attachments_per_model, + tools_per_model=tools_per_model, + openrouter_api_key=openrouter_api_key, + images=images, + ): + stage1_results.append(result) + yield _sse_event({ + "type": "stage1_model_complete", + "data": {"model": result["model"], "response": result["response"]}, + }) + + yield _sse_event({"type": "stage1_complete", "data": stage1_results}) + + if not stage1_results: + yield _sse_event({ + "type": "error", + "data": {"message": "All council models failed to respond."}, + }) + return + + # === Stage 2 === + yield _sse_event({"type": "stage2_start"}) + + stage2_results, label_to_model = await stage2_collect_rankings( + user_prompt, stage1_results, member_configs, + openrouter_api_key=openrouter_api_key, + ) + aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model) + + yield _sse_event({ + "type": "stage2_complete", + "data": { + "rankings": stage2_results, + "label_to_model": label_to_model, + "aggregate_rankings": aggregate_rankings, + }, + }) + + # === Stage 3 (streamed) === + yield _sse_event({"type": "stage3_start"}) + + full_response = "" + async for chunk in stage3_stream_synthesis( + user_prompt, stage1_results, stage2_results, chairman_config, + openrouter_api_key=openrouter_api_key, + ): + full_response += chunk + yield _sse_event({"type": "stage3_chunk", "data": {"chunk": chunk}}) + + yield _sse_event({ + "type": "stage3_complete", + "data": {"model": chairman_config.model_name, "response": full_response}, + }) + + yield _sse_event({"type": "complete"}) diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 7efdce0..c22ada3 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -4,7 +4,7 @@ from typing import AsyncGenerator, List, Dict, Any, Optional import openai import google.generativeai as genai import anthropic -from app.schemas import LLMConfig, Message, Role, Context +from app.schemas import LLMConfig, Message, Role, Context, ModelProvider logger = logging.getLogger("contextflow.llm") @@ -599,6 +599,33 @@ async def llm_streamer( yield f"Error calling LLM: {primary_error} (OpenRouter fallback also failed: {fallback_error})" +def resolve_provider(model_name: str) -> ModelProvider: + """Determine the provider from a model name string.""" + name = model_name.lower() + if any(name.startswith(p) for p in ('claude',)): + return ModelProvider.CLAUDE + if any(name.startswith(p) for p in ('gemini',)): + return ModelProvider.GOOGLE + # Default to OpenAI for gpt-*, o1, o3, etc. + return ModelProvider.OPENAI + + +async def query_model_full( + context: Context, + user_prompt: str, + config: LLMConfig, + attachments=None, + tools=None, + openrouter_api_key=None, + images=None, +) -> str: + """Collect full response from llm_streamer (non-streaming wrapper).""" + chunks = [] + async for chunk in llm_streamer(context, user_prompt, config, attachments, tools, openrouter_api_key, images): + chunks.append(chunk) + return "".join(chunks) + + async def generate_title(user_prompt: str, response: str, api_key: str = None) -> str: """ Generate a short title (3-4 words) for a Q-A pair using gpt-5-nano. diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ac48c6f..4d7bb51 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useReactFlow } from 'reactflow'; import useFlowStore from '../store/flowStore'; import { useAuthStore } from '../store/authStore'; -import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore'; +import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData } from '../store/flowStore'; import type { Edge } from 'reactflow'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -17,7 +17,7 @@ const preprocessLaTeX = (content: string): string => { .replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => `$${math}$`); }; -import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck } from 'lucide-react'; +import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users } from 'lucide-react'; interface SidebarProps { isOpen: boolean; @@ -93,6 +93,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const [showMergePreview, setShowMergePreview] = useState(false); const [isSummarizingMerge, setIsSummarizingMerge] = useState(false); + // Council mode states + const [councilStage, setCouncilStage] = useState<string>(''); + const [councilStreamBuffer, setCouncilStreamBuffer] = useState(''); + const [councilTab, setCouncilTab] = useState<'final' | 'responses' | 'rankings'>('final'); + const [councilResponseTab, setCouncilResponseTab] = useState(0); + const [councilRankingTab, setCouncilRankingTab] = useState<'aggregate' | number>('aggregate'); + const selectedNode = nodes.find((n) => n.id === selectedNodeId); // Reset stream buffer and modal states when node changes @@ -414,6 +421,178 @@ const Sidebar: React.FC<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]; + if (councilModels.length < 2) return; + + const tracesCheck = checkActiveTracesComplete(); + if (!tracesCheck.complete) return; + + const runningNodeId = selectedNode.id; + const runningPrompt = selectedNode.data.userPrompt; + const querySentAt = Date.now(); + + updateNodeData(runningNodeId, { + status: 'loading', + response: '', + querySentAt, + councilData: { stage1: null, stage2: null, stage3: null }, + }); + setStreamBuffer(''); + setCouncilStreamBuffer(''); + setCouncilStage('Starting council...'); + setStreamingNodeId(runningNodeId); + + const context = getActiveContext(runningNodeId); + const projectPath = currentBlueprintPath || 'untitled'; + const traceNodeIds = new Set<string>(); + traceNodeIds.add(runningNodeId); + const visited = new Set<string>(); + const queue = [runningNodeId]; + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + if (visited.has(currentNodeId)) continue; + visited.add(currentNodeId); + const incomingEdges = edges.filter(e => e.target === currentNodeId); + for (const edge of incomingEdges) { + if (!visited.has(edge.source)) { + traceNodeIds.add(edge.source); + queue.push(edge.source); + } + } + } + const scopes = Array.from(traceNodeIds).map(nodeId => `${projectPath}/${nodeId}`); + + const attachedFiles = selectedNode.data.attachedFileIds || []; + const effectivePrompt = runningPrompt?.trim() + ? runningPrompt + : attachedFiles.length > 0 + ? 'Please analyze the attached files.' + : ''; + + try { + const response = await fetch(`/api/run_council_stream?user=${encodeURIComponent(user?.username || 'test')}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ + node_id: runningNodeId, + incoming_contexts: [{ messages: context }], + user_prompt: effectivePrompt, + council_models: councilModels.map((m: string) => ({ model_name: m })), + chairman_model: chairmanModel, + system_prompt: selectedNode.data.systemPrompt || null, + temperature: selectedNode.data.temperature, + reasoning_effort: selectedNode.data.reasoningEffort || 'medium', + merge_strategy: selectedNode.data.mergeStrategy || 'smart', + attached_file_ids: attachedFiles, + scopes, + }), + }); + + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let sseBuffer = ''; + let stage1Results: Array<{ model: string; response: string }> = []; + let stage2Data: any = null; + let stage3Full = ''; + let stage3Model = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + sseBuffer += decoder.decode(value, { stream: true }); + + // Parse SSE events (data: {...}\n\n) + const parts = sseBuffer.split('\n\n'); + sseBuffer = parts.pop() || ''; + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith('data: ')) continue; + let evt: any; + try { + evt = JSON.parse(line.slice(6)); + } catch { continue; } + + switch (evt.type) { + case 'stage1_start': + setCouncilStage('Stage 1: Collecting responses...'); + break; + case 'stage1_model_complete': + stage1Results = [...stage1Results, evt.data]; + setCouncilStage(`Stage 1: ${stage1Results.length}/${councilModels.length} models done`); + updateNodeData(runningNodeId, { + councilData: { stage1: [...stage1Results], stage2: null, stage3: null }, + }); + break; + case 'stage1_complete': + stage1Results = evt.data; + updateNodeData(runningNodeId, { + councilData: { stage1: stage1Results, stage2: null, stage3: null }, + }); + break; + case 'stage2_start': + setCouncilStage('Stage 2: Peer ranking...'); + break; + case 'stage2_complete': + stage2Data = evt.data; + updateNodeData(runningNodeId, { + councilData: { stage1: stage1Results, stage2: stage2Data, stage3: null }, + }); + break; + case 'stage3_start': + setCouncilStage('Stage 3: Chairman synthesizing...'); + setCouncilStreamBuffer(''); + break; + case 'stage3_chunk': + stage3Full += evt.data.chunk; + setCouncilStreamBuffer(stage3Full); + setStreamBuffer(stage3Full); + break; + case 'stage3_complete': + stage3Model = evt.data.model; + stage3Full = evt.data.response; + break; + case 'complete': { + const responseReceivedAt = Date.now(); + const councilData: CouncilData = { + stage1: stage1Results, + stage2: stage2Data, + stage3: { model: stage3Model, response: stage3Full }, + }; + const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt }; + const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: stage3Full }; + updateNodeData(runningNodeId, { + status: 'success', + response: stage3Full, + responseReceivedAt, + councilData, + messages: [...context, newUserMsg, newAssistantMsg] as any, + }); + setCouncilStage(''); + generateTitle(runningNodeId, runningPrompt, stage3Full); + break; + } + case 'error': + updateNodeData(runningNodeId, { status: 'error' }); + setCouncilStage(''); + break; + } + } + } + } catch (error) { + console.error(error); + updateNodeData(runningNodeId, { status: 'error' }); + setCouncilStage(''); + } finally { + setStreamingNodeId(prev => prev === runningNodeId ? null : prev); + } + }; + const handleChange = (field: keyof NodeData, value: any) => { updateNodeData(selectedNode.id, { [field]: value }); }; @@ -1451,55 +1630,136 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { {activeTab === 'interact' && ( <div className="space-y-4"> <div> - <label className="block text-sm font-medium text-gray-700 mb-1">Model</label> - <select - value={selectedNode.data.model} - onChange={(e) => { - const newModel = e.target.value; - // Auto-set temperature to 1 for reasoning models - 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(newModel); - - if (isReasoning) { - handleChange('temperature', 1); - } - handleChange('model', newModel); - }} - className="w-full border border-gray-300 rounded-md p-2 text-sm" - > - <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> + <div className="flex items-center justify-between mb-1"> + <label className="block text-sm font-medium text-gray-700">Model</label> + <button + onClick={() => handleChange('councilMode', !selectedNode.data.councilMode)} + className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${ + selectedNode.data.councilMode + ? 'bg-amber-100 text-amber-700 border border-amber-300 dark:bg-amber-900/50 dark:text-amber-300 dark:border-amber-700' + : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' + }`} + > + <Users size={11} /> + Council {selectedNode.data.councilMode ? 'ON' : 'OFF'} + </button> + </div> + + {!selectedNode.data.councilMode ? ( + /* Single model selector */ + <select + value={selectedNode.data.model} + onChange={(e) => { + const newModel = e.target.value; + 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' + ]; + if (reasoningModels.includes(newModel)) { + handleChange('temperature', 1); + } + handleChange('model', newModel); + }} + className="w-full border border-gray-300 rounded-md p-2 text-sm" + > + <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> + ) : ( + /* Council mode: multi-model selector + chairman */ + <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> + </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)} + 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> + ))} + </select> + </div> + </div> + )} </div> {/* Trace Selector - Single Select */} @@ -1837,18 +2097,33 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { </div> )} - <button - onClick={handleRun} - disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete} - className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${ - selectedNode.data.status === 'loading' || !activeTracesCheck.complete - ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400' - : 'bg-blue-600 text-white hover:bg-blue-700' - }`} - > - {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Play size={16} />} - Run Node - </button> + {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={handleRun} + disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete} + className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${ + selectedNode.data.status === 'loading' || !activeTracesCheck.complete + ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400' + : 'bg-blue-600 text-white hover:bg-blue-700' + }`} + > + {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Play size={16} />} + Run Node + </button> + )} <div className="mt-6"> <div className="flex items-center justify-between mb-2"> @@ -1858,7 +2133,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <> <button onClick={() => { - navigator.clipboard.writeText(selectedNode.data.response || ''); + // Copy the currently-viewed content + let textToCopy = selectedNode.data.response || ''; + if (selectedNode.data.councilData && councilTab === 'responses' && selectedNode.data.councilData.stage1) { + const r = selectedNode.data.councilData.stage1[councilResponseTab]; + if (r) textToCopy = r.response; + } + navigator.clipboard.writeText(textToCopy); setCopiedResponse(true); setTimeout(() => setCopiedResponse(false), 1500); }} @@ -1902,15 +2183,153 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { )} </div> </div> - - {isEditing ? ( + + {/* Council tabbed response view */} + {selectedNode.data.councilMode && selectedNode.data.councilData && (selectedNode.data.councilData.stage1 || selectedNode.data.status === 'loading') ? ( + <div> + {/* Council tab bar */} + <div className={`flex gap-0.5 mb-2 text-xs border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + {(['final', 'responses', 'rankings'] as const).map(tab => ( + <button + key={tab} + onClick={() => setCouncilTab(tab)} + className={`px-3 py-1.5 rounded-t transition-colors capitalize ${ + councilTab === tab + ? isDark ? 'bg-gray-800 text-amber-300 border-b-2 border-amber-400' : 'bg-white text-amber-700 border-b-2 border-amber-500' + : isDark ? 'text-gray-500 hover:text-gray-300' : 'text-gray-400 hover:text-gray-600' + }`} + > + {tab === 'final' ? 'Final Answer' : tab === 'responses' ? 'Individual' : 'Rankings'} + </button> + ))} + </div> + + {/* Final Answer tab (Stage 3) */} + {councilTab === 'final' && ( + <div> + {selectedNode.data.councilData.stage3 ? ( + <div className={`text-xs mb-1 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>Chairman: {selectedNode.data.councilData.stage3.model}</div> + ) : selectedNode.data.status === 'loading' && councilStage.includes('Stage 3') ? ( + <div className={`text-xs mb-1 flex items-center gap-1 ${isDark ? 'text-amber-400' : 'text-amber-600'}`}><Loader2 className="animate-spin" size={10} /> Synthesizing...</div> + ) : null} + <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${ + isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900' + }`}> + {rawTextMode ? ( + <pre className="whitespace-pre-wrap break-words">{selectedNode.data.councilData.stage3?.response || councilStreamBuffer || ''}</pre> + ) : ( + <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}> + {preprocessLaTeX(selectedNode.data.councilData.stage3?.response || councilStreamBuffer || '')} + </ReactMarkdown> + )} + </div> + </div> + )} + + {/* Individual Responses tab (Stage 1) */} + {councilTab === 'responses' && selectedNode.data.councilData.stage1 && ( + <div> + <div className={`flex gap-0.5 mb-2 flex-wrap`}> + {selectedNode.data.councilData.stage1.map((r, i) => ( + <button + key={r.model} + onClick={() => setCouncilResponseTab(i)} + className={`px-2 py-0.5 text-xs rounded transition-colors ${ + councilResponseTab === i + ? isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700' + : isDark ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' + }`} + > + {r.model} + </button> + ))} + </div> + {(() => { + const r = selectedNode.data.councilData.stage1![councilResponseTab]; + if (!r) return null; + return ( + <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${ + isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900' + }`}> + {rawTextMode ? ( + <pre className="whitespace-pre-wrap break-words">{r.response}</pre> + ) : ( + <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>{preprocessLaTeX(r.response)}</ReactMarkdown> + )} + </div> + ); + })()} + </div> + )} + + {/* Rankings tab (Stage 2) */} + {councilTab === 'rankings' && ( + <div> + {selectedNode.data.councilData.stage2 ? ( + <div> + {/* Aggregate Rankings */} + <div className={`mb-3 p-2 rounded border ${isDark ? 'bg-gray-900 border-gray-700' : 'bg-gray-50 border-gray-200'}`}> + <div className={`text-xs font-semibold mb-1.5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Aggregate Rankings</div> + {selectedNode.data.councilData.stage2.aggregate_rankings.map((r, i) => ( + <div key={r.model} className={`flex items-center justify-between text-xs py-0.5 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> + <span className="font-mono">#{i + 1} {r.model}</span> + <span className={`${isDark ? 'text-gray-500' : 'text-gray-400'}`}>avg: {r.average_rank} ({r.rankings_count} votes)</span> + </div> + ))} + </div> + {/* Individual ranker evaluations */} + <div className={`text-xs font-semibold mb-1.5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Individual Evaluations</div> + <div className={`flex gap-0.5 mb-2 flex-wrap`}> + <button + onClick={() => setCouncilRankingTab('aggregate')} + className={`px-2 py-0.5 text-xs rounded ${councilRankingTab === 'aggregate' ? (isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700') : (isDark ? 'bg-gray-800 text-gray-400' : 'bg-gray-100 text-gray-500')}`} + >Summary</button> + {selectedNode.data.councilData.stage2.rankings.map((r, i) => ( + <button + key={r.model} + onClick={() => setCouncilRankingTab(i)} + className={`px-2 py-0.5 text-xs rounded ${councilRankingTab === i ? (isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700') : (isDark ? 'bg-gray-800 text-gray-400' : 'bg-gray-100 text-gray-500')}`} + >{r.model}</button> + ))} + </div> + {councilRankingTab !== 'aggregate' && (() => { + const r = selectedNode.data.councilData.stage2!.rankings[councilRankingTab as number]; + if (!r) return null; + // De-anonymize the ranking text + let text = r.ranking; + const mapping = selectedNode.data.councilData.stage2!.label_to_model; + for (const [label, model] of Object.entries(mapping)) { + text = text.replaceAll(label, `${label} [${model}]`); + } + return ( + <div className={`p-3 rounded-md border text-sm prose prose-sm max-w-none max-h-64 overflow-y-auto ${ + isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900' + }`}> + <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>{preprocessLaTeX(text)}</ReactMarkdown> + </div> + ); + })()} + </div> + ) : selectedNode.data.status === 'loading' ? ( + <div className={`p-3 rounded border min-h-[100px] flex items-center justify-center text-sm ${isDark ? 'bg-gray-900 border-gray-700 text-gray-500' : 'bg-gray-50 border-gray-200 text-gray-400'}`}> + <Loader2 className="animate-spin mr-2" size={14} /> Waiting for rankings... + </div> + ) : ( + <div className={`p-3 rounded border min-h-[100px] flex items-center justify-center text-sm ${isDark ? 'bg-gray-900 border-gray-700 text-gray-500' : 'bg-gray-50 border-gray-200 text-gray-400'}`}> + No ranking data + </div> + )} + </div> + )} + </div> + ) : isEditing ? ( <div className="space-y-2"> <textarea value={editedResponse} onChange={(e) => setEditedResponse(e.target.value)} className={`w-full border rounded-md p-2 text-sm min-h-[200px] font-mono focus:ring-2 focus:ring-blue-500 ${ - isDark - ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500' + isDark + ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500' : 'bg-white border-blue-300 text-gray-900' }`} /> diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx index 7542860..adeb076 100644 --- a/frontend/src/components/nodes/LLMNode.tsx +++ b/frontend/src/components/nodes/LLMNode.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Handle, Position, type NodeProps, useUpdateNodeInternals, useEdges } from 'reactflow'; import type { NodeData, MergedTrace, Trace } from '../../store/flowStore'; -import { Loader2, MessageSquare } from 'lucide-react'; +import { Loader2, MessageSquare, Users } from 'lucide-react'; import useFlowStore from '../../store/flowStore'; const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { @@ -96,13 +96,21 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { ? isDark ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed' : 'bg-gray-100 border-gray-300 opacity-50 cursor-not-allowed' - : selected - ? isDark - ? 'bg-gray-800 border-blue-400' - : 'bg-white border-blue-500' - : isDark - ? 'bg-gray-800 border-gray-600' - : 'bg-white border-gray-200' + : data.councilMode + ? selected + ? isDark + ? 'bg-gray-800 border-amber-400' + : 'bg-white border-amber-500' + : isDark + ? 'bg-gray-800 border-amber-600/60' + : 'bg-white border-amber-300' + : selected + ? isDark + ? 'bg-gray-800 border-blue-400' + : 'bg-white border-blue-500' + : isDark + ? 'bg-gray-800 border-gray-600' + : 'bg-white border-gray-200' }`} style={{ pointerEvents: isDisabled ? 'none' : 'auto', minHeight: minHandleHeight }} onMouseEnter={() => setShowPreview(true)} @@ -134,10 +142,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { }`}> {data.status === 'loading' ? ( <Loader2 className="w-4 h-4 animate-spin text-blue-500" /> + ) : data.councilMode ? ( + <Users className={`w-4 h-4 ${isDark ? 'text-amber-400' : 'text-amber-600'}`} /> ) : ( <MessageSquare className={`w-4 h-4 ${ - isDisabled - ? 'text-gray-500' + isDisabled + ? 'text-gray-500' : isDark ? 'text-gray-400' : 'text-gray-600' }`} /> )} @@ -151,7 +161,11 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { {data.label} {isDisabled && <span className="text-xs ml-1">(disabled)</span>} </div> - <div className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>{data.model}</div> + <div className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + {data.councilMode + ? `Council (${(data.councilModels || []).length})` + : data.model} + </div> </div> </div> diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts index 3590580..944b846 100644 --- a/frontend/src/store/flowStore.ts +++ b/frontend/src/store/flowStore.ts @@ -73,6 +73,16 @@ export interface MergedTrace { summarizedContent?: string; // For summary strategy, stores the LLM-generated summary } +export interface CouncilData { + stage1: Array<{ model: string; response: string }> | null; + stage2: { + rankings: Array<{ model: string; ranking: string; parsed_ranking: string[] }>; + label_to_model: Record<string, string>; + aggregate_rankings: Array<{ model: string; average_rank: number; rankings_count: number }>; + } | null; + stage3: { model: string; response: string } | null; +} + export interface NodeData { label: string; model: string; @@ -85,22 +95,28 @@ export interface NodeData { reasoningEffort: 'low' | 'medium' | 'high'; // For OpenAI reasoning models attachedFileIds?: string[]; // IDs of files attached to this node disabled?: boolean; // Greyed out, no interaction - + + // Council mode + councilMode?: boolean; + councilModels?: string[]; + chairmanModel?: string; + councilData?: CouncilData; + // Traces logic traces: Trace[]; // INCOMING Traces outgoingTraces: Trace[]; // ALL Outgoing (inherited + self + forks + merged) forkedTraces: Trace[]; // Manually created forks from "New" handle mergedTraces: MergedTrace[]; // Merged traces from multiple inputs - activeTraceIds: string[]; - - response: string; + activeTraceIds: string[]; + + response: string; status: NodeStatus; inputs: number; - + // Timestamps for merge logic querySentAt?: number; // Unix timestamp when query was sent responseReceivedAt?: number; // Unix timestamp when response was received - + [key: string]: any; } |
