Spaces:
Sleeping
Sleeping
| 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 ( | |
| <div className="app"> | |
| <Header | |
| theme={theme} | |
| onToggleTheme={() => 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} | |
| /> | |
| <main className="app-main"> | |
| <ParticipantSidebar | |
| participants={selectedParticipants} | |
| enabledMap={enabledMap} | |
| modelAssignments={modelAssignments} | |
| neonPromptByModelId={neonPromptByModelId} | |
| onToggleEnabled={handleSidebarToggleEnabled} | |
| onRemove={handleSidebarRemove} | |
| autoSelectMode={autoSelectMode} | |
| maxParticipants={maxParticipants} | |
| /> | |
| <div className="content"> | |
| <ChatControls | |
| ref={chatControlsRef} | |
| demoQuestions={demoQuestions} | |
| onStart={handleStart} | |
| onStop={handleStop} | |
| disabled={startDisabled} | |
| isRunning={isRunning} | |
| disabledReason={startDisabledReason} | |
| disabledTooltip={startDisabledTooltip} | |
| activeQuestion={activeQuestion} | |
| /> | |
| <ChatArea | |
| messages={messages} | |
| systemMessages={systemMessages} | |
| isRunning={isRunning} | |
| hasEnoughParticipantsToStart={hasEnoughParticipantsToStart} | |
| statusText={statusText} | |
| pause={pause} | |
| onContinuePause={handleContinuePause} | |
| participants={sessionParticipants.length > 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} | |
| /> | |
| </div> | |
| </main> | |
| <footer className="app-footer"> | |
| Copyright Neon.ai. All rights reserved.{' '} | |
| <a href="https://www.neon.ai/contact" target="_blank" rel="noopener noreferrer">Patents and licensing</a> | |
| </footer> | |
| <ExpertPersonaModal | |
| isOpen={expertModalOpen} | |
| initial={expertEditing} | |
| onClose={() => { setExpertModalOpen(false); setExpertEditing(null); }} | |
| onSave={handleSaveExpert} | |
| onDelete={handleDeleteExpert} | |
| allModels={allModelsFlat} | |
| defaultModelId={expertDefaultModelId} | |
| panelContext={expertPanelContext} | |
| orchestratorModelId={orchestratorModel || undefined} | |
| /> | |
| {tableOpen && ( | |
| <ChatTableView | |
| data={tableData} | |
| onClose={() => setTableOpen(false)} | |
| onExportCsv={handleDownloadCsvTable} | |
| /> | |
| )} | |
| <CredentialSummaryModal | |
| isOpen={credentialsOpen} | |
| data={credentialsData} | |
| onClose={() => setCredentialsOpen(false)} | |
| onRefresh={handleRefreshCredentials} | |
| humanParticipantId={humanParticipant?.participant_id || null} | |
| onEditHumanCredential={handleEditHumanCredential} | |
| /> | |
| <HumanParticipantModal | |
| isOpen={humanModalOpen} | |
| initial={humanEditing} | |
| onClose={() => { setHumanModalOpen(false); setHumanEditing(null); }} | |
| onSave={handleSaveHuman} | |
| onRemove={humanEditing ? handleRemoveHuman : null} | |
| /> | |
| <ConversationLimitsModal | |
| isOpen={limitsOpen} | |
| schema={limitsSchema} | |
| overrides={limitsOverrides} | |
| onClose={() => setLimitsOpen(false)} | |
| onChange={handleConversationLimitsChange} | |
| onResetAll={handleConversationLimitsResetAll} | |
| /> | |
| <PromptCatalogModal | |
| isOpen={promptCatalogOpen} | |
| catalog={promptCatalog} | |
| onClose={() => setPromptCatalogOpen(false)} | |
| /> | |
| <RateLimitNotice | |
| kind={rateLimitNotice} | |
| onClose={() => setRateLimitNotice(null)} | |
| /> | |
| </div> | |
| ); | |
| } | |