summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-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
8 files changed, 221 insertions, 89 deletions
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;
+}