import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Upload, Save, Trash2, Wand2 } from 'lucide-react'; import { generateRole, generateRoleFreeform, suggestModel } from '../utils/api'; /** * Single source of truth for creating Expert Personas. Replaces the * inline PersonaAccordion + DevMenu persona-mode/role-style settings * from LLMChats3 - those choices now live inside this modal. * * Tabs: Structured | Freeform * Role-style toggle: AI-completed | Exact (matches LLMChats3 semantics) * Freeform tab supports a file upload for writing samples. */ export default function ExpertPersonaModal({ isOpen, initial, // existing persona to edit, or null for new onClose, onSave, onDelete, allModels, // [{ id, name, provider }] defaultModelId, panelContext, // [{ name, model_id, provider }] — other panel members orchestratorModelId, // optional override for the meta-LLM call }) { const [activeTab, setActiveTab] = useState('freeform'); const [name, setName] = useState(''); const [profile, setProfile] = useState(''); const [identity, setIdentity] = useState(''); const [samples, setSamples] = useState(''); const [freeText, setFreeText] = useState(''); const [roleStyle, setRoleStyle] = useState('ai_completed'); const [modelId, setModelId] = useState(defaultModelId || ''); const [generatedPrompt, setGeneratedPrompt] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [suggestBusy, setSuggestBusy] = useState(false); const [suggestion, setSuggestion] = useState(null); const [suggestMessage, setSuggestMessage] = useState(''); const fileInputRef = useRef(null); const rolePromptText = useMemo( () => (generatedPrompt || '').trim(), [generatedPrompt], ); const composeSourceText = useCallback(() => { if (activeTab === 'freeform') { return freeText.trim(); } return [identity, profile, samples] .map(s => (s || '').trim()) .filter(Boolean) .join('\n\n'); }, [activeTab, freeText, identity, profile, samples]); const hasDescriptionContent = useMemo(() => { if (activeTab === 'freeform') { return Boolean(freeText.trim()); } return Boolean(identity.trim() || profile.trim() || samples.trim()); }, [activeTab, freeText, identity, profile, samples]); const modelNameForId = useCallback((id) => { const m = (allModels || []).find(x => x.id === id); return m ? m.name : id; }, [allModels]); const modelProviderForId = useCallback((id) => { const m = (allModels || []).find(x => x.id === id); return m?.provider || ''; }, [allModels]); const resolveModelId = useCallback((preferred) => { const models = allModels || []; if (preferred && models.some(m => m.id === preferred)) { return preferred; } return models[0]?.id || ''; }, [allModels]); useEffect(() => { if (!isOpen) return; if (initial) { setActiveTab(initial.input_mode || 'freeform'); setName(initial.name || ''); setProfile(initial.profile || ''); setIdentity(initial.identity || ''); setSamples(initial.samples || ''); setFreeText(initial.freeform || ''); setRoleStyle(initial.role_style || 'ai_completed'); setModelId(resolveModelId(initial.model_id || defaultModelId)); setGeneratedPrompt(initial.role_prompt || ''); } else { setActiveTab('freeform'); setName(''); setProfile(''); setIdentity(''); setSamples(''); setFreeText(''); setRoleStyle('ai_completed'); setModelId(resolveModelId(defaultModelId)); setGeneratedPrompt(''); } setError(''); setSuggestion(null); setSuggestMessage(''); setSuggestBusy(false); }, [isOpen, initial, defaultModelId, resolveModelId]); if (!isOpen) return null; const handleFileUpload = async (e) => { const file = e.target.files?.[0]; if (!file) return; try { const text = await file.text(); setFreeText(prev => (prev ? prev + '\n\n' : '') + text); } catch (err) { setError(`File read failed: ${err.message}`); } e.target.value = ''; }; const handleGenerate = async () => { setError(''); if (!name.trim()) { setError('Persona needs a name.'); return; } if (!hasDescriptionContent) { setError('Add a description before generating a role prompt.'); return; } setBusy(true); try { const result = activeTab === 'freeform' ? await generateRoleFreeform({ name: name.trim(), text: freeText, role_style: roleStyle, orchestrator_model_id: orchestratorModelId || undefined, }) : await generateRole({ name: name.trim(), profile, identity, samples, role_style: roleStyle, orchestrator_model_id: orchestratorModelId || undefined, }); setGeneratedPrompt(result.role_prompt || ''); } catch (err) { setError(err.message || String(err)); } finally { setBusy(false); } }; const handleSuggestModel = async () => { setError(''); setSuggestion(null); setSuggestMessage(''); const sourceText = composeSourceText(); if (!sourceText && !rolePromptText) { setSuggestMessage( 'Enter a description or role prompt for a model to be suggested.', ); return; } if (!(allModels || []).length) { setSuggestMessage('No models available to suggest from.'); return; } setSuggestBusy(true); try { const result = await suggestModel({ persona_name: name.trim() || 'Unnamed', source_text: sourceText, role_prompt: rolePromptText, available_models: allModels, panel_context: panelContext || [], orchestrator_model_id: orchestratorModelId || undefined, }); setSuggestion({ modelId: result.recommended_model_id, rationale: result.rationale || '', }); } catch (err) { setSuggestMessage(err.message || String(err)); } finally { setSuggestBusy(false); } }; const handleAcceptSuggestion = () => { if (!suggestion?.modelId) return; setModelId(suggestion.modelId); setSuggestion(null); }; const canSave = name.trim() && modelId && generatedPrompt.trim(); const handleSave = () => { if (!canSave) return; onSave({ participant_id: initial?.participant_id || `expert_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, kind: 'expert', name: name.trim(), model_id: modelId, role_prompt: generatedPrompt.trim(), input_mode: activeTab, role_style: roleStyle, profile, identity, samples, freeform: freeText, }); }; return (
e.stopPropagation()} >

{initial ? `Edit Expert Persona: ${initial.name}` : 'Create Expert Persona'}

setName(e.target.value)} />
{suggestMessage && (
{suggestMessage}
)} {suggestion && (
Suggested: {modelNameForId(suggestion.modelId)} {modelProviderForId(suggestion.modelId) ? ` (${modelProviderForId(suggestion.modelId)})` : ''}
{suggestion.rationale && (

{suggestion.rationale}

)}
)}

Freeform = one box for everything; Structured = separate fields for identity, profile, and samples.

AI-completed fills in tone and style from your notes (no new facts); Exact uses only what you typed.

{activeTab === 'freeform' ? (