summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/app/main.py129
-rw-r--r--backend/app/schemas.py29
-rw-r--r--backend/app/services/debate.py371
-rw-r--r--frontend/src/components/Sidebar.tsx695
-rw-r--r--frontend/src/store/flowStore.ts27
5 files changed, 1236 insertions, 15 deletions
diff --git a/backend/app/main.py b/backend/app/main.py
index 304c74f..89c5dd0 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -8,9 +8,10 @@ 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, CouncilRunRequest
+from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort, CouncilRunRequest, DebateRunRequest, DebateJudgeMode
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.services.debate import debate_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
@@ -584,6 +585,132 @@ async def run_council_stream(
)
+@app.post("/api/run_debate_stream")
+async def run_debate_stream(
+ request: DebateRunRequest,
+ user: str = DEFAULT_USER,
+ current_user: User | None = Depends(get_current_user_optional),
+):
+ """
+ Run a multi-round LLM Debate and stream SSE events.
+ """
+ resolved = resolve_user(current_user, user)
+ username = resolved.username if resolved else DEFAULT_USER
+
+ # Merge incoming contexts
+ 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 + tools for each debate member
+ member_configs: list[LLMConfig] = []
+ attachments_per_model: list[list[dict] | None] = []
+ tools_per_model: list[list[dict] | None] = []
+
+ for member in request.debate_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=member.temperature if member.temperature is not None else request.temperature,
+ system_prompt=request.system_prompt,
+ api_key=api_key,
+ reasoning_effort=member.reasoning_effort if member.reasoning_effort is not None else request.reasoning_effort,
+ enable_google_search=member.enable_google_search if member.enable_google_search is not None else request.enable_google_search,
+ )
+ member_configs.append(config)
+
+ # Prepare provider-specific file attachments
+ tools: list[dict] = []
+ attachments: list[dict] = []
+ 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 judge config (if external_judge mode)
+ judge_config = None
+ if request.judge_mode == DebateJudgeMode.EXTERNAL_JUDGE and request.judge_model:
+ judge = request.judge_model
+ judge_provider = resolve_provider(judge.model_name)
+ judge_api_key = get_user_api_key(resolved, judge_provider.value)
+ judge_config = LLMConfig(
+ provider=judge_provider,
+ model_name=judge.model_name,
+ temperature=judge.temperature if judge.temperature is not None else request.temperature,
+ system_prompt=request.system_prompt,
+ api_key=judge_api_key,
+ reasoning_effort=judge.reasoning_effort if judge.reasoning_effort is not None else request.reasoning_effort,
+ enable_google_search=judge.enable_google_search if judge.enable_google_search is not None else request.enable_google_search,
+ )
+
+ return StreamingResponse(
+ debate_event_stream(
+ user_prompt=request.user_prompt,
+ context=execution_context,
+ member_configs=member_configs,
+ judge_config=judge_config,
+ judge_mode=request.judge_mode,
+ debate_format=request.debate_format,
+ max_rounds=request.max_rounds,
+ custom_format_prompt=request.custom_format_prompt,
+ 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
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 4213f15..7a657a3 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -76,6 +76,35 @@ class CouncilRunRequest(BaseModel):
attached_file_ids: List[str] = Field(default_factory=list)
scopes: List[str] = Field(default_factory=list)
+class DebateJudgeMode(str, Enum):
+ EXTERNAL_JUDGE = "external_judge"
+ SELF_CONVERGENCE = "self_convergence"
+ DISPLAY_ONLY = "display_only"
+
+class DebateFormat(str, Enum):
+ FREE_DISCUSSION = "free_discussion"
+ STRUCTURED_OPPOSITION = "structured_opposition"
+ ITERATIVE_IMPROVEMENT = "iterative_improvement"
+ CUSTOM = "custom"
+
+class DebateRunRequest(BaseModel):
+ node_id: str
+ incoming_contexts: List[Context] = []
+ user_prompt: str
+ debate_models: List[CouncilMemberConfig] # 2-6 models
+ judge_model: Optional[CouncilMemberConfig] = None
+ judge_mode: DebateJudgeMode = DebateJudgeMode.EXTERNAL_JUDGE
+ debate_format: DebateFormat = DebateFormat.FREE_DISCUSSION
+ custom_format_prompt: Optional[str] = None
+ max_rounds: int = 5
+ system_prompt: Optional[str] = None
+ temperature: float = 0.7
+ reasoning_effort: ReasoningEffort = ReasoningEffort.MEDIUM
+ enable_google_search: bool = False
+ 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/debate.py b/backend/app/services/debate.py
new file mode 100644
index 0000000..d409cb9
--- /dev/null
+++ b/backend/app/services/debate.py
@@ -0,0 +1,371 @@
+"""Multi-round LLM Debate orchestration for ContextFlow."""
+
+import asyncio
+import json
+import logging
+from typing import AsyncGenerator, Dict, List, Any, Optional
+
+from app.schemas import Context, LLMConfig, DebateFormat, DebateJudgeMode
+from app.services.llm import query_model_full, llm_streamer
+
+logger = logging.getLogger("contextflow.debate")
+
+
+def _sse_event(data: dict) -> str:
+ """Format a dict as an SSE data line."""
+ return f"data: {json.dumps(data)}\n\n"
+
+
+def build_debate_prompt(
+ user_query: str,
+ debate_history: List[Dict[str, Any]],
+ model_name: str,
+ round_num: int,
+ debate_format: DebateFormat,
+ custom_prompt: Optional[str] = None,
+ model_index: int = 0,
+ total_models: int = 2,
+) -> str:
+ """Build the prompt for a debater based on format and history."""
+ history_text = ""
+ if debate_history:
+ for past_round in debate_history:
+ rn = past_round["round"]
+ history_text += f"\n--- Round {rn} ---\n"
+ for resp in past_round["responses"]:
+ history_text += f"\n[{resp['model']}]:\n{resp['response']}\n"
+
+ if debate_format == DebateFormat.FREE_DISCUSSION:
+ if round_num == 1:
+ return (
+ f"You are participating in a roundtable discussion about the following question:\n\n"
+ f'"{user_query}"\n\n'
+ f"Provide your perspective and answer to this question."
+ )
+ return (
+ f"You are participating in a roundtable discussion about the following question:\n\n"
+ f'"{user_query}"\n\n'
+ f"Here is the discussion so far:\n{history_text}\n\n"
+ f"This is round {round_num}. Consider what others have said, respond to their points, "
+ f"and refine or defend your position."
+ )
+
+ if debate_format == DebateFormat.STRUCTURED_OPPOSITION:
+ roles = ["FOR", "AGAINST", "DEVIL'S ADVOCATE", "MEDIATOR", "CRITIC", "SYNTHESIZER"]
+ role = roles[model_index % len(roles)]
+ if round_num == 1:
+ return (
+ f"You are arguing {role} the following position in a structured debate:\n\n"
+ f'"{user_query}"\n\n'
+ f"Present your strongest arguments from the {role} perspective."
+ )
+ return (
+ f"You are arguing {role} the following position in a structured debate:\n\n"
+ f'"{user_query}"\n\n'
+ f"Debate history:\n{history_text}\n\n"
+ f"This is round {round_num}. Respond to the other participants' arguments "
+ f"while maintaining your {role} position. Address their strongest points."
+ )
+
+ if debate_format == DebateFormat.ITERATIVE_IMPROVEMENT:
+ if round_num == 1:
+ return (
+ f"You are participating in an iterative improvement exercise on the following question:\n\n"
+ f'"{user_query}"\n\n'
+ f"Provide your best answer."
+ )
+ return (
+ f"You are participating in an iterative improvement exercise on the following question:\n\n"
+ f'"{user_query}"\n\n'
+ f"Here are the previous answers from all participants:\n{history_text}\n\n"
+ f"This is round {round_num}. Critique the other participants' answers, identify flaws or gaps, "
+ f"and provide an improved answer that incorporates the best insights from everyone."
+ )
+
+ if debate_format == DebateFormat.CUSTOM and custom_prompt:
+ prompt = custom_prompt
+ prompt = prompt.replace("{history}", history_text or "(No history yet)")
+ prompt = prompt.replace("{round}", str(round_num))
+ prompt = prompt.replace("{model_name}", model_name)
+ prompt = prompt.replace("{question}", user_query)
+ return prompt
+
+ # Fallback to free discussion
+ if round_num == 1:
+ return f'Provide your answer to the following question:\n\n"{user_query}"'
+ return (
+ f'Question: "{user_query}"\n\n'
+ f"Previous discussion:\n{history_text}\n\n"
+ f"Round {round_num}: Provide your updated response."
+ )
+
+
+async def debate_round(
+ configs: List[LLMConfig],
+ context: Context,
+ user_prompt: str,
+ debate_history: List[Dict[str, Any]],
+ round_num: int,
+ debate_format: DebateFormat,
+ custom_prompt: Optional[str] = None,
+ 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]:
+ """Query all debate models in parallel for one round, yielding as each completes."""
+
+ async def _query_one(idx: int, config: LLMConfig) -> Dict[str, Any]:
+ prompt = build_debate_prompt(
+ user_prompt, debate_history, config.model_name,
+ round_num, debate_format, custom_prompt,
+ model_index=idx, total_models=len(configs),
+ )
+ 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, prompt, config,
+ attachments=atts, tools=tls,
+ openrouter_api_key=openrouter_api_key,
+ images=images if round_num == 1 else None, # Only send images in round 1
+ )
+ return {"model": config.model_name, "response": response}
+ except Exception as e:
+ logger.error("Debate round %d failed for %s: %s", round_num, config.model_name, e)
+ return {"model": config.model_name, "response": f"[Error: {e}]"}
+
+ tasks = {
+ asyncio.ensure_future(_query_one(i, cfg)): i
+ for i, cfg in enumerate(configs)
+ }
+ for coro in asyncio.as_completed(tasks.keys()):
+ result = await coro
+ yield result
+
+
+async def judge_evaluate_round(
+ judge_config: LLMConfig,
+ debate_history: List[Dict[str, Any]],
+ user_query: str,
+ openrouter_api_key: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Judge decides if debate should continue after a round."""
+ last_round = len(debate_history)
+ history_text = ""
+ for past_round in debate_history:
+ rn = past_round["round"]
+ history_text += f"\n--- Round {rn} ---\n"
+ for resp in past_round["responses"]:
+ history_text += f"\n[{resp['model']}]:\n{resp['response']}\n"
+
+ prompt = (
+ f"You are the judge of a multi-model debate on the following question:\n"
+ f'"{user_query}"\n\n'
+ f"Debate history (Round 1 to {last_round}):\n{history_text}\n\n"
+ f"Evaluate whether the debate has reached a satisfactory conclusion.\n"
+ f"Consider: Have the key points been thoroughly explored? Is there consensus?\n"
+ f"Are there unresolved disagreements worth continuing?\n\n"
+ f"Respond with exactly one of:\n"
+ f"CONTINUE - if the debate should go on (explain why briefly)\n"
+ f"STOP - if a clear conclusion has been reached (explain why briefly)"
+ )
+
+ empty_context = Context(messages=[])
+ try:
+ response = await query_model_full(
+ empty_context, prompt, judge_config,
+ openrouter_api_key=openrouter_api_key,
+ )
+ should_continue = "CONTINUE" in response.upper().split("\n")[0]
+ return {"continue": should_continue, "reasoning": response}
+ except Exception as e:
+ logger.error("Judge evaluation failed: %s", e)
+ return {"continue": False, "reasoning": f"[Judge error: {e}]"}
+
+
+async def check_self_convergence(
+ configs: List[LLMConfig],
+ round_responses: List[Dict[str, Any]],
+ openrouter_api_key: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Check if debate responses have converged using the first available model."""
+ responses_text = "\n\n".join(
+ f"[{r['model']}]:\n{r['response']}" for r in round_responses
+ )
+ prompt = (
+ f"Below are the responses from the latest round of a debate:\n\n"
+ f"{responses_text}\n\n"
+ f"Do all participants essentially agree on the answer? Respond ONLY with:\n"
+ f"CONVERGED - if there is clear consensus\n"
+ f"DIVERGENT - if there are still significant disagreements"
+ )
+
+ empty_context = Context(messages=[])
+ # Use the first config as the convergence checker
+ check_config = configs[0]
+ try:
+ response = await query_model_full(
+ empty_context, prompt, check_config,
+ openrouter_api_key=openrouter_api_key,
+ )
+ converged = "CONVERGED" in response.upper().split("\n")[0]
+ return {"converged": converged, "reasoning": response}
+ except Exception as e:
+ logger.error("Convergence check failed: %s", e)
+ return {"converged": False, "reasoning": f"[Convergence check error: {e}]"}
+
+
+async def judge_final_verdict(
+ judge_config: LLMConfig,
+ debate_history: List[Dict[str, Any]],
+ user_query: str,
+ openrouter_api_key: Optional[str] = None,
+) -> AsyncGenerator[str, None]:
+ """Stream the judge's final verdict/synthesis."""
+ history_text = ""
+ for past_round in debate_history:
+ rn = past_round["round"]
+ history_text += f"\n--- Round {rn} ---\n"
+ for resp in past_round["responses"]:
+ history_text += f"\n[{resp['model']}]:\n{resp['response']}\n"
+
+ prompt = (
+ f"You are the judge of a multi-model debate. Below is the full debate transcript.\n\n"
+ f'Question: "{user_query}"\n\n'
+ f"{history_text}\n\n"
+ f"As the judge, provide:\n"
+ f"1. A summary of the key arguments from each participant\n"
+ f"2. An evaluation of the strengths and weaknesses of each position\n"
+ f"3. Your final verdict: the best, most accurate, and most comprehensive answer "
+ f"to the original question, synthesizing the best insights from the debate."
+ )
+
+ empty_context = Context(messages=[])
+ async for chunk in llm_streamer(
+ empty_context, prompt, judge_config,
+ openrouter_api_key=openrouter_api_key,
+ ):
+ yield chunk
+
+
+async def debate_event_stream(
+ user_prompt: str,
+ context: Context,
+ member_configs: List[LLMConfig],
+ judge_config: Optional[LLMConfig],
+ judge_mode: DebateJudgeMode,
+ debate_format: DebateFormat,
+ max_rounds: int = 5,
+ custom_format_prompt: Optional[str] = None,
+ 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 debate process."""
+
+ model_names = [c.model_name for c in member_configs]
+ yield _sse_event({
+ "type": "debate_start",
+ "data": {
+ "max_rounds": max_rounds,
+ "format": debate_format.value,
+ "judge_mode": judge_mode.value,
+ "models": model_names,
+ },
+ })
+
+ debate_history: List[Dict[str, Any]] = []
+
+ for round_num in range(1, max_rounds + 1):
+ yield _sse_event({"type": "round_start", "data": {"round": round_num}})
+
+ round_responses: List[Dict[str, Any]] = []
+ async for result in debate_round(
+ member_configs, context, user_prompt,
+ debate_history, round_num, debate_format, custom_format_prompt,
+ attachments_per_model=attachments_per_model,
+ tools_per_model=tools_per_model,
+ openrouter_api_key=openrouter_api_key,
+ images=images,
+ ):
+ round_responses.append(result)
+ yield _sse_event({
+ "type": "round_model_complete",
+ "data": {"round": round_num, "model": result["model"], "response": result["response"]},
+ })
+
+ debate_history.append({"round": round_num, "responses": round_responses})
+
+ yield _sse_event({
+ "type": "round_complete",
+ "data": {"round": round_num, "responses": round_responses},
+ })
+
+ if not round_responses:
+ yield _sse_event({
+ "type": "error",
+ "data": {"message": "All debate models failed to respond."},
+ })
+ return
+
+ # Check stop condition (skip on last round)
+ if round_num < max_rounds:
+ if judge_mode == DebateJudgeMode.EXTERNAL_JUDGE and judge_config:
+ decision = await judge_evaluate_round(
+ judge_config, debate_history, user_prompt,
+ openrouter_api_key=openrouter_api_key,
+ )
+ yield _sse_event({
+ "type": "judge_decision",
+ "data": {"round": round_num, **decision},
+ })
+ if not decision["continue"]:
+ break
+
+ elif judge_mode == DebateJudgeMode.SELF_CONVERGENCE:
+ convergence = await check_self_convergence(
+ member_configs, round_responses,
+ openrouter_api_key=openrouter_api_key,
+ )
+ yield _sse_event({
+ "type": "convergence_check",
+ "data": {"round": round_num, **convergence},
+ })
+ if convergence["converged"]:
+ break
+ # DISPLAY_ONLY: just continue to next round
+
+ # Final synthesis
+ if judge_mode == DebateJudgeMode.EXTERNAL_JUDGE and judge_config:
+ yield _sse_event({
+ "type": "final_start",
+ "data": {"model": judge_config.model_name},
+ })
+
+ full_verdict = ""
+ async for chunk in judge_final_verdict(
+ judge_config, debate_history, user_prompt,
+ openrouter_api_key=openrouter_api_key,
+ ):
+ full_verdict += chunk
+ yield _sse_event({"type": "final_chunk", "data": {"chunk": chunk}})
+
+ yield _sse_event({
+ "type": "final_complete",
+ "data": {"model": judge_config.model_name, "response": full_verdict},
+ })
+
+ elif judge_mode == DebateJudgeMode.SELF_CONVERGENCE:
+ # Use the last round's responses as the final answer
+ last_responses = debate_history[-1]["responses"] if debate_history else []
+ # Pick the longest response as the "best" convergent answer
+ if last_responses:
+ best = max(last_responses, key=lambda r: len(r.get("response", "")))
+ yield _sse_event({
+ "type": "final_complete",
+ "data": {"model": best["model"], "response": best["response"]},
+ })
+
+ yield _sse_event({"type": "debate_complete"})
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 64cd79a..aeb164b 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useReactFlow } from 'reactflow';
import useFlowStore from '../store/flowStore';
import { useAuthStore } from '../store/authStore';
-import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData, CouncilMemberConfig } from '../store/flowStore';
+import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData, CouncilMemberConfig, DebateData, DebateRound } 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, ChevronDown, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users } from 'lucide-react';
+import { Play, Settings, Info, ChevronLeft, ChevronRight, ChevronDown, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users, MessageSquare } from 'lucide-react';
interface SidebarProps {
isOpen: boolean;
@@ -109,6 +109,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
const [quickChatCouncilData, setQuickChatCouncilData] = useState<CouncilData | null>(null);
const [quickChatCouncilConfigOpen, setQuickChatCouncilConfigOpen] = useState(false);
+ // Debate mode states
+ const [debateStage, setDebateStage] = useState<string>('');
+ const [debateStreamBuffer, setDebateStreamBuffer] = useState('');
+ const [debateTab, setDebateTab] = useState<'final' | 'timeline' | 'per-model'>('timeline');
+ const [debateTimelineExpandedRounds, setDebateTimelineExpandedRounds] = useState<Set<number>>(new Set());
+ const [debatePerModelSelected, setDebatePerModelSelected] = useState<string>('');
+
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
// Reset stream buffer and modal states when node changes
@@ -640,6 +647,263 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
}
};
+ // Debate mode: multi-round LLM debate execution
+ const handleRunDebate = async () => {
+ if (!selectedNode) return;
+ const debateModels: CouncilMemberConfig[] = selectedNode.data.debateModels || [];
+ if (debateModels.length < 2) return;
+
+ const tracesCheck = checkActiveTracesComplete();
+ if (!tracesCheck.complete) return;
+
+ const runningNodeId = selectedNode.id;
+ const runningPrompt = selectedNode.data.userPrompt;
+ const querySentAt = Date.now();
+ const judgeMode = selectedNode.data.debateJudgeMode || 'external_judge';
+ const debateFormat = selectedNode.data.debateFormat || 'free_discussion';
+ const maxRounds = selectedNode.data.debateMaxRounds || 5;
+
+ updateNodeData(runningNodeId, {
+ status: 'loading',
+ response: '',
+ querySentAt,
+ debateData: {
+ rounds: [],
+ finalVerdict: null,
+ config: { judgeMode, format: debateFormat, maxRounds },
+ },
+ });
+ setStreamBuffer('');
+ setDebateStreamBuffer('');
+ setDebateStage('Starting debate...');
+ 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 judgeModelConfig = selectedNode.data.judgeModel || debateModels[0];
+ const response = await fetch(`/api/run_debate_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,
+ debate_models: debateModels.map((cfg) => ({
+ model_name: cfg.model,
+ temperature: cfg.temperature ?? null,
+ reasoning_effort: cfg.reasoningEffort ?? null,
+ enable_google_search: cfg.enableWebSearch ?? null,
+ })),
+ judge_model: judgeMode === 'external_judge' ? {
+ model_name: judgeModelConfig.model,
+ temperature: judgeModelConfig.temperature ?? null,
+ reasoning_effort: judgeModelConfig.reasoningEffort ?? null,
+ enable_google_search: judgeModelConfig.enableWebSearch ?? null,
+ } : null,
+ judge_mode: judgeMode,
+ debate_format: debateFormat,
+ custom_format_prompt: selectedNode.data.debateCustomPrompt || null,
+ max_rounds: maxRounds,
+ system_prompt: selectedNode.data.systemPrompt || null,
+ temperature: selectedNode.data.temperature,
+ reasoning_effort: selectedNode.data.reasoningEffort || 'medium',
+ enable_google_search: selectedNode.data.enableGoogleSearch !== false,
+ merge_strategy: selectedNode.data.mergeStrategy || 'smart',
+ attached_file_ids: attachedFiles,
+ scopes,
+ }),
+ });
+
+ if (!response.body) return;
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let sseBuffer = '';
+ const debateRounds: DebateRound[] = [];
+ let currentRound = 0;
+ let currentRoundResponses: Array<{ model: string; response: string }> = [];
+ let finalModel = '';
+ let finalFull = '';
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ sseBuffer += decoder.decode(value, { stream: true });
+
+ const parts = sseBuffer.split('\n\n');
+ sseBuffer = parts.pop() || '';
+
+ for (const part of parts) {
+ const line = part.trim();
+ if (!line.startsWith('data: ')) continue;
+ let evt: any;
+ try {
+ evt = JSON.parse(line.slice(6));
+ } catch { continue; }
+
+ switch (evt.type) {
+ case 'debate_start':
+ setDebateStage(`Debate started (${evt.data.models.length} models, max ${evt.data.max_rounds} rounds)`);
+ break;
+ case 'round_start':
+ currentRound = evt.data.round;
+ currentRoundResponses = [];
+ setDebateStage(`Round ${currentRound}/${maxRounds}: Collecting responses...`);
+ break;
+ case 'round_model_complete':
+ currentRoundResponses = [...currentRoundResponses, { model: evt.data.model, response: evt.data.response }];
+ setDebateStage(`Round ${currentRound}/${maxRounds}: ${currentRoundResponses.length}/${debateModels.length} models done`);
+ break;
+ case 'round_complete': {
+ const roundData: DebateRound = { round: evt.data.round, responses: evt.data.responses };
+ debateRounds.push(roundData);
+ updateNodeData(runningNodeId, {
+ debateData: {
+ rounds: [...debateRounds],
+ finalVerdict: null,
+ config: { judgeMode, format: debateFormat, maxRounds },
+ },
+ });
+ break;
+ }
+ case 'judge_decision': {
+ const lastRound = debateRounds[debateRounds.length - 1];
+ if (lastRound) {
+ lastRound.judgeDecision = { continue: evt.data.continue, reasoning: evt.data.reasoning };
+ updateNodeData(runningNodeId, {
+ debateData: {
+ rounds: [...debateRounds],
+ finalVerdict: null,
+ config: { judgeMode, format: debateFormat, maxRounds },
+ },
+ });
+ }
+ if (!evt.data.continue) {
+ setDebateStage('Judge stopped debate. Generating final verdict...');
+ } else {
+ setDebateStage(`Judge: Continue to round ${currentRound + 1}...`);
+ }
+ break;
+ }
+ case 'convergence_check': {
+ const lastRound2 = debateRounds[debateRounds.length - 1];
+ if (lastRound2) {
+ lastRound2.converged = evt.data.converged;
+ updateNodeData(runningNodeId, {
+ debateData: {
+ rounds: [...debateRounds],
+ finalVerdict: null,
+ config: { judgeMode, format: debateFormat, maxRounds },
+ },
+ });
+ }
+ if (evt.data.converged) {
+ setDebateStage('Consensus reached!');
+ }
+ break;
+ }
+ case 'final_start':
+ finalModel = evt.data.model;
+ setDebateStage('Judge synthesizing final verdict...');
+ setDebateStreamBuffer('');
+ break;
+ case 'final_chunk':
+ finalFull += evt.data.chunk;
+ setDebateStreamBuffer(finalFull);
+ setStreamBuffer(finalFull);
+ break;
+ case 'final_complete': {
+ finalModel = evt.data.model;
+ finalFull = evt.data.response;
+ const responseReceivedAt = Date.now();
+ const debateData: DebateData = {
+ rounds: debateRounds,
+ finalVerdict: { model: finalModel, response: finalFull },
+ config: { judgeMode, format: debateFormat, maxRounds },
+ };
+ const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt };
+ const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: finalFull };
+ updateNodeData(runningNodeId, {
+ status: 'success',
+ response: finalFull,
+ responseReceivedAt,
+ debateData,
+ messages: [...context, newUserMsg, newAssistantMsg] as any,
+ });
+ setDebateStage('');
+ generateTitle(runningNodeId, runningPrompt, finalFull);
+ break;
+ }
+ case 'debate_complete': {
+ // If no final verdict (display_only or self_convergence without explicit final_complete)
+ const currentNode = nodes.find(n => n.id === runningNodeId);
+ if (currentNode?.data.status === 'loading') {
+ const responseReceivedAt = Date.now();
+ const lastRoundResp = debateRounds.length > 0 ? debateRounds[debateRounds.length - 1].responses : [];
+ const bestResponse = lastRoundResp.length > 0
+ ? lastRoundResp.reduce((a, b) => a.response.length > b.response.length ? a : b).response
+ : '';
+ const debateData: DebateData = {
+ rounds: debateRounds,
+ finalVerdict: finalFull ? { model: finalModel, response: finalFull } : null,
+ config: { judgeMode, format: debateFormat, maxRounds },
+ };
+ const displayResponse = finalFull || bestResponse;
+ const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt };
+ const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: displayResponse };
+ updateNodeData(runningNodeId, {
+ status: 'success',
+ response: displayResponse,
+ responseReceivedAt,
+ debateData,
+ messages: [...context, newUserMsg, newAssistantMsg] as any,
+ });
+ setDebateStage('');
+ if (displayResponse) generateTitle(runningNodeId, runningPrompt, displayResponse);
+ }
+ break;
+ }
+ case 'error':
+ updateNodeData(runningNodeId, { status: 'error' });
+ setDebateStage('');
+ break;
+ }
+ }
+ }
+ } catch (error) {
+ console.error(error);
+ updateNodeData(runningNodeId, { status: 'error' });
+ setDebateStage('');
+ } finally {
+ setStreamingNodeId(prev => prev === runningNodeId ? null : prev);
+ }
+ };
+
const handleChange = (field: keyof NodeData, value: any) => {
updateNodeData(selectedNode.id, { [field]: value });
};
@@ -1844,20 +2108,218 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
<div>
<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 className="flex gap-1">
+ <button
+ onClick={() => {
+ const next = !selectedNode.data.councilMode;
+ handleChange('councilMode', next);
+ if (next) handleChange('debateMode', false);
+ }}
+ 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>
+ <button
+ onClick={() => {
+ const next = !selectedNode.data.debateMode;
+ handleChange('debateMode', next);
+ if (next) handleChange('councilMode', false);
+ }}
+ className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${
+ selectedNode.data.debateMode
+ ? 'bg-cyan-100 text-cyan-700 border border-cyan-300 dark:bg-cyan-900/50 dark:text-cyan-300 dark:border-cyan-700'
+ : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
+ }`}
+ >
+ <MessageSquare size={11} />
+ Debate {selectedNode.data.debateMode ? 'ON' : 'OFF'}
+ </button>
+ </div>
</div>
- {!selectedNode.data.councilMode ? (
+ {selectedNode.data.debateMode ? (
+ /* Debate mode: multi-model selector + judge config */
+ <div className={`space-y-2 p-2 rounded border ${isDark ? 'bg-gray-900 border-cyan-800/50' : 'bg-cyan-50/50 border-cyan-200'}`}>
+ <div>
+ <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Debate Participants (pick 2-6)</label>
+ <div className="grid grid-cols-1 gap-0.5 max-h-40 overflow-y-auto">
+ {(() => {
+ const debateModelsList = [
+ { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4.5' },
+ { value: 'claude-opus-4', label: 'claude-opus-4' },
+ { value: 'claude-opus-4-5', label: 'claude-opus-4.5' },
+ { value: 'claude-opus-4-6', label: 'claude-opus-4.6' },
+ { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
+ { value: 'gemini-2.5-flash-lite', label: 'gemini-2.5-flash-lite' },
+ { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' },
+ { value: 'gpt-4.1', label: 'gpt-4.1' },
+ { value: 'gpt-4o', label: 'gpt-4o' },
+ { value: 'gpt-5', label: 'gpt-5' },
+ { value: 'gpt-5-mini', label: 'gpt-5-mini' },
+ { value: 'gpt-5-nano', label: 'gpt-5-nano' },
+ { value: 'gpt-5.1', label: 'gpt-5.1' },
+ { value: 'gpt-5.2', label: 'gpt-5.2' },
+ { value: 'o3', label: 'o3', premium: true },
+ ];
+ const members: CouncilMemberConfig[] = selectedNode.data.debateModels || [];
+ const isReasoningModel = (v: string) => ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.2', 'gpt-5.2-pro', 'o3'].includes(v);
+ const updateDebateMember = (modelName: string, field: string, value: any) => {
+ const updated = [...members];
+ const idx = updated.findIndex(c => c.model === modelName);
+ if (idx >= 0) {
+ updated[idx] = { ...updated[idx], [field]: value };
+ handleChange('debateModels', updated);
+ }
+ };
+ return debateModelsList.map(m => {
+ const selected = members.some(c => c.model === m.value);
+ const disabled = (m as any).premium && !canUsePremiumModels;
+ const cfg = members.find(c => c.model === m.value);
+ return (
+ <div key={m.value}>
+ <label className={`flex items-center gap-1.5 text-xs py-0.5 px-1 rounded cursor-pointer ${
+ disabled ? 'opacity-40 cursor-not-allowed' : selected
+ ? isDark ? 'bg-cyan-900/40 text-cyan-200' : 'bg-cyan-100 text-cyan-800'
+ : isDark ? 'hover:bg-gray-800 text-gray-300' : 'hover:bg-gray-100 text-gray-700'
+ }`}>
+ <input
+ type="checkbox"
+ checked={selected}
+ disabled={disabled}
+ onChange={() => {
+ const next = selected
+ ? members.filter(c => c.model !== m.value)
+ : [...members, { model: m.value }];
+ handleChange('debateModels', next);
+ }}
+ className="w-3 h-3 accent-cyan-500"
+ />
+ {m.label}
+ </label>
+ {selected && cfg && (
+ <div className={`flex items-center gap-2 ml-5 mt-0.5 mb-1 px-1 py-0.5 text-xs rounded ${isDark ? 'bg-gray-800/60' : 'bg-gray-100/80'}`}>
+ <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+ T:
+ <input
+ type="number"
+ min={0} max={2} step={0.1}
+ value={cfg.temperature ?? selectedNode.data.temperature}
+ disabled={isReasoningModel(m.value)}
+ onChange={(e) => updateDebateMember(m.value, 'temperature', parseFloat(e.target.value) || 0)}
+ className={`w-12 border rounded px-1 py-0 text-xs ${isReasoningModel(m.value) ? 'opacity-50 cursor-not-allowed' : ''} ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+ />
+ </label>
+ {isReasoningModel(m.value) && (
+ <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+ Effort:
+ <select
+ value={cfg.reasoningEffort ?? selectedNode.data.reasoningEffort ?? 'medium'}
+ onChange={(e) => updateDebateMember(m.value, 'reasoningEffort', e.target.value)}
+ className={`border rounded px-1 py-0 text-xs ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+ >
+ <option value="low">Low</option>
+ <option value="medium">Med</option>
+ <option value="high">High</option>
+ </select>
+ </label>
+ )}
+ </div>
+ )}
+ </div>
+ );
+ });
+ })()}
+ </div>
+ </div>
+
+ {/* Judge Mode */}
+ <div>
+ <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Judge Mode</label>
+ <select
+ value={selectedNode.data.debateJudgeMode || 'external_judge'}
+ onChange={(e) => handleChange('debateJudgeMode', e.target.value)}
+ className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+ >
+ <option value="external_judge">External Judge</option>
+ <option value="self_convergence">Self-Convergence</option>
+ <option value="display_only">Display Only</option>
+ </select>
+ </div>
+
+ {/* Judge Model (only for external_judge) */}
+ {(selectedNode.data.debateJudgeMode || 'external_judge') === 'external_judge' && (
+ <div>
+ <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Judge Model</label>
+ <select
+ value={selectedNode.data.judgeModel?.model || (selectedNode.data.debateModels || [])[0]?.model || ''}
+ onChange={(e) => {
+ const existing = (selectedNode.data.debateModels || []).find((c: CouncilMemberConfig) => c.model === e.target.value);
+ handleChange('judgeModel', existing ? { ...existing } : { model: e.target.value });
+ }}
+ className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+ >
+ {[
+ { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4.5' },
+ { value: 'claude-opus-4-6', label: 'claude-opus-4.6' },
+ { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
+ { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' },
+ { value: 'gpt-5', label: 'gpt-5' },
+ { value: 'gpt-5.1', label: 'gpt-5.1' },
+ { value: 'gpt-5.2', label: 'gpt-5.2' },
+ ].map(m => (
+ <option key={m.value} value={m.value}>{m.label}</option>
+ ))}
+ </select>
+ </div>
+ )}
+
+ {/* Debate Format */}
+ <div>
+ <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Debate Format</label>
+ <select
+ value={selectedNode.data.debateFormat || 'free_discussion'}
+ onChange={(e) => handleChange('debateFormat', e.target.value)}
+ className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+ >
+ <option value="free_discussion">Free Discussion</option>
+ <option value="structured_opposition">Structured Opposition</option>
+ <option value="iterative_improvement">Iterative Improvement</option>
+ <option value="custom">Custom Prompt</option>
+ </select>
+ </div>
+
+ {/* Custom Prompt (only for custom format) */}
+ {selectedNode.data.debateFormat === 'custom' && (
+ <div>
+ <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Custom Prompt</label>
+ <textarea
+ value={selectedNode.data.debateCustomPrompt || ''}
+ onChange={(e) => handleChange('debateCustomPrompt', e.target.value)}
+ placeholder="Use {history}, {round}, {model_name}, {question} as placeholders"
+ rows={3}
+ className={`w-full border rounded-md p-1.5 text-xs resize-y ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500' : 'bg-white border-gray-300 placeholder-gray-400'}`}
+ />
+ </div>
+ )}
+
+ {/* Max Rounds */}
+ <div>
+ <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Max Rounds</label>
+ <input
+ type="number"
+ min={1} max={10}
+ value={selectedNode.data.debateMaxRounds || 5}
+ onChange={(e) => handleChange('debateMaxRounds', Math.max(1, Math.min(10, parseInt(e.target.value) || 5)))}
+ className={`w-20 border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+ />
+ </div>
+ </div>
+ ) : !selectedNode.data.councilMode ? (
/* Single model selector */
<select
value={selectedNode.data.model}
@@ -2500,6 +2962,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
Inspired by <a href="https://github.com/karpathy/llm-council" target="_blank" rel="noopener noreferrer" className={`underline decoration-dotted ${isDark ? 'text-gray-500 hover:text-gray-400' : 'text-gray-500 hover:text-gray-600'}`}>karpathy/llm-council</a>
</div>
</>
+ ) : selectedNode.data.debateMode ? (
+ <button
+ onClick={handleRunDebate}
+ disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.debateModels || []).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.debateModels || []).length < 2
+ ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
+ : 'bg-cyan-600 text-white hover:bg-cyan-700'
+ }`}
+ >
+ {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <MessageSquare size={16} />}
+ {selectedNode.data.status === 'loading' && debateStage ? debateStage : `Run Debate (${(selectedNode.data.debateModels || []).length})`}
+ </button>
) : (
<button
onClick={handleRun}
@@ -2713,6 +3188,198 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
)}
</div>
+ ) : selectedNode.data.debateMode && selectedNode.data.debateData && (selectedNode.data.debateData.rounds.length > 0 || selectedNode.data.status === 'loading') ? (
+ <div>
+ {/* Debate tab bar */}
+ <div className={`flex gap-0.5 mb-2 text-xs border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+ {(selectedNode.data.debateData.config.judgeMode !== 'display_only'
+ ? (['final', 'timeline', 'per-model'] as const)
+ : (['timeline', 'per-model'] as const)
+ ).map(tab => (
+ <button
+ key={tab}
+ onClick={() => setDebateTab(tab)}
+ className={`px-3 py-1.5 rounded-t transition-colors capitalize ${
+ debateTab === tab
+ ? isDark ? 'bg-gray-800 text-cyan-300 border-b-2 border-cyan-400' : 'bg-white text-cyan-700 border-b-2 border-cyan-500'
+ : isDark ? 'text-gray-500 hover:text-gray-300' : 'text-gray-400 hover:text-gray-600'
+ }`}
+ >
+ {tab === 'final' ? 'Final Answer' : tab === 'timeline' ? 'Timeline' : 'Per-Model'}
+ </button>
+ ))}
+ </div>
+
+ {/* Final Answer tab */}
+ {debateTab === 'final' && (
+ <div>
+ {selectedNode.data.debateData.finalVerdict ? (
+ <>
+ <div className={`text-xs mb-1 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>Judge: {selectedNode.data.debateData.finalVerdict.model}</div>
+ <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.debateData.finalVerdict.response}</pre>
+ ) : (
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+ {preprocessLaTeX(selectedNode.data.debateData.finalVerdict.response)}
+ </ReactMarkdown>
+ )}
+ </div>
+ </>
+ ) : selectedNode.data.status === 'loading' ? (
+ <div className={`p-3 rounded-md border min-h-[150px] text-sm ${
+ isDark ? 'bg-gray-900 border-gray-700' : 'bg-gray-50 border-gray-200'
+ }`}>
+ {debateStreamBuffer ? (
+ rawTextMode ? (
+ <pre className="whitespace-pre-wrap break-words">{debateStreamBuffer}</pre>
+ ) : (
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+ {preprocessLaTeX(debateStreamBuffer)}
+ </ReactMarkdown>
+ )
+ ) : (
+ <div className={`flex items-center gap-2 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}>
+ <Loader2 className="animate-spin" size={14} /> Waiting for final verdict...
+ </div>
+ )}
+ </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 final verdict
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Debate Timeline tab */}
+ {debateTab === 'timeline' && (
+ <div className="space-y-2 max-h-[500px] overflow-y-auto">
+ {selectedNode.data.debateData.rounds.map((round) => {
+ const isExpanded = debateTimelineExpandedRounds.has(round.round);
+ return (
+ <div key={round.round} className={`rounded border ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+ <button
+ onClick={() => {
+ const next = new Set(debateTimelineExpandedRounds);
+ if (isExpanded) next.delete(round.round); else next.add(round.round);
+ setDebateTimelineExpandedRounds(next);
+ }}
+ className={`w-full flex items-center justify-between px-3 py-2 text-xs font-semibold ${
+ isDark ? 'bg-gray-800 text-gray-300 hover:bg-gray-750' : 'bg-gray-50 text-gray-700 hover:bg-gray-100'
+ }`}
+ >
+ <span>Round {round.round} ({round.responses.length} responses)</span>
+ <div className="flex items-center gap-2">
+ {round.judgeDecision && (
+ <span className={`px-1.5 py-0.5 rounded text-[10px] ${
+ round.judgeDecision.continue
+ ? isDark ? 'bg-green-900/50 text-green-300' : 'bg-green-100 text-green-700'
+ : isDark ? 'bg-red-900/50 text-red-300' : 'bg-red-100 text-red-700'
+ }`}>
+ {round.judgeDecision.continue ? 'Continue' : 'Stop'}
+ </span>
+ )}
+ {round.converged !== undefined && (
+ <span className={`px-1.5 py-0.5 rounded text-[10px] ${
+ round.converged
+ ? isDark ? 'bg-green-900/50 text-green-300' : 'bg-green-100 text-green-700'
+ : isDark ? 'bg-yellow-900/50 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
+ }`}>
+ {round.converged ? 'Converged' : 'Divergent'}
+ </span>
+ )}
+ <ChevronDown size={12} className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
+ </div>
+ </button>
+ {isExpanded && (
+ <div className={`p-2 space-y-2 ${isDark ? 'bg-gray-900' : 'bg-white'}`}>
+ {round.responses.map((resp, ri) => (
+ <div key={ri} className={`rounded border p-2 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+ <div className={`text-xs font-semibold mb-1 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}>{resp.model}</div>
+ <div className={`text-xs prose prose-sm max-w-none ${isDark ? 'prose-invert text-gray-300' : 'text-gray-700'}`}>
+ {rawTextMode ? (
+ <pre className="whitespace-pre-wrap break-words">{resp.response}</pre>
+ ) : (
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+ {preprocessLaTeX(resp.response)}
+ </ReactMarkdown>
+ )}
+ </div>
+ </div>
+ ))}
+ {round.judgeDecision && (
+ <div className={`rounded border p-2 text-xs ${isDark ? 'border-amber-800/50 bg-amber-900/20 text-amber-200' : 'border-amber-200 bg-amber-50 text-amber-800'}`}>
+ <div className="font-semibold mb-1">Judge Decision</div>
+ <div className="whitespace-pre-wrap">{round.judgeDecision.reasoning}</div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ {selectedNode.data.status === 'loading' && debateStage && (
+ <div className={`flex items-center gap-2 p-2 text-xs ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}>
+ <Loader2 className="animate-spin" size={12} /> {debateStage}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Per-Model View tab */}
+ {debateTab === 'per-model' && (
+ <div>
+ {(() => {
+ const allModels = new Set<string>();
+ selectedNode.data.debateData!.rounds.forEach(r => r.responses.forEach(resp => allModels.add(resp.model)));
+ const modelList = Array.from(allModels);
+ const selectedModel = debatePerModelSelected || modelList[0] || '';
+ return (
+ <>
+ <div className={`flex gap-0.5 mb-2 flex-wrap`}>
+ {modelList.map(model => (
+ <button
+ key={model}
+ onClick={() => setDebatePerModelSelected(model)}
+ className={`px-2 py-0.5 text-xs rounded transition-colors ${
+ (debatePerModelSelected || modelList[0]) === model
+ ? isDark ? 'bg-cyan-900/50 text-cyan-300' : 'bg-cyan-100 text-cyan-700'
+ : isDark ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
+ }`}
+ >
+ {model}
+ </button>
+ ))}
+ </div>
+ <div className="space-y-2 max-h-[400px] overflow-y-auto">
+ {selectedNode.data.debateData!.rounds.map(round => {
+ const resp = round.responses.find(r => r.model === selectedModel);
+ if (!resp) return null;
+ return (
+ <div key={round.round} className={`rounded border p-2 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+ <div className={`text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Round {round.round}</div>
+ <div className={`text-xs prose prose-sm max-w-none ${isDark ? 'prose-invert text-gray-300' : 'text-gray-700'}`}>
+ {rawTextMode ? (
+ <pre className="whitespace-pre-wrap break-words">{resp.response}</pre>
+ ) : (
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+ {preprocessLaTeX(resp.response)}
+ </ReactMarkdown>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </>
+ );
+ })()}
+ </div>
+ )}
+ </div>
) : isEditing ? (
<div className="space-y-2">
<textarea
diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts
index f31720e..c1c8b99 100644
--- a/frontend/src/store/flowStore.ts
+++ b/frontend/src/store/flowStore.ts
@@ -91,6 +91,23 @@ export interface CouncilData {
stage3: { model: string; response: string } | null;
}
+export interface DebateRound {
+ round: number;
+ responses: Array<{ model: string; response: string }>;
+ judgeDecision?: { continue: boolean; reasoning: string };
+ converged?: boolean;
+}
+
+export interface DebateData {
+ rounds: DebateRound[];
+ finalVerdict: { model: string; response: string } | null;
+ config: {
+ judgeMode: 'external_judge' | 'self_convergence' | 'display_only';
+ format: 'free_discussion' | 'structured_opposition' | 'iterative_improvement' | 'custom';
+ maxRounds: number;
+ };
+}
+
export interface NodeData {
label: string;
model: string;
@@ -110,6 +127,16 @@ export interface NodeData {
chairmanModel?: CouncilMemberConfig;
councilData?: CouncilData;
+ // Debate mode
+ debateMode?: boolean;
+ debateModels?: CouncilMemberConfig[];
+ judgeModel?: CouncilMemberConfig;
+ debateJudgeMode?: 'external_judge' | 'self_convergence' | 'display_only';
+ debateFormat?: 'free_discussion' | 'structured_opposition' | 'iterative_improvement' | 'custom';
+ debateCustomPrompt?: string;
+ debateMaxRounds?: number;
+ debateData?: DebateData;
+
// Traces logic
traces: Trace[]; // INCOMING Traces
outgoingTraces: Trace[]; // ALL Outgoing (inherited + self + forks + merged)