diff --git a/phd-advisor-frontend/src/components/EnhancedChatInput.js b/phd-advisor-frontend/src/components/EnhancedChatInput.js
index c6ebad22..a19349f7 100644
--- a/phd-advisor-frontend/src/components/EnhancedChatInput.js
+++ b/phd-advisor-frontend/src/components/EnhancedChatInput.js
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
-import { Send, Paperclip, FileText, X, Trash2, Download } from 'lucide-react';
+import { Send, Paperclip, FileText, X, Trash2, Download, Users, Sparkles } from 'lucide-react';
import FileUpload from './FileUpload';
const EnhancedChatInput = ({
@@ -8,8 +8,10 @@ const EnhancedChatInput = ({
uploadedDocuments = [],
isLoading,
currentChatSessionId,
- authToken,
- placeholder = "Ask your advisors anything..."
+ authToken,
+ placeholder = "Ask your advisors anything...",
+ responseMode = 'panel',
+ onResponseModeChange,
}) => {
const [inputMessage, setInputMessage] = useState('');
const [showUpload, setShowUpload] = useState(false);
@@ -204,6 +206,19 @@ const EnhancedChatInput = ({
{uploadedDocuments.length}
)}
+
+
{/* Right - Send Button */}
diff --git a/phd-advisor-frontend/src/components/MessageBubble.js b/phd-advisor-frontend/src/components/MessageBubble.js
index 5a83e68a..50341480 100644
--- a/phd-advisor-frontend/src/components/MessageBubble.js
+++ b/phd-advisor-frontend/src/components/MessageBubble.js
@@ -339,10 +339,23 @@ const MessageBubble = ({
return (
{!inlineAvatar && (
-
+ {advisor.avatarUrl ? (
+

+ ) : Icon ? (
+
+ ) : (
+
+ {(advisor.name || message.advisorName || 'A').charAt(0)}
+
+ )}
)}
diff --git a/phd-advisor-frontend/src/contexts/AppConfigContext.js b/phd-advisor-frontend/src/contexts/AppConfigContext.js
index c68bfa31..93dcc42f 100644
--- a/phd-advisor-frontend/src/contexts/AppConfigContext.js
+++ b/phd-advisor-frontend/src/contexts/AppConfigContext.js
@@ -109,7 +109,21 @@ export const AppConfigProvider = ({ children }) => {
}, []);
useEffect(() => {
- setAdvisors(buildAdvisors(personaItems, avatarOverrides));
+ const built = buildAdvisors(personaItems, avatarOverrides);
+ // Synthetic persona used for aggregated/synthesized responses — represents
+ // a single combined "Partner" voice rather than the panel of advisors.
+ built.aggregated = {
+ name: 'Partner',
+ role: 'Synthesized Response',
+ description: 'A single combined response merging all advisor perspectives.',
+ color: '#7C3AED',
+ bgColor: '#F3E8FF',
+ darkColor: '#A78BFA',
+ darkBgColor: '#3B2A5E',
+ icon: LucideIcons.User,
+ avatarUrl: avatarOverrides.aggregated || null,
+ };
+ setAdvisors(built);
}, [personaItems, avatarOverrides]);
const setAdvisorAvatar = (advisorId, url) => {
diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js
index a1556fa1..d0558aee 100644
--- a/phd-advisor-frontend/src/pages/ChatPage.js
+++ b/phd-advisor-frontend/src/pages/ChatPage.js
@@ -34,8 +34,23 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig
const [isLoadingSession, setIsLoadingSession] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
+ // 'panel' | 'aggregated' — persisted so the choice survives reloads.
+ const [responseMode, setResponseMode] = useState(() => {
+ try { return localStorage.getItem('responseMode') === 'aggregated' ? 'aggregated' : 'panel'; }
+ catch { return 'panel'; }
+ });
+ useEffect(() => {
+ try { localStorage.setItem('responseMode', responseMode); } catch { /* non-fatal */ }
+ }, [responseMode]);
+
+ // Per-exchange view override: { [groupId]: 'panel' | 'aggregated' }.
+ // Lets the user flip an individual exchange between the advisor panel and
+ // the single combined answer, independently of the global default.
+ const [groupViews, setGroupViews] = useState({});
+ // Group ids currently running an on-demand synthesis pass (for lag UX).
+ const [synthesizingGroups, setSynthesizingGroups] = useState({});
+
-
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -368,6 +383,111 @@ const handleNewChat = async (sessionId = null) => {
};
+ // Reusable streaming call to /chat-stream. Lifted to component scope so the
+ // on-demand "generalize" action can reuse it too.
+ const streamChat = async (userInput) => {
+ const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${authToken}`,
+ },
+ body: JSON.stringify({
+ user_input: userInput,
+ response_length: 'medium',
+ chat_session_id: currentSessionId,
+ }),
+ });
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
+ return response;
+ };
+
+ // Ask the model to merge a set of panel responses into one cohesive answer.
+ // Returns the merged message object, or null if synthesis produced nothing.
+ // NOTE: this reuses /chat-stream client-side until a backend orchestrator
+ // endpoint exists; see the response-modes issue for the planned contract.
+ const synthesizeAggregated = async ({ userPrompt, panelMessages, groupId }) => {
+ const perspectives = panelMessages
+ .map((m, i) => `Perspective ${i + 1} — ${m.advisorName}:\n${m.content}`)
+ .join('\n\n');
+ const synthesisPrompt =
+ `The user originally asked: "${userPrompt}"\n\n` +
+ `You received these ${panelMessages.length} expert perspectives:\n\n${perspectives}\n\n` +
+ `Synthesize them into a single, cohesive best-answer response that integrates the strongest points from each. ` +
+ `Do not list the perspectives separately — produce one unified answer addressed to the user.`;
+
+ const synthResponse = await streamChat(synthesisPrompt);
+ const sReader = synthResponse.body.getReader();
+ const sDecoder = new TextDecoder();
+ let sBuffer = '';
+ let mergedMsg = null;
+
+ while (!mergedMsg) {
+ const { done, value } = await sReader.read();
+ if (done) break;
+ sBuffer += sDecoder.decode(value, { stream: true });
+ const sLines = sBuffer.split('\n');
+ sBuffer = sLines.pop() ?? '';
+ for (const line of sLines) {
+ if (!line.trim()) continue;
+ const payload = JSON.parse(line);
+ if (payload.type === 'advisor' && payload.data?.content) {
+ mergedMsg = {
+ id: generateMessageId(),
+ type: 'advisor',
+ persona_id: 'aggregated',
+ content: payload.data.content,
+ timestamp: new Date(),
+ advisorName: 'Partner',
+ is_aggregated: true,
+ groupId,
+ source_personas: panelMessages.map(r => r.persona_id),
+ };
+ break;
+ }
+ }
+ }
+ // Drain remaining stream so the connection closes cleanly.
+ try { await sReader.cancel(); } catch (_) {}
+ return mergedMsg;
+ };
+
+ // Toggle a single exchange between the panel and the aggregated view. If the
+ // user asks for the aggregated view on an exchange that doesn't have one yet
+ // (e.g. a panel-mode or historical message), synthesize it on demand and
+ // persist it so it survives reloads.
+ const handleToggleGroupView = async (groupId, panelMessages, hasAggregated) => {
+ const current = groupViews[groupId] || (hasAggregated ? 'aggregated' : 'panel');
+ const next = current === 'panel' ? 'aggregated' : 'panel';
+
+ if (next === 'aggregated' && !hasAggregated) {
+ // Find the user prompt that triggered this exchange.
+ const firstId = panelMessages[0]?.id;
+ const idx = messages.findIndex(m => m.id === firstId);
+ let userPrompt = '';
+ for (let i = idx - 1; i >= 0; i--) {
+ if (messages[i].type === 'user') { userPrompt = messages[i].content; break; }
+ }
+
+ setSynthesizingGroups(prev => ({ ...prev, [groupId]: true }));
+ try {
+ const mergedMsg = await synthesizeAggregated({ userPrompt, panelMessages, groupId });
+ if (mergedMsg) {
+ setMessages(prev => [...prev, mergedMsg]);
+ await saveMessageToSession(mergedMsg);
+ setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' }));
+ }
+ } catch (err) {
+ console.error('On-demand synthesis failed:', err);
+ } finally {
+ setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; });
+ }
+ return;
+ }
+
+ setGroupViews(prev => ({ ...prev, [groupId]: next }));
+ };
+
const handleSendMessage = async (inputMessage) => {
if (!inputMessage.trim()) return;
@@ -404,23 +524,14 @@ const handleNewChat = async (sessionId = null) => {
setIsLoading(true);
setThinkingAdvisors(['system']);
- try {
- const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${authToken}`,
- },
- body: JSON.stringify({
- user_input: inputMessage,
- response_length: 'medium',
- chat_session_id: currentSessionId // Include current session ID
- }),
- });
+ const aggregatedMode = responseMode === 'aggregated';
+ const groupId = 'grp_' + generateMessageId();
+ // Always collect this exchange's advisor responses so the panel is stored
+ // even when aggregated mode is the default — the user can toggle to it.
+ const collectedAdvisorResponses = [];
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
+ try {
+ const response = await streamChat(inputMessage);
const reader = response.body.getReader();
const decoder = new TextDecoder();
@@ -451,10 +562,15 @@ const handleNewChat = async (sessionId = null) => {
advisorName: d.persona_name || d.persona_id,
used_documents: d.used_documents || false,
document_chunks_used: d.document_chunks_used || 0,
+ groupId,
};
- setMessages(prev => [...prev, msg]);
+ collectedAdvisorResponses.push(msg);
setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id));
- await saveMessageToSession(msg);
+ if (!aggregatedMode) {
+ // Panel mode: stream responses in live as they arrive.
+ setMessages(prev => [...prev, msg]);
+ await saveMessageToSession(msg);
+ }
break;
}
case 'clarification':
@@ -488,6 +604,41 @@ const handleNewChat = async (sessionId = null) => {
}
}
+ if (aggregatedMode && collectedAdvisorResponses.length > 0) {
+ // Persist the panel responses too (hidden by default in this mode) so
+ // the user can still toggle this exchange back to the full panel.
+ setMessages(prev => [...prev, ...collectedAdvisorResponses]);
+ for (const m of collectedAdvisorResponses) {
+ await saveMessageToSession(m);
+ }
+
+ // Then synthesize the single combined answer.
+ setThinkingAdvisors([]);
+ setSynthesizingGroups(prev => ({ ...prev, [groupId]: true }));
+ try {
+ const mergedMsg = await synthesizeAggregated({
+ userPrompt: inputMessage,
+ panelMessages: collectedAdvisorResponses,
+ groupId,
+ });
+ if (mergedMsg) {
+ setMessages(prev => [...prev, mergedMsg]);
+ await saveMessageToSession(mergedMsg);
+ setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' }));
+ } else {
+ // Nothing usable — fall back to the panel view we already stored.
+ setGroupViews(prev => ({ ...prev, [groupId]: 'panel' }));
+ }
+ } catch (synthErr) {
+ console.error('Synthesis pass failed, falling back to panel:', synthErr);
+ setGroupViews(prev => ({ ...prev, [groupId]: 'panel' }));
+ } finally {
+ setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; });
+ }
+ } else if (!aggregatedMode) {
+ setGroupViews(prev => ({ ...prev, [groupId]: 'panel' }));
+ }
+
} catch (error) {
console.error('Error sending message:', error);
setMessages(prev => [...prev, {
@@ -715,7 +866,21 @@ const handleNewChat = async (sessionId = null) => {
advisorGroup.push(messages[i]);
i++;
}
- groups.push({ type: 'advisor_group', messages: advisorGroup });
+ const aggregatedMessages = advisorGroup.filter(m => m.is_aggregated);
+ const panelMessages = advisorGroup.filter(m => !m.is_aggregated);
+ // Prefer the shared groupId stamped at creation; fall back to a stable
+ // id derived from the message ids so historical/legacy exchanges
+ // (saved before groupId existed) can still be toggled.
+ const groupId =
+ advisorGroup.find(m => m.groupId)?.groupId ||
+ `legacy_${advisorGroup.map(m => m.id).join('_')}`;
+ groups.push({
+ type: 'advisor_group',
+ groupId,
+ messages: advisorGroup,
+ panelMessages,
+ aggregatedMessages,
+ });
} else {
groups.push({ type: 'single', message: messages[i] });
i++;
@@ -793,21 +958,16 @@ const handleNewChat = async (sessionId = null) => {