diff --git a/frontend/src/components/protocol/AddNodePanel.tsx b/frontend/src/components/protocol/AddNodePanel.tsx index 4a95bc4..3cc71ee 100644 --- a/frontend/src/components/protocol/AddNodePanel.tsx +++ b/frontend/src/components/protocol/AddNodePanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { ArrowRight, Atom, BookMarked, Bot, Braces, BrainCircuit, Cloud, Code2, Cog, Database, FileText, HardDrive, Repeat2, Route, ScrollText, ShieldCheck, Server, Sparkles, X } from 'lucide-react' +import { ArrowRight, Atom, BookMarked, Bot, Braces, BrainCircuit, Cloud, Code2, Cog, Database, FileText, HardDrive, Repeat2, Route, ScrollText, ShieldCheck, Server, Sparkles, User, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DATASET_BROWSE } from './datasetCatalog' @@ -107,6 +107,13 @@ const NODE_CATALOG = [ description: 'Not yet functional -- declares intent for a future phase', icon: BrainCircuit, }, + { + type: 'persona', + category: 'knowledge', + label: 'Persona', + description: 'OCEAN personality traits for experimental manipulation of agent behavior', + icon: User, + }, // Not a node type either -- picking this opens the dataset browser // (DatasetBrowserPanel), same reasoning as MCP Servers above and Skills // below: the diff --git a/frontend/src/components/protocol/DesignTab.tsx b/frontend/src/components/protocol/DesignTab.tsx index 369919e..b7d56d6 100644 --- a/frontend/src/components/protocol/DesignTab.tsx +++ b/frontend/src/components/protocol/DesignTab.tsx @@ -504,6 +504,8 @@ export function DesignTab({ // too -- not just the cell rows. queryClient.invalidateQueries({ queryKey: ['experiments', experiment.id, 'design-revisions'] }) queryClient.invalidateQueries({ queryKey: ['experiments', experiment.id, 'design-impact'] }) + // Invalidate runs list which shows cell labels + queryClient.invalidateQueries({ queryKey: ['protocol-runs'] }) }, }) diff --git a/frontend/src/components/protocol/FactorEditorDialog.tsx b/frontend/src/components/protocol/FactorEditorDialog.tsx index 35ae361..dce7e09 100644 --- a/frontend/src/components/protocol/FactorEditorDialog.tsx +++ b/frontend/src/components/protocol/FactorEditorDialog.tsx @@ -39,7 +39,9 @@ import { PythonCodeEditor } from './PythonCodeEditor' import { useDialogAutosave } from './useDialogAutosave' import { useDialogDraft } from './useDialogDraft' import type { DesignFactor } from '@/types/experiments' +import { Library, Upload } from 'lucide-react' import { DatasetFactorEditor } from './DatasetFactorEditor' +import { PERSONA_LIBRARY } from './personaLibrary' import { KnowledgeFactorEditor } from './KnowledgeFactorEditor' import { ScriptFactorEditor } from './ScriptFactorEditor' import { ToolFactorEditor } from './ToolFactorEditor' @@ -419,6 +421,195 @@ function DatasetConfigLevelRow({ ) } +function PersonaLevelRow({ value, onChange }: { value: StructuredLevel; onChange: (next: StructuredLevel) => void }) { + const [selectedPersonas, setSelectedPersonas] = useState(() => { + const ids = value.persona_ids as string[] | undefined + if (ids && Array.isArray(ids) && ids.length > 0) return ids + const id = value.persona_id as string | null + return id ? [id] : [''] + }) + + // Sync state when value changes (e.g., when loading an existing factor for editing) + useEffect(() => { + const ids = value.persona_ids as string[] | undefined + if (ids && Array.isArray(ids) && ids.length > 0) { + setSelectedPersonas(ids) + } else { + const id = value.persona_id as string | null + setSelectedPersonas(id ? [id] : ['']) + } + }, [value.persona_ids, value.persona_id]) + + function patch(patch: StructuredLevel) { + onChange({ ...value, ...patch }) + } + + function updatePersona(index: number, newId: string | null) { + const newSelected = [...selectedPersonas] + newSelected[index] = newId || '' + const filtered = newSelected.filter(id => id !== '') + setSelectedPersonas(filtered.length > 0 ? filtered : ['']) + + const combinedText = filtered + .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.prompt ?? '') + .filter(Boolean) + .join('\n\n') + + const allLabels = filtered + .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.label) + .filter(Boolean) + .join(' + ') + + patch({ + persona_text: combinedText, + persona_id: filtered[0] || null, + persona_ids: filtered, + persona_label: allLabels || null, + mode: 'library', + persona_file: null + }) + } + + function addPersona() { + if (selectedPersonas.length < 5) { + setSelectedPersonas([...selectedPersonas, '']) + } + } + + function removePersona(index: number) { + const newSelected = selectedPersonas.filter((_, i) => i !== index) + setSelectedPersonas(newSelected.length > 0 ? newSelected : ['']) + + const combinedText = newSelected + .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.prompt ?? '') + .filter(Boolean) + .join('\n\n') + + const allLabels = newSelected + .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.label) + .filter(Boolean) + .join(' + ') + + patch({ + persona_text: combinedText, + persona_id: newSelected[0] || null, + persona_ids: newSelected, + persona_label: allLabels || null, + mode: newSelected.length > 0 ? 'library' : 'custom', + persona_file: null + }) + } + + async function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + const text = await file.text() + patch({ + persona_text: text, + persona_file: file.name, + mode: 'file', + persona_id: null, + persona_ids: [], + persona_label: null + }) + setSelectedPersonas(['']) + } + + return ( +
+
+ +
+ {selectedPersonas.map((personaId, index) => ( +
+ + {selectedPersonas.length > 1 && ( + + )} +
+ ))} +
+ {selectedPersonas.length < 5 && ( + + )} +
+
+ +
+ + +
+
+
+ +