From d18df3c777ab37df7a2be54227e67a36457e26b4 Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Tue, 5 May 2026 09:35:16 -0600 Subject: [PATCH 1/2] Enhance chat functionality with response mode toggle - Added a response mode toggle in EnhancedChatInput to switch between 'panel' and 'aggregated' response modes. - Updated ChatPage to manage response mode state and handle message synthesis in aggregated mode. - Enhanced advisor avatar handling in MessageBubble for improved visual representation. - Updated AppConfigContext to include a synthetic persona for aggregated responses. This update improves user experience by allowing for a more cohesive response from advisors. --- .../src/components/EnhancedChatInput.js | 21 +++- .../src/components/MessageBubble.js | 17 ++- .../src/contexts/AppConfigContext.js | 16 ++- phd-advisor-frontend/src/pages/ChatPage.js | 119 +++++++++++++++--- 4 files changed, 147 insertions(+), 26 deletions(-) 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..573e693e 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -34,6 +34,7 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig const [isLoadingSession, setIsLoadingSession] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); + const [responseMode, setResponseMode] = useState('panel'); // 'panel' | 'aggregated' @@ -404,7 +405,12 @@ const handleNewChat = async (sessionId = null) => { setIsLoading(true); setThinkingAdvisors(['system']); - try { + // In 'aggregated' mode we collect advisor responses internally and merge + // them into a single synthesized message instead of rendering each one. + const aggregatedMode = responseMode === 'aggregated'; + const collectedAdvisorResponses = []; + + const streamChat = async (userInput) => { const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, { method: 'POST', headers: { @@ -412,15 +418,17 @@ const handleNewChat = async (sessionId = null) => { 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ - user_input: inputMessage, + user_input: userInput, response_length: 'medium', - chat_session_id: currentSessionId // Include current session ID + chat_session_id: currentSessionId }), }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return response; + }; - 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(); @@ -452,9 +460,14 @@ const handleNewChat = async (sessionId = null) => { used_documents: d.used_documents || false, document_chunks_used: d.document_chunks_used || 0, }; - setMessages(prev => [...prev, msg]); - setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); - await saveMessageToSession(msg); + if (aggregatedMode) { + collectedAdvisorResponses.push(msg); + setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); + } else { + setMessages(prev => [...prev, msg]); + setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); + await saveMessageToSession(msg); + } break; } case 'clarification': @@ -488,6 +501,75 @@ const handleNewChat = async (sessionId = null) => { } } + // Aggregated mode: take the panel responses we just collected and ask + // the model to synthesize them into a single response. We reuse + // /chat-stream and surface only the first advisor reply as the merged answer. + if (aggregatedMode && collectedAdvisorResponses.length > 0) { + setThinkingAdvisors(['system']); + const perspectives = collectedAdvisorResponses + .map((m, i) => `Perspective ${i + 1} — ${m.advisorName}:\n${m.content}`) + .join('\n\n'); + const synthesisPrompt = + `The user originally asked: "${inputMessage}"\n\n` + + `You received these ${collectedAdvisorResponses.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.`; + + try { + 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) { + const d = payload.data; + mergedMsg = { + id: generateMessageId(), + type: 'advisor', + persona_id: 'aggregated', + content: d.content, + timestamp: new Date(), + advisorName: 'Partner', + is_aggregated: true, + source_personas: collectedAdvisorResponses.map(r => r.persona_id), + }; + break; + } + } + } + // Drain remaining stream so the connection closes cleanly. + try { await sReader.cancel(); } catch (_) {} + + if (mergedMsg) { + setMessages(prev => [...prev, mergedMsg]); + await saveMessageToSession(mergedMsg); + } else { + // Fallback: if synthesis returned nothing usable, show panel responses + // so the user isn't left empty-handed. + for (const m of collectedAdvisorResponses) { + setMessages(prev => [...prev, m]); + await saveMessageToSession(m); + } + } + } catch (synthErr) { + console.error('Synthesis pass failed, falling back to panel:', synthErr); + for (const m of collectedAdvisorResponses) { + setMessages(prev => [...prev, m]); + await saveMessageToSession(m); + } + } + } + } catch (error) { console.error('Error sending message:', error); setMessages(prev => [...prev, { @@ -793,21 +875,16 @@ const handleNewChat = async (sessionId = null) => {
- id !== 'aggregated') + )} thinkingAdvisors={thinkingAdvisors} getAdvisorColors={getAdvisorColors} isDark={isDark} />
- {/* Add session title display */} - {currentSessionTitle && ( -
- {currentSessionTitle} -
- )} - {/* Export Button */} {
)} - Date: Sat, 16 May 2026 10:41:20 -0600 Subject: [PATCH 2/2] Implement response mode persistence and on-demand synthesis in ChatPage - Enhanced ChatPage to persist user-selected response mode ('panel' or 'aggregated') across sessions using localStorage. - Introduced functionality for on-demand synthesis of advisor responses, allowing users to toggle between individual and aggregated views for specific exchanges. - Added error handling for streaming chat responses and improved state management for group views and synthesizing groups. This update enhances user experience by providing a more flexible and cohesive chat interaction. --- phd-advisor-frontend/src/pages/ChatPage.js | 320 +++++++++++++++------ 1 file changed, 228 insertions(+), 92 deletions(-) diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 573e693e..d0558aee 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -34,9 +34,23 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig const [isLoadingSession, setIsLoadingSession] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); - const [responseMode, setResponseMode] = useState('panel'); // 'panel' | 'aggregated' + // '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' }); @@ -369,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; @@ -405,28 +524,12 @@ const handleNewChat = async (sessionId = null) => { setIsLoading(true); setThinkingAdvisors(['system']); - // In 'aggregated' mode we collect advisor responses internally and merge - // them into a single synthesized message instead of rendering each one. 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 = []; - 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; - }; - try { const response = await streamChat(inputMessage); @@ -459,13 +562,13 @@ 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, }; - if (aggregatedMode) { - collectedAdvisorResponses.push(msg); - setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); - } else { + collectedAdvisorResponses.push(msg); + setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); + if (!aggregatedMode) { + // Panel mode: stream responses in live as they arrive. setMessages(prev => [...prev, msg]); - setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); await saveMessageToSession(msg); } break; @@ -501,73 +604,39 @@ const handleNewChat = async (sessionId = null) => { } } - // Aggregated mode: take the panel responses we just collected and ask - // the model to synthesize them into a single response. We reuse - // /chat-stream and surface only the first advisor reply as the merged answer. if (aggregatedMode && collectedAdvisorResponses.length > 0) { - setThinkingAdvisors(['system']); - const perspectives = collectedAdvisorResponses - .map((m, i) => `Perspective ${i + 1} — ${m.advisorName}:\n${m.content}`) - .join('\n\n'); - const synthesisPrompt = - `The user originally asked: "${inputMessage}"\n\n` + - `You received these ${collectedAdvisorResponses.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.`; + // 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 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) { - const d = payload.data; - mergedMsg = { - id: generateMessageId(), - type: 'advisor', - persona_id: 'aggregated', - content: d.content, - timestamp: new Date(), - advisorName: 'Partner', - is_aggregated: true, - source_personas: collectedAdvisorResponses.map(r => r.persona_id), - }; - break; - } - } - } - // Drain remaining stream so the connection closes cleanly. - try { await sReader.cancel(); } catch (_) {} - + const mergedMsg = await synthesizeAggregated({ + userPrompt: inputMessage, + panelMessages: collectedAdvisorResponses, + groupId, + }); if (mergedMsg) { setMessages(prev => [...prev, mergedMsg]); await saveMessageToSession(mergedMsg); + setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' })); } else { - // Fallback: if synthesis returned nothing usable, show panel responses - // so the user isn't left empty-handed. - for (const m of collectedAdvisorResponses) { - setMessages(prev => [...prev, m]); - await saveMessageToSession(m); - } + // 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); - for (const m of collectedAdvisorResponses) { - setMessages(prev => [...prev, m]); - await saveMessageToSession(m); - } + 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) { @@ -797,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++; @@ -925,15 +1008,68 @@ const handleNewChat = async (sessionId = null) => {
{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' && (