import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import Header from './components/Header'; import ParticipantSidebar from './components/ParticipantSidebar'; import ChatControls from './components/ChatControls'; import ChatArea from './components/ChatArea'; import ExpertPersonaModal from './components/ExpertPersonaModal'; import ChatTableView from './components/ChatTableView'; import CredentialSummaryModal from './components/CredentialSummaryModal'; import ConversationLimitsModal from './components/ConversationLimitsModal'; import PromptCatalogModal from './components/PromptCatalogModal'; import HumanParticipantModal from './components/HumanParticipantModal'; import RateLimitNotice from './components/RateLimitNotice'; import { fetchModels, fetchPersonas, fetchDemoQuestions, startChat, continueChat, getOrchestrator, setOrchestrator, getSpeedPriority, setSpeedPriority, getAuthStatus, exportChat, exportApiLog, fetchTableView, fetchCredentials, fetchConversationLimitsDefaults, fetchConversationFormats, autoSelectParticipants, fetchPromptCatalog, getRateLimitStatus, submitHumanResponse, patchHumanCredential, generateHumanCredentialFromProfile, } from './utils/api'; import * as storage from './utils/storage'; import './styles/variables.css'; import './styles/layout.css'; import './styles/components.css'; import './styles/ccai.css'; /** True when the user is subject to the per-IP daily chat cap. */ function isRateLimitedUser(auth) { return auth && !auth.is_org_member && auth.remaining_conversations >= 0; } /** Append a system note at the current point in the chat timeline (not end-of-feed). */ function appendInlineChatNote(setMessages, text, extra = {}) { setMessages(prev => [...prev, { role: 'system', text, timestamp: Date.now() / 1000, ...extra, }]); } export default function App() { // Persistent state const persisted = useMemo(() => storage.loadState(), []); const initialParticipants = useMemo( () => storage.resolveInitialParticipants(persisted), [persisted], ); const [theme, setTheme] = useState(() => persisted.theme || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') ); const [expertPersonas, setExpertPersonas] = useState(persisted.expert_personas || []); const [selectedIds, setSelectedIds] = useState(initialParticipants.selectedIds); const [enabledMap, setEnabledMap] = useState(initialParticipants.enabledMap); const [modelAssignments, setModelAssignments] = useState(persisted.model_assignments || {}); const [orchestratorModel, setOrchestratorModelState] = useState(persisted.orchestrator_model_id); const [summarizerModel, setSummarizerModelState] = useState(persisted.summarizer_model_id); const [maxParticipants, setMaxParticipants] = useState(persisted.max_participants || 5); // Response-priority toggle. The backend is the source of truth (so // it stays consistent across browsers), but we mirror the value // here so the UI doesn't flicker after a settings change. // Default false matches the backend default ("Prioritize model choice"). const [speedPriority, setSpeedPriorityState] = useState(false); // Conversation-format plugin catalog + current selection. The // catalog is fetched once on mount from /api/chat/conversation-formats // so adding a new structure / decision plugin server-side doesn't // require a frontend change. Selections fall back to whatever the // backend reports as its default until the user picks otherwise. const [conversationFormats, setConversationFormats] = useState({ structures: [], decisions: [], default_structure_id: 'collaborative', default_decision_id: 'consensus', }); const [conversationStructureId, setConversationStructureIdState] = useState( persisted.conversation_structure_id || null, ); const [decisionMethodId, setDecisionMethodIdState] = useState( persisted.decision_method_id || null, ); const handleConversationStructureChange = useCallback((id) => { setConversationStructureIdState(id || null); storage.setConversationStructureId(id || null); }, []); const handleDecisionMethodChange = useCallback((id) => { setDecisionMethodIdState(id || null); storage.setDecisionMethodId(id || null); }, []); // Backend catalog const [providers, setProviders] = useState([]); const [neonModels, setNeonModels] = useState([]); const [catalog, setCatalog] = useState({ neon: [], extra: [] }); const [demoQuestions, setDemoQuestions] = useState([]); // Display options const [showResponseTime, setShowResponseTime] = useState(false); const [showChatStats, setShowChatStats] = useState(false); // Auth + rate limit const [auth, setAuth] = useState(null); const [dailyLimit, setDailyLimit] = useState(30); // Conversation state const [messages, setMessages] = useState([]); const [systemMessages, setSystemMessages] = useState([]); const [isRunning, setIsRunning] = useState(false); const [statusText, setStatusText] = useState(''); const [sessionId, setSessionId] = useState(null); const [sessionParticipants, setSessionParticipants] = useState([]); const [pause, setPause] = useState(null); const [activeQuestion, setActiveQuestion] = useState(''); // Modals const [expertModalOpen, setExpertModalOpen] = useState(false); const [expertEditing, setExpertEditing] = useState(null); const [tableData, setTableData] = useState(null); const [tableOpen, setTableOpen] = useState(false); // Credential Summary: cached snapshot fed by SSE `credentials_updated` // events, plus an open/closed flag and a question echo for the modal // header. Reset on each new chat start. const [credentialsData, setCredentialsData] = useState(null); const [credentialsOpen, setCredentialsOpen] = useState(false); // Conversation limits: schema (defaults + bounds + descriptions) // pulled from /api/chat/limits/defaults, plus a sparse map of the // user's overrides persisted to localStorage. Empty map means // "use server defaults". The schema lazy-loads on first open. const [limitsSchema, setLimitsSchema] = useState(null); const [limitsOverrides, setLimitsOverrides] = useState( persisted.conversation_limits || {}, ); const [limitsOpen, setLimitsOpen] = useState(false); // Auto-select toggle: when on, the participant dropdown defers // selection to the orchestrator LLM at /chat/start time. We also // snapshot the user's manual selection before turning it on so we // can restore it when it's turned back off. const [autoSelectMode, setAutoSelectMode] = useState( !!persisted.auto_select_mode, ); const [priorManualSelection, setPriorManualSelection] = useState(null); // Prompt catalog: lazily fetched on first open, then cached for the // rest of the session. The catalog is static per backend deploy. const [promptCatalog, setPromptCatalog] = useState(null); const [promptCatalogOpen, setPromptCatalogOpen] = useState(false); // In-the-loop human participant. // humanParticipant is the persisted spec: // { participant_id, name, credential_summary: {...} } | null // humanModalOpen / humanEditing power the Add/Edit modal. // awaitingHuman holds the payload from the last human_turn_needed // SSE event (null when no human turn is pending). // humanSubmitting blocks the slot's buttons while POST is in flight. const [humanParticipant, setHumanParticipant] = useState( persisted.human_participant || null, ); const [humanModalOpen, setHumanModalOpen] = useState(false); const [humanEditing, setHumanEditing] = useState(null); const [awaitingHuman, setAwaitingHuman] = useState(null); const [humanSubmitting, setHumanSubmitting] = useState(false); const abortRef = useRef(null); const chatControlsRef = useRef(null); const humanCredentialGenRef = useRef(null); const oneLeftNoticeShownRef = useRef(false); const [rateLimitNotice, setRateLimitNotice] = useState(null); // ─── Apply theme ──────────────────────────────────────────────── useEffect(() => { document.documentElement.setAttribute('data-theme', theme); storage.setTheme(theme); }, [theme]); // ─── Load catalogs ────────────────────────────────────────────── useEffect(() => { fetchModels().then(d => { setProviders(d.providers || []); setNeonModels(d.neon_models || []); }).catch(err => console.error('Failed to load models:', err)); fetchPersonas().then(setCatalog).catch(err => console.error('Failed to load personas:', err)); fetchDemoQuestions().then(d => setDemoQuestions(d.questions || [])) .catch(err => console.error('Failed to load demo questions:', err)); getOrchestrator().then(d => { // Only sync if user hasn't explicitly chosen one (localStorage wins) if (!persisted.orchestrator_model_id && d?.model_id) { setOrchestratorModelState(d.model_id); } }).catch(() => {}); // Hydrate the Response-priority toggle from the backend so the // initial render of the Settings menu shows the real server state. getSpeedPriority().then(d => { if (typeof d?.enabled === 'boolean') setSpeedPriorityState(d.enabled); }).catch(() => {}); fetchConversationFormats().then(catalog => { if (!catalog || !Array.isArray(catalog.structures)) return; setConversationFormats(catalog); }).catch(() => {}); getAuthStatus().then(setAuth).catch(() => {}); getRateLimitStatus().then(d => { if (d?.daily_limit) setDailyLimit(d.daily_limit); }).catch(() => {}); }, [persisted.orchestrator_model_id]); // Pop up once per session when the daily cap leaves exactly one chat. useEffect(() => { if (!isRateLimitedUser(auth)) return; if (auth.remaining_conversations === 1 && !oneLeftNoticeShownRef.current) { oneLeftNoticeShownRef.current = true; setRateLimitNotice('one_left'); } }, [auth]); // ─── Build a flat list of all models for pickers ──────────────── const allModelsFlat = useMemo(() => { const list = []; for (const p of providers) { for (const m of p.models) { list.push({ id: m.id, name: m.name, provider: p.name, kind: 'provider' }); } } for (const nm of neonModels) { for (const p of (nm.personas || [])) { if (p.enabled === false) continue; list.push({ id: `neon:${nm.model_id}:${p.persona_name}`, name: p.persona_name, provider: `Neon / ${nm.name.split('/').pop()}`, kind: 'neon_character', }); } } return list; }, [providers, neonModels]); // Default for new Expert Personas: orchestrator if it's in the builder // list, otherwise the first model the user can actually pick. const expertDefaultModelId = useMemo(() => { if (orchestratorModel && allModelsFlat.some(m => m.id === orchestratorModel)) { return orchestratorModel; } return allModelsFlat[0]?.id || ''; }, [orchestratorModel, allModelsFlat]); // Map neon:model@ver:persona ids -> HANA system_prompt (from /api/models). const neonPromptByModelId = useMemo(() => { const map = {}; for (const nm of neonModels) { for (const p of (nm.personas || [])) { if (p.enabled === false) continue; const id = `neon:${nm.model_id}:${p.persona_name}`; const sp = (p.system_prompt || '').trim(); if (sp) map[id] = sp; } } return map; }, [neonModels]); // ─── Active participants resolved from selectedIds ────────────── const allCatalogParticipants = useMemo(() => { const map = {}; for (const p of (catalog.neon || [])) map[p.participant_id] = p; for (const p of (catalog.extra || [])) map[p.participant_id] = p; for (const p of (expertPersonas || [])) map[p.participant_id] = p; return map; }, [catalog, expertPersonas]); // Synthetic catalog entry for the in-the-loop human, so they slot // into the same data structures the rest of the app already uses // (sidebar, start payload, credentials display). const humanCatalogEntry = useMemo(() => { if (!humanParticipant) return null; return { participant_id: humanParticipant.participant_id, kind: 'human', name: humanParticipant.name, role_prompt: '', model_id: '', default_model_id: '', model_display: 'Human participant', display_name: 'Human participant', }; }, [humanParticipant]); const selectedParticipants = useMemo(() => { const fromCatalog = selectedIds .map(id => allCatalogParticipants[id]) .filter(Boolean); // The human always appears first in the sidebar / participants list. return humanCatalogEntry ? [humanCatalogEntry, ...fromCatalog] : fromCatalog; }, [selectedIds, allCatalogParticipants, humanCatalogEntry]); // Other panel members sent to the Expert Persona "Suggest a model" // action so recommendations can favor model-family diversity. const expertPanelContext = useMemo(() => { const editingId = expertEditing?.participant_id; return selectedParticipants .filter(p => p.kind !== 'human' && p.participant_id !== editingId) .map(p => { const mid = modelAssignments[p.participant_id] || p.model_id || ''; const m = allModelsFlat.find(x => x.id === mid); return { name: p.name, model_id: mid, provider: m?.provider || '', }; }); }, [selectedParticipants, modelAssignments, expertEditing, allModelsFlat]); const enabledSelectedCount = useMemo(() => { return selectedParticipants.filter(p => enabledMap[p.participant_id] !== false).length; }, [selectedParticipants, enabledMap]); // ─── Persistence ──────────────────────────────────────────────── useEffect(() => { storage.setExpertPersonas(expertPersonas); }, [expertPersonas]); useEffect(() => { storage.setParticipantsSelected(selectedIds); }, [selectedIds]); useEffect(() => { storage.setParticipantsEnabled(enabledMap); }, [enabledMap]); useEffect(() => { storage.setModelAssignments(modelAssignments); }, [modelAssignments]); useEffect(() => { storage.setOrchestratorModelId(orchestratorModel); }, [orchestratorModel]); useEffect(() => { storage.setSummarizerModelId(summarizerModel); }, [summarizerModel]); useEffect(() => { storage.setMaxParticipants(maxParticipants); }, [maxParticipants]); useEffect(() => { storage.setHumanParticipant(humanParticipant); }, [humanParticipant]); // ─── Settings handlers ────────────────────────────────────────── const handleOrchestratorChange = useCallback(async (modelId) => { try { await setOrchestrator(modelId || ''); setOrchestratorModelState(modelId || null); } catch (err) { console.error('Failed to set orchestrator:', err); } }, []); const handleSummarizerChange = useCallback((modelId) => { setSummarizerModelState(modelId || null); }, []); const handleSpeedPriorityChange = useCallback(async (enabled) => { // Optimistic update; revert on backend error so the UI never // claims a setting the server didn't actually accept. setSpeedPriorityState(enabled); try { const d = await setSpeedPriority(enabled); if (typeof d?.enabled === 'boolean') setSpeedPriorityState(d.enabled); } catch (err) { console.error('Failed to set speed priority:', err); setSpeedPriorityState(!enabled); } }, []); const handleMaxParticipantsChange = useCallback((n) => { const clamped = Math.max(3, Math.min(9, n)); setMaxParticipants(clamped); if (selectedIds.length > clamped) { setSelectedIds(prev => prev.slice(0, clamped)); } }, [selectedIds]); const handleModelAssignmentChange = useCallback((participantId, modelId) => { setModelAssignments(prev => { const next = { ...prev }; if (modelId) next[participantId] = modelId; else delete next[participantId]; return next; }); }, []); // ─── Participant ops ──────────────────────────────────────────── const handleToggleParticipant = useCallback((participant, kind) => { const id = participant.participant_id; // The human occupies one of the maxParticipants slots; reserve it // when computing the room left for LLM picks. const humanReserved = humanParticipant ? 1 : 0; setSelectedIds(prev => { if (prev.includes(id)) { // Deselect entirely setEnabledMap(em => { const next = { ...em }; delete next[id]; return next; }); return prev.filter(x => x !== id); } if (prev.length + humanReserved >= maxParticipants) return prev; setEnabledMap(em => ({ ...em, [id]: true })); return [...prev, id]; }); }, [maxParticipants, humanParticipant]); const handleSidebarToggleEnabled = useCallback((participantId, enabled) => { setEnabledMap(em => ({ ...em, [participantId]: enabled })); }, []); const handleSidebarRemove = useCallback((participantId) => { if (humanParticipant && participantId === humanParticipant.participant_id) { setHumanParticipant(null); return; } setSelectedIds(prev => prev.filter(x => x !== participantId)); setEnabledMap(em => { const next = { ...em }; delete next[participantId]; return next; }); }, [humanParticipant]); // ─── Human participant ops ─────────────────────────────────────── const handleOpenHumanModal = useCallback(() => { setHumanEditing(humanParticipant); setHumanModalOpen(true); }, [humanParticipant]); const runHumanCredentialGeneration = useCallback(async (spec, question) => { const result = await generateHumanCredentialFromProfile({ name: spec.name, question: (question || '').trim(), profile_text: spec.profile_text, participant_id: spec.participant_id, orchestrator_model_id: orchestratorModel || null, }); const cred = result.credential || {}; return { ...spec, credential_pending: false, credential_built_for_question: (question || '').trim(), credential_summary: { name: cred.name || spec.name, expertise: cred.expertise || '', personality: cred.personality || '', credibility_for_question: typeof cred.credibility_for_question === 'number' ? cred.credibility_for_question : 0.55, bias_to_watch: cred.bias_to_watch || '', }, }; }, [orchestratorModel]); const startHumanCredentialGeneration = useCallback((spec, question) => { const promise = runHumanCredentialGeneration(spec, question) .then((updated) => { setHumanParticipant(prev => ( prev && prev.participant_id === spec.participant_id ? updated : prev )); return updated; }) .catch((err) => { console.error('Human credential generation failed:', err); setHumanParticipant(prev => ( prev && prev.participant_id === spec.participant_id ? { ...prev, credential_pending: false, credential_error: err.message } : prev )); throw err; }); humanCredentialGenRef.current = promise; return promise; }, [runHumanCredentialGeneration]); const handleSaveHuman = useCallback((spec) => { const pending = { ...spec, credential_pending: true, credential_summary: null, }; setHumanParticipant(pending); setHumanModalOpen(false); setHumanEditing(null); const question = chatControlsRef.current?.getDraftQuestion?.() || ''; startHumanCredentialGeneration(pending, question); }, [startHumanCredentialGeneration]); const handleRemoveHuman = useCallback(() => { humanCredentialGenRef.current = null; setHumanParticipant(null); setHumanModalOpen(false); setHumanEditing(null); }, []); const handleHumanSubmit = useCallback(async (text) => { if (!sessionId || !awaitingHuman) return; setHumanSubmitting(true); try { await submitHumanResponse(sessionId, { text }); } catch (err) { console.error('Human response failed:', err); setSystemMessages(prev => [...prev, { text: `Couldn't send your message: ${err.message}`, }]); } finally { setHumanSubmitting(false); } }, [sessionId, awaitingHuman]); const handleHumanSkip = useCallback(async () => { if (!sessionId || !awaitingHuman) return; setHumanSubmitting(true); try { await submitHumanResponse(sessionId, { text: '', skip: true }); } catch (err) { console.error('Human skip failed:', err); } finally { setHumanSubmitting(false); } }, [sessionId, awaitingHuman]); const handleEditHumanCredential = useCallback(async (patch) => { if (!sessionId) return; try { const result = await patchHumanCredential(sessionId, patch); const updated = result.credential; if (updated) { // Reflect the edit in the persisted spec so re-opens of the // Add-a-Human modal show the latest version. setHumanParticipant(prev => prev ? { ...prev, name: updated.name || prev.name, credential_summary: { name: updated.name || prev.name, expertise: updated.expertise || '', personality: updated.personality || '', credibility_for_question: updated.credibility_for_question ?? 0.55, bias_to_watch: updated.bias_to_watch || '', }, // profile_text unchanged — edits in the modal only adjust the summary. } : prev); // Refresh the credentials cache so the modal reflects the edit. const data = await fetchCredentials(sessionId); setCredentialsData(data); } } catch (err) { console.error('Edit human credential failed:', err); } }, [sessionId]); // ─── Auto-select toggle ───────────────────────────────────────── // When turning ON, snapshot the current manual selection so we can // restore it on OFF. The actual LLM ranking happens in handleStart // (so the user's question is available); this just flips the mode. const handleToggleAutoSelectMode = useCallback((on) => { if (on && !autoSelectMode) { setPriorManualSelection([...selectedIds]); } else if (!on && autoSelectMode && priorManualSelection !== null) { setSelectedIds(priorManualSelection); setPriorManualSelection(null); } setAutoSelectMode(!!on); storage.setAutoSelectMode(!!on); }, [autoSelectMode, selectedIds, priorManualSelection]); // ─── Expert persona ops ───────────────────────────────────────── const handleOpenExpertModal = useCallback((personaOrNull) => { setExpertEditing(personaOrNull); setExpertModalOpen(true); }, []); const handleSaveExpert = useCallback((persona) => { setExpertPersonas(prev => { const idx = prev.findIndex(p => p.participant_id === persona.participant_id); if (idx === -1) return [...prev, persona]; const next = [...prev]; next[idx] = persona; return next; }); setExpertModalOpen(false); setExpertEditing(null); }, []); const handleDeleteExpert = useCallback((id) => { setExpertPersonas(prev => prev.filter(p => p.participant_id !== id)); setSelectedIds(prev => prev.filter(x => x !== id)); setEnabledMap(em => { const n = { ...em }; delete n[id]; return n; }); setExpertModalOpen(false); setExpertEditing(null); }, []); // ─── Downloads ────────────────────────────────────────────────── const downloadFile = useCallback((filename, content, mime = 'text/plain;charset=utf-8') => { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }, []); const handleDownloadTxt = useCallback(async () => { if (!sessionId) return; try { const r = await exportChat(sessionId, 'txt'); downloadFile(r.filename, r.content); } catch (err) { console.error('Export failed:', err); } }, [sessionId, downloadFile]); const handleDownloadMd = useCallback(async () => { if (!sessionId) return; try { const r = await exportChat(sessionId, 'md'); downloadFile(r.filename, r.content); } catch (err) { console.error('Export failed:', err); } }, [sessionId, downloadFile]); const handleDownloadCsvTable = useCallback(async () => { if (!sessionId) return; try { const r = await exportChat(sessionId, 'csv-table'); downloadFile(r.filename, r.content, 'text/csv;charset=utf-8'); } catch (err) { console.error('CSV export failed:', err); } }, [sessionId, downloadFile]); const handleDownloadApiLog = useCallback(async () => { if (!sessionId) return; try { const r = await exportApiLog(sessionId); downloadFile('api_log.json', JSON.stringify(r, null, 2), 'application/json'); } catch (err) { console.error('API log export failed:', err); } }, [sessionId, downloadFile]); // ─── Table view ───────────────────────────────────────────────── const handleShowTableView = useCallback(async () => { if (!sessionId) return; try { const data = await fetchTableView(sessionId); setTableData(data); setTableOpen(true); } catch (err) { console.error('Table fetch failed:', err); } }, [sessionId]); // ─── Credential Summary view ──────────────────────────────────── // Always re-fetch on open so the modal reflects the very latest // server-side state (the Phase-3 refresh may have run after the SSE // event was missed by a stale tab). const handleShowCredentials = useCallback(async () => { if (!sessionId) return; try { const data = await fetchCredentials(sessionId); setCredentialsData(data); setCredentialsOpen(true); } catch (err) { console.error('Credentials fetch failed:', err); } }, [sessionId]); const handleRefreshCredentials = useCallback(async () => { if (!sessionId) return; try { const data = await fetchCredentials(sessionId); setCredentialsData(data); } catch (err) { console.error('Credentials refresh failed:', err); } }, [sessionId]); // ─── Conversation limits (settings) ──────────────────────────── // Lazy-load the schema on first open, then cache it for the rest // of the session. The user's override map is already in state // and persisted to localStorage; we hand it to the modal as the // initial draft and re-persist on every change. const handleShowConversationLimits = useCallback(async () => { if (!limitsSchema) { try { const data = await fetchConversationLimitsDefaults(); setLimitsSchema(data); } catch (err) { console.error('Conversation-limit schema fetch failed:', err); return; } } setLimitsOpen(true); }, [limitsSchema]); const handleConversationLimitsChange = useCallback((next) => { setLimitsOverrides(next); storage.setConversationLimits(next); }, []); const handleConversationLimitsResetAll = useCallback(() => { setLimitsOverrides({}); storage.setConversationLimits({}); }, []); // ─── Prompt catalog (Transparency) ───────────────────────────── const handleShowPromptCatalog = useCallback(async () => { if (!promptCatalog) { try { const data = await fetchPromptCatalog(); setPromptCatalog(data); } catch (err) { console.error('Prompt catalog fetch failed:', err); return; } } setPromptCatalogOpen(true); }, [promptCatalog]); // ─── Build start payload ──────────────────────────────────────── // `participantsOverride`, if provided, replaces the // selectedParticipants-derived list (used by the auto-select flow // because the freshly-chosen list isn't in state yet when we need it). const buildStartPayload = useCallback((theQuestion, participantsOverride, humanOverride) => { // Always honor the sidebar enabled toggles, including when // auto-select supplies a participantsOverride list (which used to // bypass enabledMap and always prepend a disabled human). const sourceList = participantsOverride ?? selectedParticipants; const baseList = sourceList.filter( p => enabledMap[p.participant_id] !== false, ); const participants = baseList.map(p => ({ participant_id: p.participant_id, kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon' : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')), name: p.name, role_prompt: p.kind === 'human' ? null : (p.role_prompt || null), model_id_override: p.kind === 'human' ? null : (modelAssignments[p.participant_id] || null), })); const expert_payload = baseList .filter(p => (p.kind || '').startsWith('expert')) .map(p => ({ participant_id: p.participant_id, name: p.name, model_id: modelAssignments[p.participant_id] || p.model_id, role_prompt: p.role_prompt, })); // The human's pre-authored credential summary rides alongside the // participants array. Backend rejects start if it sees a human in // participants but no human_credential, so this MUST be present // whenever the human is enabled. const hp = humanOverride ?? humanParticipant; const humanInList = baseList.find(p => p.kind === 'human'); let human_credential = null; if (humanInList && hp) { const cs = hp.credential_summary || {}; human_credential = { participant_id: humanInList.participant_id, name: humanInList.name, expertise: cs.expertise || hp.profile_text?.slice(0, 500) || '', personality: cs.personality || '', credibility_for_question: typeof cs.credibility_for_question === 'number' ? cs.credibility_for_question : 0.55, bias_to_watch: cs.bias_to_watch || '', }; } return { question: theQuestion, participants, expert_personas: expert_payload, model_assignments: modelAssignments, orchestrator_model_id: orchestratorModel, summarizer_model_id: summarizerModel, max_participants: maxParticipants, // Sparse override map; backend clamps and falls back per-field. limits: limitsOverrides, human_credential, // Conversation format selection. null fields make the backend // fall back to its built-in defaults (collaborative + consensus). conversation_structure_id: conversationStructureId, decision_method_id: decisionMethodId, }; }, [ selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants, limitsOverrides, humanParticipant, conversationStructureId, decisionMethodId, ]); // ─── Stop / continue ──────────────────────────────────────────── const handleStop = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } setIsRunning(false); setStatusText(''); setPause(null); setSystemMessages(prev => [...prev, { text: 'Chat stopped by user.' }]); }, []); const handleContinuePause = useCallback(async (reason) => { if (!sessionId) return; try { await continueChat(sessionId, reason); setPause(null); } catch (err) { console.error('Continue failed:', err); } }, [sessionId]); // ─── Start chat ───────────────────────────────────────────────── const handleStart = useCallback(async (theQuestion) => { if (!theQuestion || !theQuestion.trim()) return; if (isRateLimitedUser(auth) && auth.remaining_conversations === 0) { setRateLimitNotice('exhausted'); return; } // In auto-select mode the dropdown has no manual picks - skip the // pre-flight count check and validate the chosen pool below instead. if (!autoSelectMode && enabledSelectedCount < 2) return; const controller = new AbortController(); abortRef.current = controller; setIsRunning(true); setMessages([]); setSystemMessages([]); setStatusText( autoSelectMode ? 'Picking participants...' : 'Starting conversation...', ); setSessionId(null); setSessionParticipants([]); setPause(null); setActiveQuestion(theQuestion.trim()); setCredentialsData(null); setAwaitingHuman(null); // Resolve the final participant list. When auto-select is on, ask // the orchestrator to rank every available candidate; otherwise // fall through to the user's manual selection. let resolvedParticipants = null; if (autoSelectMode) { const candidatePool = Object.values(allCatalogParticipants); if (candidatePool.length < 2) { setIsRunning(false); setStatusText(''); setSystemMessages(prev => [...prev, { text: 'Auto-select needs at least 2 candidate participants available.', }]); return; } const candidatesPayload = candidatePool.map(p => ({ participant_id: p.participant_id, name: p.name, role_prompt: p.role_prompt || '', kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon' : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')), model_id: modelAssignments[p.participant_id] || p.model_id || p.default_model_id || '', })); try { // Reserve a seat for the human only when they are enabled in // the sidebar; a disabled human should not be auto-included. const humanEnabled = humanParticipant && enabledMap[humanParticipant.participant_id] !== false; const humanReserved = humanEnabled ? 1 : 0; const llmTarget = Math.max(2, maxParticipants - humanReserved); const result = await autoSelectParticipants({ question: theQuestion.trim(), count: llmTarget, candidates: candidatesPayload, orchestrator_model_id: orchestratorModel, }); const chosenIds = result.selected || []; const chosenLlms = chosenIds .map(id => allCatalogParticipants[id]) .filter(Boolean); resolvedParticipants = humanEnabled && humanCatalogEntry ? [humanCatalogEntry, ...chosenLlms] : chosenLlms; if (resolvedParticipants.length < 2) { setIsRunning(false); setStatusText(''); setSystemMessages(prev => [...prev, { text: 'Auto-select returned too few participants. ' + 'Turn auto-select off and pick manually.', }]); return; } // Reflect the pick in the sidebar. setSelectedIds(chosenIds); setEnabledMap(prev => { const next = { ...prev }; for (const id of chosenIds) next[id] = true; return next; }); if (result.rationale) { setSystemMessages(prev => [...prev, { text: `Auto-select rationale: ${result.rationale}`, }]); } setStatusText('Starting conversation...'); } catch (err) { console.error('Auto-select failed:', err); setIsRunning(false); setStatusText(''); setSystemMessages(prev => [...prev, { text: `Auto-select failed: ${err.message}`, }]); return; } } let humanForStart = humanParticipant; const humanEnabledForStart = humanParticipant && enabledMap[humanParticipant.participant_id] !== false; if (humanEnabledForStart) { const q = theQuestion.trim(); const needsQuestionRefresh = q && humanParticipant.credential_built_for_question !== q; if (humanParticipant.credential_pending && humanCredentialGenRef.current) { setStatusText('Finishing credential summary...'); try { humanForStart = await humanCredentialGenRef.current; } catch { humanForStart = humanParticipant; } } if (!humanForStart?.credential_summary?.expertise || needsQuestionRefresh) { setStatusText('Preparing credential summary...'); try { humanForStart = await runHumanCredentialGeneration(humanParticipant, q); setHumanParticipant(humanForStart); humanCredentialGenRef.current = Promise.resolve(humanForStart); } catch (err) { setIsRunning(false); setStatusText(''); setSystemMessages(prev => [...prev, { text: `Could not generate human credential: ${err.message}`, }]); return; } } } try { await startChat( buildStartPayload(theQuestion, resolvedParticipants, humanForStart), { onSession: (data) => { setSessionId(data.session_id); setSessionParticipants(data.participants || []); }, onMessage: (data) => { setMessages(prev => { const mid = data?.message_id; if (!mid) return [...prev, data]; const idx = prev.findIndex(m => m.message_id === mid); if (idx >= 0) { const next = [...prev]; next[idx] = { ...next[idx], ...data, streaming: false }; return next; } return [...prev, data]; }); setStatusText('Conversation in progress...'); }, onMessageStreamStart: (data) => { setMessages(prev => [...prev, { ...data, role: 'participant', text: '', streaming: true, timestamp: Date.now() / 1000, }]); }, onMessageDelta: (data) => { const mid = data?.message_id; const delta = data?.delta || ''; if (!mid || !delta) return; setMessages(prev => prev.map(m => ( m.message_id === mid ? { ...m, text: `${m.text || ''}${delta}` } : m ))); }, onOrchestrator: (data) => { if (data && data.text) { setMessages(prev => [...prev, { ...data, role: 'orchestrator' }]); } else if (data?.message) { setStatusText(data.message); } }, onStatus: (data) => setStatusText(data.message || ''), onSystem: (data) => { setSystemMessages(prev => [...prev, data]); if (data.text === 'End of Chat') { setStatusText(''); } }, onError: (data) => { setStatusText(''); setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]); }, onParticipantError: (data) => { const text = `${data.name || 'A participant'} couldn't respond this turn.`; appendInlineChatNote(setMessages, text, { kind: 'participant_error', phase: data.phase, participant_id: data.participant_id, }); }, onParticipantSubstituted: (data) => { const name = data.name || 'A participant'; const toDisplay = data.to_model_display || data.to_model_id || 'a substitute model'; appendInlineChatNote( setMessages, `${name}'s primary model didn't respond; continuing with ${toDisplay}.`, { kind: 'participant_substituted', phase: data.phase }, ); }, onParticipantReplaced: (data) => { if (Array.isArray(data?.roster)) { setSessionParticipants(data.roster); } const origName = data.original_name || 'A participant'; const altName = data.new_name || 'an alternate'; appendInlineChatNote( setMessages, `${origName} couldn't give an initial opinion; ${altName} is taking their place.`, { kind: 'participant_replaced', phase: data.phase }, ); }, onVoteCast: (data) => { const voter = data?.voter_name || 'A voter'; let line; if (data?.vote) { line = `${voter} votes ${data.vote}.`; } else if (Array.isArray(data?.ranking) && data.ranking.length > 0) { line = `${voter} submitted ranking: ${data.ranking.join(' > ')}.`; } else if (typeof data?.choice === 'number' && data.choice > 0) { line = `${voter} votes for option ${data.choice}.`; } else { line = `${voter} abstained or returned an invalid ballot.`; } appendInlineChatNote(setMessages, line, { kind: 'vote_cast' }); }, onVoteTally: (data) => { const kind = data?.kind || 'vote'; appendInlineChatNote( setMessages, `Vote complete (${kind}); see report below.`, { kind: 'vote_tally' }, ); }, onFailsafePause: (data) => { setPause({ reason: 'messages', ...data }); }, onOrchestratorCapPause: (data) => { setPause({ reason: 'orchestrator', ...data }); }, onCredentialsUpdated: (data) => { // Backend emits this after the Phase-1.5 build and (when // it changes) after the Phase-3 refresh. We cache the // payload so the modal opens instantly without a round trip. setCredentialsData({ session_id: data.session_id, question: theQuestion.trim(), credentials: data.credentials || [], stage: data.stage || 'built', }); }, onHumanTurnNeeded: (data) => { // Orchestrator is paused waiting on the human; render the // green-bordered input slot and the lower-screen indicator. setAwaitingHuman(data || null); setStatusText( `${data?.speaker_name || 'Human'} is up next.`, ); }, onHumanTurnCleared: () => { setAwaitingHuman(null); setHumanSubmitting(false); }, onDone: () => { setIsRunning(false); setStatusText(''); }, }, controller.signal, ); } catch (err) { if (err.name === 'AbortError') return; console.error('Chat error:', err); const isRateLimit = err.message && err.message.includes('Daily conversation limit'); setSystemMessages(prev => [...prev, { text: isRateLimit ? `Daily conversation limit reached (${dailyLimit}/day). Sign in with HuggingFace for unlimited access.` : `Error: ${err.message}`, }]); } finally { setIsRunning(false); abortRef.current = null; getAuthStatus().then(setAuth).catch(() => {}); } }, [ buildStartPayload, enabledSelectedCount, dailyLimit, auth, autoSelectMode, allCatalogParticipants, modelAssignments, maxParticipants, orchestratorModel, enabledMap, humanParticipant, humanCatalogEntry, runHumanCredentialGeneration, ]); // In auto-select mode we don't require manual picks - the orchestrator // will choose them at /chat/start time, so just need 2+ candidates // available in the catalog. const autoSelectReady = autoSelectMode && Object.keys(allCatalogParticipants).length >= 2; const hasEnoughParticipantsToStart = autoSelectMode ? autoSelectReady : enabledSelectedCount >= 2; const startDisabled = isRunning || !hasEnoughParticipantsToStart; const startDisabledReason = autoSelectMode ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '') : enabledSelectedCount < 2 ? 'Add at least 2 active participants to start.' : ''; const startDisabledTooltip = autoSelectMode ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '') : enabledSelectedCount < 2 ? 'Select at least 2 participants.' : ''; return (
setTheme(t => t === 'light' ? 'dark' : 'light')} auth={auth} catalog={catalog} expertPersonas={expertPersonas} selectedIds={selectedIds} maxParticipants={maxParticipants} onToggleParticipant={handleToggleParticipant} onOpenExpertModal={handleOpenExpertModal} autoSelectMode={autoSelectMode} onToggleAutoSelectMode={handleToggleAutoSelectMode} humanParticipant={humanParticipant} onOpenHumanModal={handleOpenHumanModal} allModels={allModelsFlat} orchestratorModel={orchestratorModel} onOrchestratorChange={handleOrchestratorChange} summarizerModel={summarizerModel} onSummarizerChange={handleSummarizerChange} speedPriority={speedPriority} onSpeedPriorityChange={handleSpeedPriorityChange} conversationFormats={conversationFormats} conversationStructureId={conversationStructureId} onConversationStructureChange={handleConversationStructureChange} decisionMethodId={decisionMethodId} onDecisionMethodChange={handleDecisionMethodChange} showResponseTime={showResponseTime} onShowResponseTimeChange={setShowResponseTime} showChatStats={showChatStats} onShowChatStatsChange={setShowChatStats} onMaxParticipantsChange={handleMaxParticipantsChange} participants={selectedParticipants} modelAssignments={modelAssignments} onModelAssignmentChange={handleModelAssignmentChange} onShowTableView={handleShowTableView} onShowCredentials={handleShowCredentials} hasCredentials={!!sessionId} onShowPromptCatalog={handleShowPromptCatalog} onShowConversationLimits={handleShowConversationLimits} conversationLimitsOverridden={Object.keys(limitsOverrides).length > 0} onDownloadChatTxt={handleDownloadTxt} onDownloadChatMd={handleDownloadMd} onDownloadCsvTable={handleDownloadCsvTable} onDownloadApiLog={handleDownloadApiLog} hasApiLog={!!sessionId} hasChat={messages.length > 0} />
0 ? sessionParticipants : selectedParticipants} showResponseTime={showResponseTime} showChatStats={showChatStats} awaitingHuman={awaitingHuman} humanSubmitting={humanSubmitting} onHumanSubmit={handleHumanSubmit} onHumanSkip={handleHumanSkip} onShowTableView={handleShowTableView} onDownloadChatTxt={handleDownloadTxt} onDownloadChatMd={handleDownloadMd} onDownloadCsvTable={handleDownloadCsvTable} onDownloadApiLog={handleDownloadApiLog} hasApiLog={!!sessionId} />
{ setExpertModalOpen(false); setExpertEditing(null); }} onSave={handleSaveExpert} onDelete={handleDeleteExpert} allModels={allModelsFlat} defaultModelId={expertDefaultModelId} panelContext={expertPanelContext} orchestratorModelId={orchestratorModel || undefined} /> {tableOpen && ( setTableOpen(false)} onExportCsv={handleDownloadCsvTable} /> )} setCredentialsOpen(false)} onRefresh={handleRefreshCredentials} humanParticipantId={humanParticipant?.participant_id || null} onEditHumanCredential={handleEditHumanCredential} /> { setHumanModalOpen(false); setHumanEditing(null); }} onSave={handleSaveHuman} onRemove={humanEditing ? handleRemoveHuman : null} /> setLimitsOpen(false)} onChange={handleConversationLimitsChange} onResetAll={handleConversationLimitsResetAll} /> setPromptCatalogOpen(false)} /> setRateLimitNotice(null)} />
); }