summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorkarpathy <andrej.karpathy@gmail.com>2025-11-22 14:27:53 -0800
committerkarpathy <andrej.karpathy@gmail.com>2025-11-22 14:27:53 -0800
commiteb0eb26f4cefa4880c895ff017f312e8674f9b73 (patch)
treeea20b736519a5b4149b0356fec93447eef950e6b /frontend/src
v0
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.css15
-rw-r--r--frontend/src/App.jsx120
-rw-r--r--frontend/src/api.js68
-rw-r--r--frontend/src/assets/react.svg1
-rw-r--r--frontend/src/components/ChatInterface.css149
-rw-r--r--frontend/src/components/ChatInterface.jsx117
-rw-r--r--frontend/src/components/Sidebar.css78
-rw-r--r--frontend/src/components/Sidebar.jsx43
-rw-r--r--frontend/src/components/Stage1.css65
-rw-r--r--frontend/src/components/Stage1.jsx36
-rw-r--r--frontend/src/components/Stage2.css153
-rw-r--r--frontend/src/components/Stage2.jsx99
-rw-r--r--frontend/src/components/Stage3.css25
-rw-r--r--frontend/src/components/Stage3.jsx22
-rw-r--r--frontend/src/index.css98
-rw-r--r--frontend/src/main.jsx10
16 files changed, 1099 insertions, 0 deletions
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..6863cd4
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,15 @@
+* {
+ box-sizing: border-box;
+}
+
+.app {
+ display: flex;
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+ background: #ffffff;
+ color: #333;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..0396e3a
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,120 @@
+import { useState, useEffect } from 'react';
+import Sidebar from './components/Sidebar';
+import ChatInterface from './components/ChatInterface';
+import { api } from './api';
+import './App.css';
+
+function App() {
+ const [conversations, setConversations] = useState([]);
+ const [currentConversationId, setCurrentConversationId] = useState(null);
+ const [currentConversation, setCurrentConversation] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Load conversations on mount
+ useEffect(() => {
+ loadConversations();
+ }, []);
+
+ // Load conversation details when selected
+ useEffect(() => {
+ if (currentConversationId) {
+ loadConversation(currentConversationId);
+ }
+ }, [currentConversationId]);
+
+ const loadConversations = async () => {
+ try {
+ const convs = await api.listConversations();
+ setConversations(convs);
+ } catch (error) {
+ console.error('Failed to load conversations:', error);
+ }
+ };
+
+ const loadConversation = async (id) => {
+ try {
+ const conv = await api.getConversation(id);
+ setCurrentConversation(conv);
+ } catch (error) {
+ console.error('Failed to load conversation:', error);
+ }
+ };
+
+ const handleNewConversation = async () => {
+ try {
+ const newConv = await api.createConversation();
+ setConversations([
+ { id: newConv.id, created_at: newConv.created_at, message_count: 0 },
+ ...conversations,
+ ]);
+ setCurrentConversationId(newConv.id);
+ } catch (error) {
+ console.error('Failed to create conversation:', error);
+ }
+ };
+
+ const handleSelectConversation = (id) => {
+ setCurrentConversationId(id);
+ };
+
+ const handleSendMessage = async (content) => {
+ if (!currentConversationId) return;
+
+ setIsLoading(true);
+ try {
+ // Optimistically add user message to UI
+ const userMessage = { role: 'user', content };
+ setCurrentConversation((prev) => ({
+ ...prev,
+ messages: [...prev.messages, userMessage],
+ }));
+
+ // Send message and get council response
+ const response = await api.sendMessage(currentConversationId, content);
+
+ // Add assistant message to UI
+ const assistantMessage = {
+ role: 'assistant',
+ stage1: response.stage1,
+ stage2: response.stage2,
+ stage3: response.stage3,
+ metadata: response.metadata,
+ };
+
+ setCurrentConversation((prev) => ({
+ ...prev,
+ messages: [...prev.messages, assistantMessage],
+ }));
+
+ // Reload conversations list to update message count
+ await loadConversations();
+ } catch (error) {
+ console.error('Failed to send message:', error);
+ // Remove optimistic user message on error
+ setCurrentConversation((prev) => ({
+ ...prev,
+ messages: prev.messages.slice(0, -1),
+ }));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <div className="app">
+ <Sidebar
+ conversations={conversations}
+ currentConversationId={currentConversationId}
+ onSelectConversation={handleSelectConversation}
+ onNewConversation={handleNewConversation}
+ />
+ <ChatInterface
+ conversation={currentConversation}
+ onSendMessage={handleSendMessage}
+ isLoading={isLoading}
+ />
+ </div>
+ );
+}
+
+export default App;
diff --git a/frontend/src/api.js b/frontend/src/api.js
new file mode 100644
index 0000000..479f0ef
--- /dev/null
+++ b/frontend/src/api.js
@@ -0,0 +1,68 @@
+/**
+ * API client for the LLM Council backend.
+ */
+
+const API_BASE = 'http://localhost:8001';
+
+export const api = {
+ /**
+ * List all conversations.
+ */
+ async listConversations() {
+ const response = await fetch(`${API_BASE}/api/conversations`);
+ if (!response.ok) {
+ throw new Error('Failed to list conversations');
+ }
+ return response.json();
+ },
+
+ /**
+ * Create a new conversation.
+ */
+ async createConversation() {
+ const response = await fetch(`${API_BASE}/api/conversations`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}),
+ });
+ if (!response.ok) {
+ throw new Error('Failed to create conversation');
+ }
+ return response.json();
+ },
+
+ /**
+ * Get a specific conversation.
+ */
+ async getConversation(conversationId) {
+ const response = await fetch(
+ `${API_BASE}/api/conversations/${conversationId}`
+ );
+ if (!response.ok) {
+ throw new Error('Failed to get conversation');
+ }
+ return response.json();
+ },
+
+ /**
+ * Send a message in a conversation.
+ */
+ async sendMessage(conversationId, content) {
+ const response = await fetch(
+ `${API_BASE}/api/conversations/${conversationId}/message`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ content }),
+ }
+ );
+ if (!response.ok) {
+ throw new Error('Failed to send message');
+ }
+ return response.json();
+ },
+};
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/frontend/src/assets/react.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> \ No newline at end of file
diff --git a/frontend/src/components/ChatInterface.css b/frontend/src/components/ChatInterface.css
new file mode 100644
index 0000000..531d2a3
--- /dev/null
+++ b/frontend/src/components/ChatInterface.css
@@ -0,0 +1,149 @@
+.chat-interface {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ background: #ffffff;
+}
+
+.messages-container {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+}
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: #666;
+ text-align: center;
+}
+
+.empty-state h2 {
+ margin: 0 0 8px 0;
+ font-size: 24px;
+ color: #333;
+}
+
+.empty-state p {
+ margin: 0;
+ font-size: 16px;
+}
+
+.message-group {
+ margin-bottom: 32px;
+}
+
+.user-message,
+.assistant-message {
+ margin-bottom: 16px;
+}
+
+.message-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: #666;
+ margin-bottom: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.user-message .message-content {
+ background: #f0f7ff;
+ padding: 16px;
+ border-radius: 8px;
+ border: 1px solid #d0e7ff;
+ color: #333;
+ line-height: 1.6;
+ max-width: 80%;
+ white-space: pre-wrap;
+}
+
+.loading-indicator {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ color: #666;
+ font-size: 14px;
+}
+
+.spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid #e0e0e0;
+ border-top-color: #4a90e2;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.input-form {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 24px;
+ border-top: 1px solid #e0e0e0;
+ background: #fafafa;
+}
+
+.message-input {
+ flex: 1;
+ padding: 14px;
+ background: #ffffff;
+ border: 1px solid #d0d0d0;
+ border-radius: 8px;
+ color: #333;
+ font-size: 15px;
+ font-family: inherit;
+ line-height: 1.5;
+ outline: none;
+ resize: vertical;
+ min-height: 80px;
+ max-height: 300px;
+}
+
+.message-input:focus {
+ border-color: #4a90e2;
+ box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
+}
+
+.message-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background: #f5f5f5;
+}
+
+.send-button {
+ padding: 14px 28px;
+ background: #4a90e2;
+ border: 1px solid #4a90e2;
+ border-radius: 8px;
+ color: #fff;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ white-space: nowrap;
+ align-self: flex-end;
+}
+
+.send-button:hover:not(:disabled) {
+ background: #357abd;
+ border-color: #357abd;
+}
+
+.send-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background: #ccc;
+ border-color: #ccc;
+}
diff --git a/frontend/src/components/ChatInterface.jsx b/frontend/src/components/ChatInterface.jsx
new file mode 100644
index 0000000..951183f
--- /dev/null
+++ b/frontend/src/components/ChatInterface.jsx
@@ -0,0 +1,117 @@
+import { useState, useEffect, useRef } from 'react';
+import ReactMarkdown from 'react-markdown';
+import Stage1 from './Stage1';
+import Stage2 from './Stage2';
+import Stage3 from './Stage3';
+import './ChatInterface.css';
+
+export default function ChatInterface({
+ conversation,
+ onSendMessage,
+ isLoading,
+}) {
+ const [input, setInput] = useState('');
+ const messagesEndRef = useRef(null);
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [conversation]);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (input.trim() && !isLoading) {
+ onSendMessage(input);
+ setInput('');
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ // Submit on Enter (without Shift)
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit(e);
+ }
+ };
+
+ if (!conversation) {
+ return (
+ <div className="chat-interface">
+ <div className="empty-state">
+ <h2>Welcome to LLM Council</h2>
+ <p>Create a new conversation to get started</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="chat-interface">
+ <div className="messages-container">
+ {conversation.messages.length === 0 ? (
+ <div className="empty-state">
+ <h2>Start a conversation</h2>
+ <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>
+ <Stage1 responses={msg.stage1} />
+ <Stage2
+ rankings={msg.stage2}
+ labelToModel={msg.metadata?.label_to_model}
+ aggregateRankings={msg.metadata?.aggregate_rankings}
+ />
+ <Stage3 finalResponse={msg.stage3} />
+ </div>
+ )}
+ </div>
+ ))
+ )}
+
+ {isLoading && (
+ <div className="loading-indicator">
+ <div className="spinner"></div>
+ <span>Consulting the council...</span>
+ </div>
+ )}
+
+ <div ref={messagesEndRef} />
+ </div>
+
+ <form className="input-form" onSubmit={handleSubmit}>
+ <textarea
+ className="message-input"
+ placeholder="Ask your question... (Shift+Enter for new line, Enter to send)"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ disabled={isLoading}
+ rows={3}
+ />
+ <button
+ type="submit"
+ className="send-button"
+ disabled={!input.trim() || isLoading}
+ >
+ Send
+ </button>
+ </form>
+ </div>
+ );
+}
diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css
new file mode 100644
index 0000000..c4f4b97
--- /dev/null
+++ b/frontend/src/components/Sidebar.css
@@ -0,0 +1,78 @@
+.sidebar {
+ width: 260px;
+ background: #f8f8f8;
+ border-right: 1px solid #e0e0e0;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.sidebar-header {
+ padding: 16px;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.sidebar-header h1 {
+ font-size: 18px;
+ margin: 0 0 12px 0;
+ color: #333;
+}
+
+.new-conversation-btn {
+ width: 100%;
+ padding: 10px;
+ background: #4a90e2;
+ border: 1px solid #4a90e2;
+ border-radius: 6px;
+ color: #fff;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background 0.2s;
+ font-weight: 500;
+}
+
+.new-conversation-btn:hover {
+ background: #357abd;
+ border-color: #357abd;
+}
+
+.conversation-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.no-conversations {
+ padding: 16px;
+ text-align: center;
+ color: #999;
+ font-size: 14px;
+}
+
+.conversation-item {
+ padding: 12px;
+ margin-bottom: 4px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.conversation-item:hover {
+ background: #f0f0f0;
+}
+
+.conversation-item.active {
+ background: #e8f0fe;
+ border: 1px solid #4a90e2;
+}
+
+.conversation-title {
+ color: #333;
+ font-size: 14px;
+ margin-bottom: 4px;
+}
+
+.conversation-meta {
+ color: #999;
+ font-size: 12px;
+}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
new file mode 100644
index 0000000..c189690
--- /dev/null
+++ b/frontend/src/components/Sidebar.jsx
@@ -0,0 +1,43 @@
+import { useState, useEffect } from 'react';
+import './Sidebar.css';
+
+export default function Sidebar({
+ conversations,
+ currentConversationId,
+ onSelectConversation,
+ onNewConversation,
+}) {
+ return (
+ <div className="sidebar">
+ <div className="sidebar-header">
+ <h1>LLM Council</h1>
+ <button className="new-conversation-btn" onClick={onNewConversation}>
+ + New Conversation
+ </button>
+ </div>
+
+ <div className="conversation-list">
+ {conversations.length === 0 ? (
+ <div className="no-conversations">No conversations yet</div>
+ ) : (
+ conversations.map((conv) => (
+ <div
+ key={conv.id}
+ className={`conversation-item ${
+ conv.id === currentConversationId ? 'active' : ''
+ }`}
+ onClick={() => onSelectConversation(conv.id)}
+ >
+ <div className="conversation-title">
+ Conversation {conv.id.slice(0, 8)}...
+ </div>
+ <div className="conversation-meta">
+ {conv.message_count} messages
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/src/components/Stage1.css b/frontend/src/components/Stage1.css
new file mode 100644
index 0000000..1f5133e
--- /dev/null
+++ b/frontend/src/components/Stage1.css
@@ -0,0 +1,65 @@
+.stage {
+ margin: 24px 0;
+ padding: 20px;
+ background: #fafafa;
+ border-radius: 8px;
+ border: 1px solid #e0e0e0;
+}
+
+.stage-title {
+ margin: 0 0 16px 0;
+ color: #333;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.tabs {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.tab {
+ padding: 8px 16px;
+ background: #ffffff;
+ border: 1px solid #d0d0d0;
+ border-radius: 6px 6px 0 0;
+ color: #666;
+ cursor: pointer;
+ font-size: 14px;
+ transition: all 0.2s;
+}
+
+.tab:hover {
+ background: #f0f0f0;
+ color: #333;
+ border-color: #4a90e2;
+}
+
+.tab.active {
+ background: #ffffff;
+ color: #4a90e2;
+ border-color: #4a90e2;
+ border-bottom-color: #ffffff;
+ font-weight: 600;
+}
+
+.tab-content {
+ background: #ffffff;
+ padding: 16px;
+ border-radius: 6px;
+ border: 1px solid #e0e0e0;
+}
+
+.model-name {
+ color: #888;
+ font-size: 12px;
+ margin-bottom: 12px;
+ font-family: monospace;
+}
+
+.response-text {
+ color: #333;
+ line-height: 1.6;
+}
diff --git a/frontend/src/components/Stage1.jsx b/frontend/src/components/Stage1.jsx
new file mode 100644
index 0000000..071937c
--- /dev/null
+++ b/frontend/src/components/Stage1.jsx
@@ -0,0 +1,36 @@
+import { useState } from 'react';
+import ReactMarkdown from 'react-markdown';
+import './Stage1.css';
+
+export default function Stage1({ responses }) {
+ const [activeTab, setActiveTab] = useState(0);
+
+ if (!responses || responses.length === 0) {
+ return null;
+ }
+
+ return (
+ <div className="stage stage1">
+ <h3 className="stage-title">Stage 1: Individual Responses</h3>
+
+ <div className="tabs">
+ {responses.map((resp, index) => (
+ <button
+ key={index}
+ className={`tab ${activeTab === index ? 'active' : ''}`}
+ onClick={() => setActiveTab(index)}
+ >
+ {resp.model.split('/')[1] || resp.model}
+ </button>
+ ))}
+ </div>
+
+ <div className="tab-content">
+ <div className="model-name">{responses[activeTab].model}</div>
+ <div className="response-text markdown-content">
+ <ReactMarkdown>{responses[activeTab].response}</ReactMarkdown>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/src/components/Stage2.css b/frontend/src/components/Stage2.css
new file mode 100644
index 0000000..99c460a
--- /dev/null
+++ b/frontend/src/components/Stage2.css
@@ -0,0 +1,153 @@
+.stage2 {
+ background: #fafafa;
+}
+
+.stage2 h4 {
+ margin: 20px 0 8px 0;
+ color: #333;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.stage2 h4:first-of-type {
+ margin-top: 0;
+}
+
+.stage-description {
+ margin: 0 0 12px 0;
+ color: #666;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.aggregate-rankings {
+ background: #f0f7ff;
+ padding: 16px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+ border: 2px solid #d0e7ff;
+}
+
+.aggregate-rankings h4 {
+ margin: 0 0 12px 0;
+ color: #2a7ae2;
+ font-size: 15px;
+}
+
+.aggregate-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.aggregate-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px;
+ background: #ffffff;
+ border-radius: 6px;
+ border: 1px solid #d0e7ff;
+}
+
+.rank-position {
+ color: #2a7ae2;
+ font-weight: 700;
+ font-size: 16px;
+ min-width: 35px;
+}
+
+.rank-model {
+ flex: 1;
+ color: #333;
+ font-family: monospace;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.rank-score {
+ color: #666;
+ font-size: 13px;
+ font-family: monospace;
+}
+
+.stage2 .tabs {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.stage2 .tab {
+ padding: 8px 16px;
+ background: #ffffff;
+ border: 1px solid #d0d0d0;
+ border-radius: 6px 6px 0 0;
+ color: #666;
+ cursor: pointer;
+ font-size: 14px;
+ transition: all 0.2s;
+}
+
+.stage2 .tab:hover {
+ background: #f0f0f0;
+ color: #333;
+ border-color: #4a90e2;
+}
+
+.stage2 .tab.active {
+ background: #ffffff;
+ color: #4a90e2;
+ border-color: #4a90e2;
+ border-bottom-color: #ffffff;
+ font-weight: 600;
+}
+
+.stage2 .tab-content {
+ background: #ffffff;
+ padding: 16px;
+ border-radius: 6px;
+ border: 1px solid #e0e0e0;
+ margin-bottom: 20px;
+}
+
+.ranking-model {
+ color: #888;
+ font-size: 12px;
+ font-family: monospace;
+ margin-bottom: 12px;
+}
+
+.ranking-content {
+ color: #333;
+ line-height: 1.6;
+ font-size: 14px;
+}
+
+.parsed-ranking {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 2px solid #e0e0e0;
+}
+
+.parsed-ranking strong {
+ color: #2a7ae2;
+ font-size: 13px;
+}
+
+.parsed-ranking ol {
+ margin: 8px 0 0 0;
+ padding-left: 24px;
+ color: #333;
+}
+
+.parsed-ranking li {
+ margin: 4px 0;
+ font-family: monospace;
+ font-size: 13px;
+}
+
+.rank-count {
+ color: #999;
+ font-size: 12px;
+}
diff --git a/frontend/src/components/Stage2.jsx b/frontend/src/components/Stage2.jsx
new file mode 100644
index 0000000..2550fa6
--- /dev/null
+++ b/frontend/src/components/Stage2.jsx
@@ -0,0 +1,99 @@
+import { useState } from 'react';
+import ReactMarkdown from 'react-markdown';
+import './Stage2.css';
+
+function deAnonymizeText(text, labelToModel) {
+ if (!labelToModel) return text;
+
+ let result = text;
+ // Replace each "Response X" with the actual model name
+ Object.entries(labelToModel).forEach(([label, model]) => {
+ const modelShortName = model.split('/')[1] || model;
+ result = result.replace(new RegExp(label, 'g'), `**${modelShortName}**`);
+ });
+ return result;
+}
+
+export default function Stage2({ rankings, labelToModel, aggregateRankings }) {
+ const [activeTab, setActiveTab] = useState(0);
+
+ if (!rankings || rankings.length === 0) {
+ return null;
+ }
+
+ return (
+ <div className="stage stage2">
+ <h3 className="stage-title">Stage 2: Peer Rankings</h3>
+
+ <h4>Raw Evaluations</h4>
+ <p className="stage-description">
+ Each model evaluated all responses (anonymized as Response A, B, C, etc.) and provided rankings.
+ Below, model names are shown in <strong>bold</strong> for readability, but the original evaluation used anonymous labels.
+ </p>
+
+ <div className="tabs">
+ {rankings.map((rank, index) => (
+ <button
+ key={index}
+ className={`tab ${activeTab === index ? 'active' : ''}`}
+ onClick={() => setActiveTab(index)}
+ >
+ {rank.model.split('/')[1] || rank.model}
+ </button>
+ ))}
+ </div>
+
+ <div className="tab-content">
+ <div className="ranking-model">
+ {rankings[activeTab].model}
+ </div>
+ <div className="ranking-content markdown-content">
+ <ReactMarkdown>
+ {deAnonymizeText(rankings[activeTab].ranking, labelToModel)}
+ </ReactMarkdown>
+ </div>
+
+ {rankings[activeTab].parsed_ranking &&
+ rankings[activeTab].parsed_ranking.length > 0 && (
+ <div className="parsed-ranking">
+ <strong>Extracted Ranking:</strong>
+ <ol>
+ {rankings[activeTab].parsed_ranking.map((label, i) => (
+ <li key={i}>
+ {labelToModel && labelToModel[label]
+ ? labelToModel[label].split('/')[1] || labelToModel[label]
+ : label}
+ </li>
+ ))}
+ </ol>
+ </div>
+ )}
+ </div>
+
+ {aggregateRankings && aggregateRankings.length > 0 && (
+ <div className="aggregate-rankings">
+ <h4>Aggregate Rankings (Street Cred)</h4>
+ <p className="stage-description">
+ Combined results across all peer evaluations (lower score is better):
+ </p>
+ <div className="aggregate-list">
+ {aggregateRankings.map((agg, index) => (
+ <div key={index} className="aggregate-item">
+ <span className="rank-position">#{index + 1}</span>
+ <span className="rank-model">
+ {agg.model.split('/')[1] || agg.model}
+ </span>
+ <span className="rank-score">
+ Avg: {agg.average_rank.toFixed(2)}
+ </span>
+ <span className="rank-count">
+ ({agg.rankings_count} votes)
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/frontend/src/components/Stage3.css b/frontend/src/components/Stage3.css
new file mode 100644
index 0000000..954a9d9
--- /dev/null
+++ b/frontend/src/components/Stage3.css
@@ -0,0 +1,25 @@
+.stage3 {
+ background: #f0fff0;
+ border-color: #c8e6c8;
+}
+
+.final-response {
+ background: #ffffff;
+ padding: 20px;
+ border-radius: 6px;
+ border: 1px solid #c8e6c8;
+}
+
+.chairman-label {
+ color: #2d8a2d;
+ font-size: 12px;
+ font-family: monospace;
+ margin-bottom: 12px;
+ font-weight: 600;
+}
+
+.final-text {
+ color: #333;
+ line-height: 1.7;
+ font-size: 15px;
+}
diff --git a/frontend/src/components/Stage3.jsx b/frontend/src/components/Stage3.jsx
new file mode 100644
index 0000000..9a9dbf7
--- /dev/null
+++ b/frontend/src/components/Stage3.jsx
@@ -0,0 +1,22 @@
+import ReactMarkdown from 'react-markdown';
+import './Stage3.css';
+
+export default function Stage3({ finalResponse }) {
+ if (!finalResponse) {
+ return null;
+ }
+
+ return (
+ <div className="stage stage3">
+ <h3 className="stage-title">Stage 3: Final Council Answer</h3>
+ <div className="final-response">
+ <div className="chairman-label">
+ Chairman: {finalResponse.model.split('/')[1] || finalResponse.model}
+ </div>
+ <div className="final-text markdown-content">
+ <ReactMarkdown>{finalResponse.response}</ReactMarkdown>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..698f393
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,98 @@
+:root {
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background: #f5f5f5;
+}
+
+#root {
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+}
+
+/* Global markdown styling */
+.markdown-content {
+ padding: 12px;
+}
+
+.markdown-content p {
+ margin: 0 0 12px 0;
+}
+
+.markdown-content p:last-child {
+ margin-bottom: 0;
+}
+
+.markdown-content h1,
+.markdown-content h2,
+.markdown-content h3,
+.markdown-content h4,
+.markdown-content h5,
+.markdown-content h6 {
+ margin: 16px 0 8px 0;
+}
+
+.markdown-content h1:first-child,
+.markdown-content h2:first-child,
+.markdown-content h3:first-child,
+.markdown-content h4:first-child,
+.markdown-content h5:first-child,
+.markdown-content h6:first-child {
+ margin-top: 0;
+}
+
+.markdown-content ul,
+.markdown-content ol {
+ margin: 0 0 12px 0;
+ padding-left: 24px;
+}
+
+.markdown-content li {
+ margin: 4px 0;
+}
+
+.markdown-content pre {
+ background: #f5f5f5;
+ padding: 12px;
+ border-radius: 4px;
+ overflow-x: auto;
+ margin: 0 0 12px 0;
+}
+
+.markdown-content code {
+ background: #f5f5f5;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-family: monospace;
+ font-size: 0.9em;
+}
+
+.markdown-content pre code {
+ background: none;
+ padding: 0;
+}
+
+.markdown-content blockquote {
+ margin: 0 0 12px 0;
+ padding-left: 16px;
+ border-left: 4px solid #ddd;
+ color: #666;
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+ <StrictMode>
+ <App />
+ </StrictMode>,
+)