summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/config.py6
-rw-r--r--backend/council.py56
-rw-r--r--backend/main.py40
-rw-r--r--frontend/package-lock.json296
-rw-r--r--frontend/package.json3
-rw-r--r--frontend/src/App.jsx54
-rw-r--r--frontend/src/api.js31
-rw-r--r--frontend/src/components/ChatInterface.css19
-rw-r--r--frontend/src/components/ChatInterface.jsx175
-rw-r--r--frontend/src/components/Stage1.jsx3
-rw-r--r--frontend/src/components/Stage2.jsx3
-rw-r--r--frontend/src/components/Stage3.jsx3
-rw-r--r--frontend/src/index.css22
13 files changed, 594 insertions, 117 deletions
diff --git a/backend/config.py b/backend/config.py
index a9cf7c4..cf8fcb4 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -10,14 +10,14 @@ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
# Council members - list of OpenRouter model identifiers
COUNCIL_MODELS = [
- "openai/gpt-5.1",
+ "openai/gpt-5.2",
"google/gemini-3-pro-preview",
- "anthropic/claude-sonnet-4.5",
+ "anthropic/claude-opus-4.6",
"x-ai/grok-4",
]
# Chairman model - synthesizes final response
-CHAIRMAN_MODEL = "google/gemini-3-pro-preview"
+CHAIRMAN_MODEL = "anthropic/claude-opus-4.6"
# OpenRouter API endpoint
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
diff --git a/backend/council.py b/backend/council.py
index 5069abe..6facbd8 100644
--- a/backend/council.py
+++ b/backend/council.py
@@ -1,21 +1,46 @@
"""3-stage LLM Council orchestration."""
-from typing import List, Dict, Any, Tuple
+from typing import List, Dict, Any, Tuple, Optional
from .openrouter import query_models_parallel, query_model
from .config import COUNCIL_MODELS, CHAIRMAN_MODEL
-async def stage1_collect_responses(user_query: str) -> List[Dict[str, Any]]:
+def _build_messages(
+ conversation_history: Optional[List[Dict[str, str]]],
+ current_content: str
+) -> List[Dict[str, str]]:
+ """
+ Build a messages list with conversation history + current user message.
+
+ Args:
+ conversation_history: List of {"role": "user"/"assistant", "content": ...} dicts
+ current_content: The current message content to append as user
+
+ Returns:
+ Messages list for the OpenRouter API
+ """
+ messages = []
+ if conversation_history:
+ messages.extend(conversation_history)
+ messages.append({"role": "user", "content": current_content})
+ return messages
+
+
+async def stage1_collect_responses(
+ user_query: str,
+ conversation_history: Optional[List[Dict[str, str]]] = None
+) -> List[Dict[str, Any]]:
"""
Stage 1: Collect individual responses from all council models.
Args:
user_query: The user's question
+ conversation_history: Optional list of prior conversation messages
Returns:
List of dicts with 'model' and 'response' keys
"""
- messages = [{"role": "user", "content": user_query}]
+ messages = _build_messages(conversation_history, user_query)
# Query all models in parallel
responses = await query_models_parallel(COUNCIL_MODELS, messages)
@@ -34,7 +59,8 @@ async def stage1_collect_responses(user_query: str) -> List[Dict[str, Any]]:
async def stage2_collect_rankings(
user_query: str,
- stage1_results: List[Dict[str, Any]]
+ stage1_results: List[Dict[str, Any]],
+ conversation_history: Optional[List[Dict[str, str]]] = None
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
"""
Stage 2: Each model ranks the anonymized responses.
@@ -42,6 +68,7 @@ async def stage2_collect_rankings(
Args:
user_query: The original user query
stage1_results: Results from Stage 1
+ conversation_history: Optional list of prior conversation messages
Returns:
Tuple of (rankings list, label_to_model mapping)
@@ -92,7 +119,7 @@ FINAL RANKING:
Now provide your evaluation and ranking:"""
- messages = [{"role": "user", "content": ranking_prompt}]
+ messages = _build_messages(conversation_history, ranking_prompt)
# Get rankings from all council models in parallel
responses = await query_models_parallel(COUNCIL_MODELS, messages)
@@ -115,7 +142,8 @@ Now provide your evaluation and ranking:"""
async def stage3_synthesize_final(
user_query: str,
stage1_results: List[Dict[str, Any]],
- stage2_results: List[Dict[str, Any]]
+ stage2_results: List[Dict[str, Any]],
+ conversation_history: Optional[List[Dict[str, str]]] = None
) -> Dict[str, Any]:
"""
Stage 3: Chairman synthesizes final response.
@@ -124,6 +152,7 @@ async def stage3_synthesize_final(
user_query: The original user query
stage1_results: Individual model responses from Stage 1
stage2_results: Rankings from Stage 2
+ conversation_history: Optional list of prior conversation messages
Returns:
Dict with 'model' and 'response' keys
@@ -156,7 +185,7 @@ Your task as Chairman is to synthesize all of this information into a single, co
Provide a clear, well-reasoned final answer that represents the council's collective wisdom:"""
- messages = [{"role": "user", "content": chairman_prompt}]
+ messages = _build_messages(conversation_history, chairman_prompt)
# Query the chairman model
response = await query_model(CHAIRMAN_MODEL, messages)
@@ -293,18 +322,22 @@ Title:"""
return title
-async def run_full_council(user_query: str) -> Tuple[List, List, Dict, Dict]:
+async def run_full_council(
+ user_query: str,
+ conversation_history: Optional[List[Dict[str, str]]] = None
+) -> Tuple[List, List, Dict, Dict]:
"""
Run the complete 3-stage council process.
Args:
user_query: The user's question
+ conversation_history: Optional list of prior conversation messages
Returns:
Tuple of (stage1_results, stage2_results, stage3_result, metadata)
"""
# Stage 1: Collect individual responses
- stage1_results = await stage1_collect_responses(user_query)
+ stage1_results = await stage1_collect_responses(user_query, conversation_history)
# If no models responded successfully, return error
if not stage1_results:
@@ -314,7 +347,7 @@ async def run_full_council(user_query: str) -> Tuple[List, List, Dict, Dict]:
}, {}
# Stage 2: Collect rankings
- stage2_results, label_to_model = await stage2_collect_rankings(user_query, stage1_results)
+ stage2_results, label_to_model = await stage2_collect_rankings(user_query, stage1_results, conversation_history)
# Calculate aggregate rankings
aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model)
@@ -323,7 +356,8 @@ async def run_full_council(user_query: str) -> Tuple[List, List, Dict, Dict]:
stage3_result = await stage3_synthesize_final(
user_query,
stage1_results,
- stage2_results
+ stage2_results,
+ conversation_history
)
# Prepare metadata
diff --git a/backend/main.py b/backend/main.py
index e33ce59..40353dd 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -14,6 +14,20 @@ from .council import run_full_council, generate_conversation_title, stage1_colle
app = FastAPI(title="LLM Council API")
+
+def _extract_conversation_history(conversation: Dict[str, Any]) -> List[Dict[str, str]]:
+ """
+ Extract conversation history as a flat messages list for multi-turn context.
+ User messages use their content; assistant messages use the Stage 3 (chairman) response.
+ """
+ history = []
+ for msg in conversation["messages"]:
+ if msg["role"] == "user":
+ history.append({"role": "user", "content": msg["content"]})
+ elif msg["role"] == "assistant" and msg.get("stage3"):
+ history.append({"role": "assistant", "content": msg["stage3"].get("response", "")})
+ return history
+
# Enable CORS for local development
app.add_middleware(
CORSMiddleware,
@@ -93,20 +107,21 @@ async def send_message(conversation_id: str, request: SendMessageRequest):
# Check if this is the first message
is_first_message = len(conversation["messages"]) == 0
- # Add user message
- storage.add_user_message(conversation_id, request.content)
-
# If this is the first message, generate a title
if is_first_message:
title = await generate_conversation_title(request.content)
storage.update_conversation_title(conversation_id, title)
+ # Build conversation history for multi-turn context
+ conversation_history = _extract_conversation_history(conversation)
+
# Run the 3-stage council process
stage1_results, stage2_results, stage3_result, metadata = await run_full_council(
- request.content
+ request.content, conversation_history
)
- # Add assistant message with all stages
+ # Save user + assistant messages together only after full completion
+ storage.add_user_message(conversation_id, request.content)
storage.add_assistant_message(
conversation_id,
stage1_results,
@@ -137,11 +152,11 @@ async def send_message_stream(conversation_id: str, request: SendMessageRequest)
# Check if this is the first message
is_first_message = len(conversation["messages"]) == 0
+ # Build conversation history for multi-turn context
+ conversation_history = _extract_conversation_history(conversation)
+
async def event_generator():
try:
- # Add user message
- storage.add_user_message(conversation_id, request.content)
-
# Start title generation in parallel (don't await yet)
title_task = None
if is_first_message:
@@ -149,18 +164,18 @@ async def send_message_stream(conversation_id: str, request: SendMessageRequest)
# Stage 1: Collect responses
yield f"data: {json.dumps({'type': 'stage1_start'})}\n\n"
- stage1_results = await stage1_collect_responses(request.content)
+ stage1_results = await stage1_collect_responses(request.content, conversation_history)
yield f"data: {json.dumps({'type': 'stage1_complete', 'data': stage1_results})}\n\n"
# Stage 2: Collect rankings
yield f"data: {json.dumps({'type': 'stage2_start'})}\n\n"
- stage2_results, label_to_model = await stage2_collect_rankings(request.content, stage1_results)
+ stage2_results, label_to_model = await stage2_collect_rankings(request.content, stage1_results, conversation_history)
aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model)
yield f"data: {json.dumps({'type': 'stage2_complete', 'data': stage2_results, 'metadata': {'label_to_model': label_to_model, 'aggregate_rankings': aggregate_rankings}})}\n\n"
# Stage 3: Synthesize final answer
yield f"data: {json.dumps({'type': 'stage3_start'})}\n\n"
- stage3_result = await stage3_synthesize_final(request.content, stage1_results, stage2_results)
+ stage3_result = await stage3_synthesize_final(request.content, stage1_results, stage2_results, conversation_history)
yield f"data: {json.dumps({'type': 'stage3_complete', 'data': stage3_result})}\n\n"
# Wait for title generation if it was started
@@ -169,7 +184,8 @@ async def send_message_stream(conversation_id: str, request: SendMessageRequest)
storage.update_conversation_title(conversation_id, title)
yield f"data: {json.dumps({'type': 'title_complete', 'data': {'title': title}})}\n\n"
- # Save complete assistant message
+ # Save user + assistant messages together only after full completion
+ storage.add_user_message(conversation_id, request.content)
storage.add_assistant_message(
conversation_id,
stage1_results,
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a6a7c34..0f1c4d9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "react-markdown": "^10.1.0"
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -2391,6 +2392,44 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -2414,6 +2453,107 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -2603,6 +2743,127 @@
"micromark-util-types": "^2.0.0"
}
},
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -3231,6 +3492,24 @@
"node": ">=0.10.0"
}
},
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -3262,6 +3541,21 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 89fd2c8..1963a90 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,8 @@
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "react-markdown": "^10.1.0"
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 1954155..8b7a547 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
import Sidebar from './components/Sidebar';
import ChatInterface from './components/ChatInterface';
import { api } from './api';
@@ -9,6 +9,8 @@ function App() {
const [currentConversationId, setCurrentConversationId] = useState(null);
const [currentConversation, setCurrentConversation] = useState(null);
const [isLoading, setIsLoading] = useState(false);
+ const [pendingInput, setPendingInput] = useState(null);
+ const abortControllerRef = useRef(null);
// Load conversations on mount
useEffect(() => {
@@ -57,9 +59,36 @@ function App() {
setCurrentConversationId(id);
};
+ const handleStopGeneration = () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+
+ // Recover the last user message into the input box and remove the incomplete pair
+ setCurrentConversation((prev) => {
+ const messages = [...prev.messages];
+ // Find the last user message to recover its content
+ let recoveredContent = '';
+ // Remove trailing assistant message (incomplete)
+ if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') {
+ messages.pop();
+ }
+ // Remove the user message and recover its text
+ if (messages.length > 0 && messages[messages.length - 1].role === 'user') {
+ const userMsg = messages.pop();
+ recoveredContent = userMsg.content;
+ }
+ setPendingInput(recoveredContent);
+ return { ...prev, messages };
+ });
+ }
+ };
+
const handleSendMessage = async (content) => {
if (!currentConversationId) return;
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
setIsLoading(true);
try {
// Optimistically add user message to UI
@@ -91,6 +120,7 @@ function App() {
// Send message with streaming
await api.sendMessageStream(currentConversationId, content, (eventType, event) => {
+ if (controller.signal.aborted) return;
switch (eventType) {
case 'stage1_start':
setCurrentConversation((prev) => {
@@ -169,15 +199,20 @@ function App() {
default:
console.log('Unknown event type:', eventType);
}
- });
+ }, controller.signal);
} catch (error) {
- console.error('Failed to send message:', error);
- // Remove optimistic messages on error
- setCurrentConversation((prev) => ({
- ...prev,
- messages: prev.messages.slice(0, -2),
- }));
+ if (error.name === 'AbortError') {
+ // User stopped generation — handleStopGeneration already cleaned up messages
+ } else {
+ console.error('Failed to send message:', error);
+ // Remove optimistic messages on error
+ setCurrentConversation((prev) => ({
+ ...prev,
+ messages: prev.messages.slice(0, -2),
+ }));
+ }
setIsLoading(false);
+ abortControllerRef.current = null;
}
};
@@ -192,7 +227,10 @@ function App() {
<ChatInterface
conversation={currentConversation}
onSendMessage={handleSendMessage}
+ onStopGeneration={handleStopGeneration}
isLoading={isLoading}
+ pendingInput={pendingInput}
+ onPendingInputConsumed={() => setPendingInput(null)}
/>
</div>
);
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 87ec685..a53ed90 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -73,7 +73,7 @@ export const api = {
* @param {function} onEvent - Callback function for each event: (eventType, data) => void
* @returns {Promise<void>}
*/
- async sendMessageStream(conversationId, content, onEvent) {
+ async sendMessageStream(conversationId, content, onEvent, signal) {
const response = await fetch(
`${API_BASE}/api/conversations/${conversationId}/message/stream`,
{
@@ -82,6 +82,7 @@ export const api = {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
+ signal,
}
);
@@ -91,22 +92,30 @@ export const api = {
const reader = response.body.getReader();
const decoder = new TextDecoder();
+ let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
- const chunk = decoder.decode(value);
- const lines = chunk.split('\n');
+ buffer += decoder.decode(value, { stream: true });
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- const data = line.slice(6);
- try {
- const event = JSON.parse(data);
- onEvent(event.type, event);
- } catch (e) {
- console.error('Failed to parse SSE event:', e);
+ // Split on double newline (SSE event boundary)
+ const parts = buffer.split('\n\n');
+ // Last part may be incomplete — keep it in buffer
+ buffer = parts.pop();
+
+ for (const part of parts) {
+ const lines = part.split('\n');
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6);
+ try {
+ const event = JSON.parse(data);
+ onEvent(event.type, event);
+ } catch (e) {
+ console.error('Failed to parse SSE event:', e);
+ }
}
}
}
diff --git a/frontend/src/components/ChatInterface.css b/frontend/src/components/ChatInterface.css
index 0d01300..63bc75d 100644
--- a/frontend/src/components/ChatInterface.css
+++ b/frontend/src/components/ChatInterface.css
@@ -161,3 +161,22 @@
background: #ccc;
border-color: #ccc;
}
+
+.stop-button {
+ padding: 14px 28px;
+ background: #e24a4a;
+ border: 1px solid #e24a4a;
+ border-radius: 8px;
+ color: #fff;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ white-space: nowrap;
+ align-self: flex-end;
+}
+
+.stop-button:hover {
+ background: #c93636;
+ border-color: #c93636;
+}
diff --git a/frontend/src/components/ChatInterface.jsx b/frontend/src/components/ChatInterface.jsx
index 3ae796c..5f431c2 100644
--- a/frontend/src/components/ChatInterface.jsx
+++ b/frontend/src/components/ChatInterface.jsx
@@ -1,25 +1,102 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, memo } from 'react';
import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
import Stage1 from './Stage1';
import Stage2 from './Stage2';
import Stage3 from './Stage3';
import './ChatInterface.css';
+const remarkPlugins = [remarkGfm];
+
+// Only memoize user messages (they never change once sent)
+const UserMessage = memo(function UserMessage({ content }) {
+ return (
+ <div className="message-group">
+ <div className="user-message">
+ <div className="message-label">You</div>
+ <div className="message-content">
+ <div className="markdown-content">
+ <ReactMarkdown remarkPlugins={remarkPlugins}>{content}</ReactMarkdown>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+});
+
+// Memoize completed assistant messages, but skip memo for the active (last) one
+const AssistantMessage = memo(function AssistantMessage({ msg, isActive }) {
+ return (
+ <div className="message-group">
+ <div className="assistant-message">
+ <div className="message-label">LLM Council</div>
+
+ {/* Stage 1 */}
+ {msg.loading?.stage1 && (
+ <div className="stage-loading">
+ <div className="spinner"></div>
+ <span>Running Stage 1: Collecting individual responses...</span>
+ </div>
+ )}
+ {msg.stage1 && <Stage1 responses={msg.stage1} />}
+
+ {/* Stage 2 */}
+ {msg.loading?.stage2 && (
+ <div className="stage-loading">
+ <div className="spinner"></div>
+ <span>Running Stage 2: Peer rankings...</span>
+ </div>
+ )}
+ {msg.stage2 && (
+ <Stage2
+ rankings={msg.stage2}
+ labelToModel={msg.metadata?.label_to_model}
+ aggregateRankings={msg.metadata?.aggregate_rankings}
+ />
+ )}
+
+ {/* Stage 3 */}
+ {msg.loading?.stage3 && (
+ <div className="stage-loading">
+ <div className="spinner"></div>
+ <span>Running Stage 3: Final synthesis...</span>
+ </div>
+ )}
+ {msg.stage3 && <Stage3 finalResponse={msg.stage3} />}
+ </div>
+ </div>
+ );
+}, (prevProps, nextProps) => {
+ // If active (streaming), always re-render
+ if (prevProps.isActive || nextProps.isActive) return false;
+ // Otherwise skip re-render (completed messages don't change)
+ return true;
+});
+
export default function ChatInterface({
conversation,
onSendMessage,
+ onStopGeneration,
isLoading,
+ pendingInput,
+ onPendingInputConsumed,
}) {
const [input, setInput] = useState('');
+ const textareaRef = useRef(null);
const messagesEndRef = useRef(null);
- const scrollToBottom = () => {
+ useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- };
+ }, [conversation, isLoading]);
+ // Recover input from stopped generation
useEffect(() => {
- scrollToBottom();
- }, [conversation]);
+ if (pendingInput !== null) {
+ setInput(pendingInput);
+ onPendingInputConsumed();
+ setTimeout(() => textareaRef.current?.focus(), 0);
+ }
+ }, [pendingInput]);
const handleSubmit = (e) => {
e.preventDefault();
@@ -30,7 +107,6 @@ export default function ChatInterface({
};
const handleKeyDown = (e) => {
- // Submit on Enter (without Shift)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
@@ -57,57 +133,13 @@ export default function ChatInterface({
<p>Ask a question to consult the LLM Council</p>
</div>
) : (
- conversation.messages.map((msg, index) => (
- <div key={index} className="message-group">
- {msg.role === 'user' ? (
- <div className="user-message">
- <div className="message-label">You</div>
- <div className="message-content">
- <div className="markdown-content">
- <ReactMarkdown>{msg.content}</ReactMarkdown>
- </div>
- </div>
- </div>
- ) : (
- <div className="assistant-message">
- <div className="message-label">LLM Council</div>
-
- {/* Stage 1 */}
- {msg.loading?.stage1 && (
- <div className="stage-loading">
- <div className="spinner"></div>
- <span>Running Stage 1: Collecting individual responses...</span>
- </div>
- )}
- {msg.stage1 && <Stage1 responses={msg.stage1} />}
-
- {/* Stage 2 */}
- {msg.loading?.stage2 && (
- <div className="stage-loading">
- <div className="spinner"></div>
- <span>Running Stage 2: Peer rankings...</span>
- </div>
- )}
- {msg.stage2 && (
- <Stage2
- rankings={msg.stage2}
- labelToModel={msg.metadata?.label_to_model}
- aggregateRankings={msg.metadata?.aggregate_rankings}
- />
- )}
-
- {/* Stage 3 */}
- {msg.loading?.stage3 && (
- <div className="stage-loading">
- <div className="spinner"></div>
- <span>Running Stage 3: Final synthesis...</span>
- </div>
- )}
- {msg.stage3 && <Stage3 finalResponse={msg.stage3} />}
- </div>
- )}
- </div>
- ))
+ conversation.messages.map((msg, index) => {
+ if (msg.role === 'user') {
+ return <UserMessage key={index} content={msg.content} />;
+ }
+ const isLastAssistant = isLoading && index === conversation.messages.length - 1;
+ return <AssistantMessage key={index} msg={msg} isActive={isLastAssistant} />;
+ })
)}
{isLoading && (
@@ -120,9 +152,9 @@ export default function ChatInterface({
<div ref={messagesEndRef} />
</div>
- {conversation.messages.length === 0 && (
- <form className="input-form" onSubmit={handleSubmit}>
+ <form className="input-form" onSubmit={handleSubmit}>
<textarea
+ ref={textareaRef}
className="message-input"
placeholder="Ask your question... (Shift+Enter for new line, Enter to send)"
value={input}
@@ -131,15 +163,24 @@ export default function ChatInterface({
disabled={isLoading}
rows={3}
/>
- <button
- type="submit"
- className="send-button"
- disabled={!input.trim() || isLoading}
- >
- Send
- </button>
+ {isLoading ? (
+ <button
+ type="button"
+ className="stop-button"
+ onClick={onStopGeneration}
+ >
+ Stop
+ </button>
+ ) : (
+ <button
+ type="submit"
+ className="send-button"
+ disabled={!input.trim()}
+ >
+ Send
+ </button>
+ )}
</form>
- )}
</div>
);
}
diff --git a/frontend/src/components/Stage1.jsx b/frontend/src/components/Stage1.jsx
index 071937c..7478876 100644
--- a/frontend/src/components/Stage1.jsx
+++ b/frontend/src/components/Stage1.jsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
import './Stage1.css';
export default function Stage1({ responses }) {
@@ -28,7 +29,7 @@ export default function Stage1({ responses }) {
<div className="tab-content">
<div className="model-name">{responses[activeTab].model}</div>
<div className="response-text markdown-content">
- <ReactMarkdown>{responses[activeTab].response}</ReactMarkdown>
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{responses[activeTab].response}</ReactMarkdown>
</div>
</div>
</div>
diff --git a/frontend/src/components/Stage2.jsx b/frontend/src/components/Stage2.jsx
index 2550fa6..5d28ed7 100644
--- a/frontend/src/components/Stage2.jsx
+++ b/frontend/src/components/Stage2.jsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
import './Stage2.css';
function deAnonymizeText(text, labelToModel) {
@@ -48,7 +49,7 @@ export default function Stage2({ rankings, labelToModel, aggregateRankings }) {
{rankings[activeTab].model}
</div>
<div className="ranking-content markdown-content">
- <ReactMarkdown>
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
{deAnonymizeText(rankings[activeTab].ranking, labelToModel)}
</ReactMarkdown>
</div>
diff --git a/frontend/src/components/Stage3.jsx b/frontend/src/components/Stage3.jsx
index 9a9dbf7..f63a442 100644
--- a/frontend/src/components/Stage3.jsx
+++ b/frontend/src/components/Stage3.jsx
@@ -1,4 +1,5 @@
import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
import './Stage3.css';
export default function Stage3({ finalResponse }) {
@@ -14,7 +15,7 @@ export default function Stage3({ finalResponse }) {
Chairman: {finalResponse.model.split('/')[1] || finalResponse.model}
</div>
<div className="final-text markdown-content">
- <ReactMarkdown>{finalResponse.response}</ReactMarkdown>
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{finalResponse.response}</ReactMarkdown>
</div>
</div>
</div>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 698f393..b7b4ed7 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -96,3 +96,25 @@ body {
border-left: 4px solid #ddd;
color: #666;
}
+
+.markdown-content table {
+ border-collapse: collapse;
+ margin: 0 0 12px 0;
+ width: 100%;
+}
+
+.markdown-content th,
+.markdown-content td {
+ border: 1px solid #d0d0d0;
+ padding: 8px 12px;
+ text-align: left;
+}
+
+.markdown-content th {
+ background: #f5f5f5;
+ font-weight: 600;
+}
+
+.markdown-content tr:nth-child(even) {
+ background: #fafafa;
+}