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