diff --git a/decnet_web/src/components/PersonaGeneration.tsx b/decnet_web/src/components/PersonaGeneration.tsx index 1bcf3d1c..62194f81 100644 --- a/decnet_web/src/components/PersonaGeneration.tsx +++ b/decnet_web/src/components/PersonaGeneration.tsx @@ -1,498 +1,20 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { - Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download, Sparkles, + Mail, Plus, AlertTriangle, Upload, Download, Sparkles, } from '../icons'; -import api, { type ApiError } from '../utils/api'; import { useToast } from './Toasts/useToast'; -import Modal from './Modal/Modal'; +import PersonaCard from './PersonaGeneration/PersonaCard'; +import PersonaEditor from './PersonaGeneration/PersonaEditor'; +import { usePersonaGeneration } from './PersonaGeneration/usePersonaGeneration'; +import { + BLANK, TEMPLATE, coercePersona, mergePersonas, validate, +} from './PersonaGeneration/helpers'; +import type { EmailPersona, FilterKey } from './PersonaGeneration/types'; import './DeckyFleet.css'; import './PersonaGeneration.css'; -type Tone = 'formal' | 'direct' | 'casual' | 'technical' | 'custom'; -type ReplyLatency = 'fast' | 'normal' | 'slow'; - -interface EmailPersona { - name: string; - email: string; - role: string; - tone: Tone; - tone_custom: string | null; - mannerisms: string[]; - language: string | null; - signature: string | null; - active_hours: string; - reply_latency: ReplyLatency; - uses_llms_heavily: boolean; -} - -interface PersonasResponse { - path?: string; - topology_name?: string; - language_default?: string; - personas: EmailPersona[]; -} - -const BLANK: EmailPersona = { - name: '', - email: '', - role: '', - tone: 'formal', - tone_custom: null, - mannerisms: [], - language: 'en', - signature: null, - active_hours: '09:00-18:00', - reply_latency: 'normal', - uses_llms_heavily: false, -}; - -const TONES: Tone[] = ['formal', 'direct', 'casual', 'technical', 'custom']; -const LATENCIES: ReplyLatency[] = ['fast', 'normal', 'slow']; - -type FilterKey = 'all' | Tone; - -function extractErrorDetail(err: unknown, fallback: string): string { - const e = err as ApiError; - if (e?.response?.data?.detail) return e.response.data.detail; - if (e?.response?.status === 403) return 'Insufficient permissions (admin only)'; - if (e?.response?.status === 401) return 'Session expired — please log in again'; - if (e?.message) return e.message; - return fallback; -} - -/** Light client-side validation — server re-validates with the same - * Pydantic schema the worker uses, this is just the early-warn UX. */ -function validate(p: EmailPersona): string | null { - if (!p.name.trim()) return 'name is required'; - if (!p.email.trim()) return 'email is required'; - if (!p.email.includes('@') || !p.email.split('@')[1]?.includes('.')) { - return 'email must look like user@domain.tld'; - } - if (!p.role.trim()) return 'role is required'; - if (p.tone === 'custom' && !(p.tone_custom ?? '').trim()) { - return 'custom tone requires a description'; - } - if (p.mannerisms.length > 12) return 'at most 12 mannerisms per persona'; - return null; -} - -// ─── Bulk upload helpers ────────────────────────────────────────────────── - -const TEMPLATE: { personas: EmailPersona[] } = { - personas: [ - { - name: 'Jane Operator', - email: 'jane@example.com', - role: 'Network Admin', - tone: 'formal', - tone_custom: null, - mannerisms: ["uses bullet points", "signs off with 'Best regards'"], - language: 'en', - signature: 'Jane Operator\nNetwork Admin', - active_hours: '09:00-18:00', - reply_latency: 'normal', - uses_llms_heavily: false, - }, - ], -}; - -/** Soft client-side normalizer for an uploaded persona entry. - * Mirrors the Pydantic rules in decnet/realism/personas.py. - * Server re-validates on save, so this is just early-warn UX. */ -function coercePersona(raw: unknown): { ok: EmailPersona } | { error: string } { - if (!raw || typeof raw !== 'object') return { error: 'entry is not an object' }; - const r = raw as Record; - const name = typeof r.name === 'string' ? r.name.trim() : ''; - const email = typeof r.email === 'string' ? r.email.trim() : ''; - const role = typeof r.role === 'string' ? r.role.trim() : ''; - if (!name) return { error: 'missing name' }; - if (!email) return { error: 'missing email' }; - if (!email.includes('@') || !email.split('@')[1]?.includes('.')) { - return { error: `invalid email "${email}"` }; - } - if (!role) return { error: 'missing role' }; - const tone = TONES.includes(r.tone as Tone) ? (r.tone as Tone) : 'formal'; - const tone_custom = typeof r.tone_custom === 'string' && r.tone_custom.trim() - ? r.tone_custom.slice(0, 128) : null; - if (tone === 'custom' && !tone_custom) { - return { error: 'tone="custom" requires a non-empty tone_custom' }; - } - const reply_latency = LATENCIES.includes(r.reply_latency as ReplyLatency) - ? (r.reply_latency as ReplyLatency) : 'normal'; - const mannerisms = Array.isArray(r.mannerisms) - ? r.mannerisms.filter((m): m is string => typeof m === 'string').slice(0, 12) - : []; - const language = typeof r.language === 'string' && r.language - ? r.language.slice(0, 8) : null; - const signature = typeof r.signature === 'string' && r.signature - ? r.signature : null; - const active_hours = typeof r.active_hours === 'string' && r.active_hours - ? r.active_hours : '09:00-18:00'; - return { - ok: { - name, email, role, tone, tone_custom, mannerisms, language, signature, - active_hours, reply_latency, - uses_llms_heavily: r.uses_llms_heavily === true, - }, - }; -} - -interface MergeResult { - merged: EmailPersona[]; - added: number; - replaced: number; -} - -/** Dedupe by lowercased email; uploaded entries replace existing matches. */ -function mergePersonas(current: EmailPersona[], incoming: EmailPersona[]): MergeResult { - const byEmail = new Map(); - for (const p of current) byEmail.set(p.email.toLowerCase(), p); - let added = 0; - let replaced = 0; - for (const p of incoming) { - const key = p.email.toLowerCase(); - if (byEmail.has(key)) replaced += 1; - else added += 1; - byEmail.set(key, p); - } - return { merged: Array.from(byEmail.values()), added, replaced }; -} - -// ─── Persona card ───────────────────────────────────────────────────────── - -interface PersonaCardProps { - persona: EmailPersona; - onEdit: () => void; - onRemove: () => void; -} - -const PersonaCard: React.FC = ({ persona: p, onEdit, onRemove }) => ( -
-
-
- - {p.name} -
- {p.email} -
- -
-
- ROLE - {p.role} -
-
- TONE - - {p.tone === 'custom' && p.tone_custom - ? (p.tone_custom.length > 24 ? `${p.tone_custom.slice(0, 22)}…` : p.tone_custom) - : p.tone} - -
-
- LANG - {(p.language ?? 'en').toUpperCase()} -
-
- HOURS - {p.active_hours} -
-
- REPLY - {p.reply_latency} -
-
- -
-
- MANNERISMS -
-
- {p.mannerisms.length === 0 ? ( - - ) : ( - p.mannerisms.map((m, i) => ( - - {m.length > 24 ? `${m.slice(0, 22)}…` : m} - - )) - )} -
-
- -
- - {p.uses_llms_heavily ? ( - - LLM-HEAVY - - ) : ( - SUPPRESSED EM-DASH - )} - -
- - -
-
-
-); - -// ─── Editor modal ───────────────────────────────────────────────────────── - -interface PersonaEditorProps { - open: boolean; - editing: boolean; - draft: EmailPersona; - setDraft: (p: EmailPersona) => void; - draftError: string | null; - mannerismDraft: string; - setMannerismDraft: (s: string) => void; - onClose: () => void; - onSave: () => void; -} - -const PersonaEditor: React.FC = ({ - open, editing, draft, setDraft, draftError, - mannerismDraft, setMannerismDraft, onClose, onSave, -}) => { - const addMannerism = () => { - const t = mannerismDraft.trim(); - if (!t) return; - if (draft.mannerisms.includes(t)) { - setMannerismDraft(''); - return; - } - setDraft({ ...draft, mannerisms: [...draft.mannerisms, t] }); - setMannerismDraft(''); - }; - - const removeMannerism = (idx: number) => { - setDraft({ - ...draft, - mannerisms: draft.mannerisms.filter((_, i) => i !== idx), - }); - }; - - return ( - - - - - } - > -
-
-
- - setDraft({ ...draft, name: e.target.value })} - placeholder="John Smith" - /> -
-
- - setDraft({ ...draft, email: e.target.value })} - placeholder="john.smith@corp.com" - /> -
-
- -
- - setDraft({ ...draft, role: e.target.value })} - placeholder="Chief Operating Officer" - /> -
- -
-
- - - {draft.tone === 'custom' && ( - - setDraft({ ...draft, tone_custom: e.target.value || null }) - } - placeholder="e.g. terse, deadpan, sarcastic-but-polite" - /> - )} -
-
- - setDraft({ ...draft, language: e.target.value || null })} - placeholder="en" - /> -
-
- - -
-
- - setDraft({ ...draft, active_hours: e.target.value })} - placeholder="09:00-18:00 (wraps OK)" - /> -
-
- -
- -
- setMannerismDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addMannerism(); - } - }} - placeholder="opens with 'Hey' not 'Dear'" - /> - -
- {draft.mannerisms.length > 0 && ( -
- {draft.mannerisms.map((m, i) => ( - removeMannerism(i)} - title="click to remove" - > - {m} ✕ - - ))} -
- )} -
- -
- -