summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <blackhao0426@gmail.com>2026-02-13 21:43:34 +0000
committerYurenHao0426 <blackhao0426@gmail.com>2026-02-13 21:43:34 +0000
commit77be59bc0a6353e98846b9c9bfa2d566efea8b1f (patch)
treec0cc008b4705eb50616e6656f8fbc0e5b3475307
parent30921396cb53f61eca90c85d692e0fc06d0f5ff4 (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.py162
-rw-r--r--backend/app/schemas.py18
-rw-r--r--backend/app/services/council.py322
-rw-r--r--backend/app/services/llm.py29
-rw-r--r--frontend/src/components/Sidebar.tsx555
-rw-r--r--frontend/src/components/nodes/LLMNode.tsx36
-rw-r--r--frontend/src/store/flowStore.ts28
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;
}