diff options
| author | haoyuren <13851610112@163.com> | 2026-02-12 12:45:24 -0600 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-02-12 12:45:24 -0600 |
| commit | c8fae0256c91a0ebe495270aa15baa2f27211268 (patch) | |
| tree | efc908a9fb259a18809ab5151a15fc0f1e10fdf1 | |
| parent | 92e1fccb1bdcf1bab7221aa9ed90f9dc72529131 (diff) | |
Multi-turn conversation, stop generation, SSE fix, and UI improvements
- Multi-turn context: all council stages now receive conversation history
(user messages + Stage 3 chairman responses) for coherent follow-ups
- Stop generation: abort streaming mid-request, recover query to input box
- SSE parsing: buffer-based chunking to prevent JSON split across packets
- Atomic storage: user + assistant messages saved together after completion,
preventing dangling messages on abort
- GFM markdown: tables, strikethrough via remark-gfm plugin + table styles
- Performance: memo user messages and completed assistant messages, only
re-render the active streaming message
- Model config: gpt-5.2, claude-opus-4.6 as chairman
- Always show input box for multi-turn conversations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | backend/config.py | 6 | ||||
| -rw-r--r-- | backend/council.py | 56 | ||||
| -rw-r--r-- | backend/main.py | 40 | ||||
| -rw-r--r-- | frontend/package-lock.json | 296 | ||||
| -rw-r--r-- | frontend/package.json | 3 | ||||
| -rw-r--r-- | frontend/src/App.jsx | 54 | ||||
| -rw-r--r-- | frontend/src/api.js | 31 | ||||
| -rw-r--r-- | frontend/src/components/ChatInterface.css | 19 | ||||
| -rw-r--r-- | frontend/src/components/ChatInterface.jsx | 175 | ||||
| -rw-r--r-- | frontend/src/components/Stage1.jsx | 3 | ||||
| -rw-r--r-- | frontend/src/components/Stage2.jsx | 3 | ||||
| -rw-r--r-- | frontend/src/components/Stage3.jsx | 3 | ||||
| -rw-r--r-- | frontend/src/index.css | 22 |
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; +} |
