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 ? ( + {advisor.name + ) : 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) => {
- id !== 'aggregated') + )} thinkingAdvisors={thinkingAdvisors} getAdvisorColors={getAdvisorColors} isDark={isDark} />
- {/* Add session title display */} - {currentSessionTitle && ( -
- {currentSessionTitle} -
- )} - {/* Export Button */} {
{messageGroups.map((group) => ( - group.type === 'advisor_group' ? ( - m.id).join('-')} - messages={group.messages} - onReply={handleReplyToMessage} - onExpand={handleExpandMessage} - onClick={handleMessageClick} - /> - ) : ( + group.type === 'advisor_group' ? (() => { + const hasAggregated = group.aggregatedMessages.length > 0; + const view = groupViews[group.groupId] || (hasAggregated ? 'aggregated' : 'panel'); + const isSynth = !!synthesizingGroups[group.groupId]; + const showAggregated = view === 'aggregated' && hasAggregated; + const shown = showAggregated ? group.aggregatedMessages : group.panelMessages; + const showToggle = group.panelMessages.length > 1 || hasAggregated || isSynth; + const switchTo = (target) => { + if ((target === 'aggregated') !== showAggregated) { + handleToggleGroupView(group.groupId, group.panelMessages, hasAggregated); + } + }; + const segBtn = (active) => ({ + display: 'flex', alignItems: 'center', gap: 6, + fontSize: 12.5, padding: '5px 10px', border: 'none', + borderRadius: 6, cursor: isSynth ? 'default' : 'pointer', + fontFamily: 'inherit', + background: active ? 'var(--accent-primary, #6366f1)' : 'transparent', + color: active ? '#fff' : 'var(--text-secondary)', + }); + return ( +
+ {showToggle && ( +
+ + +
+ )} + {isSynth && ( +
+ + Combining advisor responses into one answer… +
+ )} + {shown.length > 0 && ( + m.id).join('-')} + messages={shown} + onReply={handleReplyToMessage} + onExpand={handleExpandMessage} + onClick={handleMessageClick} + /> + )} +
+ ); + })() : (
{group.message.type === 'user' && (
@@ -969,15 +1182,17 @@ const handleNewChat = async (sessionId = null) => {
)} -