feat(web): Persona Generation page under AUTOMATION

New dashboard surface for editing the global emailgen persona pool —
the JSON file fleet (MACVLAN/IPVLAN) and SWARM-shard mail deckies pull
from.  MazeNET topology personas are out of scope here; they're
configured per-topology in the topology editor.

Backend:
* GET/PUT /api/v1/emailgen/personas — admin-write, viewer-read.  PUT
  validates with the same Pydantic schema the worker uses
  (parse_personas), drops invalid entries with a warning, returns 400
  only when the entire payload fails.  Path is operator-discoverable
  on every response so a CLI-driven backup workflow stays visible.

Frontend:
* PersonaGeneration.tsx + .css — table + add/edit modal with the full
  EmailPersona schema (name, email, role, tone, mannerisms list,
  language, signature, active hours, reply latency, uses_llms_heavily).
  Local edits are batched; explicit "SAVE CHANGES" writes back, with a
  dirty-indicator pill and a "DISCARD" reset.  Email uniqueness is
  enforced client-side so the scheduler never picks the same persona
  as both sender + recipient.
* Sidebar AUTOMATION group gains a "Persona Generation" entry next to
  Orchestrator; route registered at /persona-generation.

The worker reads the same on-disk file the API writes — see
decnet.orchestrator.emailgen.global_pool.  The API resets the
in-process cache on every read/write so the worker picks up dashboard
edits within its next tick rather than waiting on mtime.
This commit is contained in:
2026-04-27 09:55:42 -04:00
parent 818aebadfc
commit f046634d6e
9 changed files with 1248 additions and 5 deletions

View File

@@ -24,6 +24,7 @@ const IdentityDetail = lazy(() => import('./components/IdentityDetail'));
const Campaigns = lazy(() => import('./components/Campaigns'));
const CampaignDetail = lazy(() => import('./components/CampaignDetail'));
const Orchestrator = lazy(() => import('./components/Orchestrator'));
const PersonaGeneration = lazy(() => import('./components/PersonaGeneration'));
const Config = lazy(() => import('./components/Config'));
const Bounty = lazy(() => import('./components/Bounty'));
const Credentials = lazy(() => import('./components/Credentials'));
@@ -123,6 +124,7 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
<Route path="/campaigns" element={<Campaigns />} />
<Route path="/campaigns/:id" element={<CampaignDetail />} />
<Route path="/orchestrator" element={<Orchestrator />} />
<Route path="/persona-generation" element={<PersonaGeneration />} />
<Route path="/config" element={<Config />} />
<Route path="/swarm-updates" element={<RemoteUpdates />} />
<Route path="/swarm/hosts" element={<SwarmHosts />} />

View File

@@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom';
import {
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu,
ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, Mail,
} from '../icons';
import { prefetchRoute } from '../routePrefetch';
import './Layout.css';
@@ -34,6 +34,7 @@ const ROUTE_LABELS: Record<string, string> = {
'/identities': 'IDENTITIES',
'/campaigns': 'CAMPAIGNS',
'/orchestrator': 'ORCHESTRATOR',
'/persona-generation': 'PERSONA GENERATION',
'/config': 'CONFIG',
'/swarm-updates': 'REMOTE UPDATES',
'/swarm/hosts': 'SWARM HOSTS',
@@ -108,8 +109,10 @@ const Layout: React.FC<LayoutProps> = ({
<nav className="sidebar-nav">
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
<NavItem to="/fleet" icon={<Server size={20} />} label="Decoy Fleet" open={sidebarOpen} />
<NavItem to="/mazenet" icon={<Network size={20} />} label="MazeNET" open={sidebarOpen} />
<NavGroup label="DEPLOY" icon={<Server size={20} />} open={sidebarOpen}>
<NavItem to="/fleet" icon={<Server size={18} />} label="Decoy Fleet" open={sidebarOpen} indent />
<NavItem to="/mazenet" icon={<Network size={18} />} label="MazeNET" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="ALERTS" icon={<Bell size={20} />} open={sidebarOpen}>
<NavItem
to="/live-logs"
@@ -127,15 +130,16 @@ const Layout: React.FC<LayoutProps> = ({
indent
/>
</NavGroup>
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
<NavItem to="/credentials" icon={<Lock size={20} />} label="Credentials" open={sidebarOpen} />
<NavGroup label="THREAT DATA" icon={<Activity size={20} />} open={sidebarOpen}>
<NavItem to="/attackers" icon={<Activity size={18} />} label="Attackers" open={sidebarOpen} indent />
<NavItem to="/identities" icon={<Fingerprint size={18} />} label="Identities" open={sidebarOpen} indent />
<NavItem to="/campaigns" icon={<Crosshair size={18} />} label="Campaigns" open={sidebarOpen} indent />
<NavItem to="/credentials" icon={<Lock size={18} />} label="Credentials" open={sidebarOpen} indent />
<NavItem to="/bounty" icon={<Archive size={18} />} label="Bounty" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="AUTOMATION" icon={<Zap size={20} />} open={sidebarOpen}>
<NavItem to="/orchestrator" icon={<Cpu size={18} />} label="Orchestrator" open={sidebarOpen} indent />
<NavItem to="/persona-generation" icon={<Mail size={18} />} label="Persona Generation" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />

View File

@@ -0,0 +1,375 @@
/* Persona Generation page — global pool CRUD UI for fleet/shard mail
deckies' fake-employee personas. Mirrors Webhooks.css visual language
(chips, table, modal) so dashboard density stays consistent. */
.persona-gen-root {
padding: 18px 24px;
color: var(--text);
}
.persona-gen-root .page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.persona-gen-root .page-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.persona-gen-root .header-line {
display: flex;
align-items: center;
gap: 10px;
}
.persona-gen-root .header-line h1 {
font-size: 1.1rem;
letter-spacing: 2px;
margin: 0;
text-transform: uppercase;
}
.persona-gen-root .page-sub {
font-size: 0.7rem;
letter-spacing: 1.4px;
color: var(--dim);
text-transform: uppercase;
}
.persona-gen-root .violet-accent { color: var(--violet); }
.persona-gen-root .dirty-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--amber, #f59e0b);
font-size: 0.62rem;
letter-spacing: 1.4px;
text-transform: uppercase;
border: 1px solid var(--amber, #f59e0b);
}
/* Info banner explaining scope (non-MazeNET) + showing pool path. */
.persona-gen-root .info-banner {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-left: 3px solid var(--violet);
padding: 10px 14px;
margin-bottom: 14px;
font-size: 0.78rem;
line-height: 1.5;
}
.persona-gen-root .info-banner em { color: var(--matrix); font-style: normal; }
.persona-gen-root .info-line { margin-top: 6px; font-size: 0.72rem; }
/* Action row */
.persona-gen-root .controls-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.persona-gen-root .btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.7rem;
letter-spacing: 1.5px;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s;
}
.persona-gen-root .btn:hover:not(:disabled) {
border-color: var(--matrix);
color: var(--matrix);
}
.persona-gen-root .btn:disabled { opacity: 0.4; cursor: not-allowed; }
.persona-gen-root .btn.primary { border-color: var(--matrix); color: var(--matrix); }
.persona-gen-root .btn.primary:hover:not(:disabled) { background: rgba(0, 255, 65, 0.07); }
.persona-gen-root .btn.ghost { background: transparent; }
.persona-gen-root .error-line {
color: var(--alert);
font-size: 0.72rem;
display: inline-flex;
gap: 6px;
align-items: center;
}
/* Table */
.persona-gen-root .persona-list {
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.18);
}
.persona-gen-root .persona-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.persona-gen-root .persona-table thead th {
text-align: left;
padding: 8px 10px;
letter-spacing: 1.4px;
font-size: 0.66rem;
color: var(--dim);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
}
.persona-gen-root .persona-table tbody td {
padding: 8px 10px;
border-bottom: 1px dashed var(--border);
vertical-align: middle;
}
.persona-gen-root .persona-table tbody tr:hover {
background: rgba(0, 255, 65, 0.04);
}
.persona-gen-root .row-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.persona-gen-root .icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 4px 6px;
cursor: pointer;
transition: all 0.12s;
}
.persona-gen-root .icon-btn:hover {
border-color: var(--matrix);
color: var(--matrix);
}
.persona-gen-root .icon-btn.danger:hover {
border-color: var(--alert);
color: var(--alert);
}
/* Tone chips — subtle palette differentiation */
.persona-gen-root .tone-chip {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--border);
font-size: 0.62rem;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.persona-gen-root .tone-chip.tone-formal { border-color: var(--violet); color: var(--violet); }
.persona-gen-root .tone-chip.tone-direct { border-color: var(--matrix); color: var(--matrix); }
.persona-gen-root .tone-chip.tone-casual { border-color: var(--amber, #f59e0b); color: var(--amber, #f59e0b); }
.persona-gen-root .tone-chip.tone-technical { border-color: var(--cyan, #22d3ee); color: var(--cyan, #22d3ee); }
.persona-gen-root .chip {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--border);
font-size: 0.62rem;
letter-spacing: 1.4px;
text-transform: uppercase;
}
.persona-gen-root .dim-chip { color: var(--dim); }
.persona-gen-root .warn-chip {
border-color: var(--amber, #f59e0b);
color: var(--amber, #f59e0b);
}
.persona-gen-root .mono { font-family: var(--font-mono); font-size: 0.74rem; }
.persona-gen-root .matrix-text { color: var(--matrix); }
.persona-gen-root .dim { color: var(--dim); }
/* ── Modal ──────────────────────────────────────────────────────── */
.persona-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
backdrop-filter: blur(2px);
}
.persona-modal {
background: var(--bg);
border: 1px solid var(--border);
width: min(640px, 92vw);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
}
.persona-modal .bd-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.persona-modal .bd-head h3 {
margin: 0;
font-size: 0.85rem;
letter-spacing: 1.6px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.persona-modal .close-btn {
background: transparent;
border: none;
color: var(--dim);
cursor: pointer;
}
.persona-modal .close-btn:hover { color: var(--alert); }
.persona-modal .bd-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.persona-modal .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.persona-modal .field-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.persona-modal .field-label {
font-size: 0.66rem;
letter-spacing: 1.4px;
color: var(--dim);
text-transform: uppercase;
}
.persona-modal .field input[type="text"],
.persona-modal .field input[type="email"],
.persona-modal .field select,
.persona-modal .field textarea {
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
font-size: 0.82rem;
font-family: inherit;
}
.persona-modal .field input:focus,
.persona-modal .field select:focus,
.persona-modal .field textarea:focus {
outline: none;
border-color: var(--matrix);
}
.persona-modal .check-field {
flex-direction: row;
align-items: flex-start;
gap: 10px;
}
.persona-modal .check-field input[type="checkbox"] {
margin-top: 4px;
}
.persona-modal .mannerism-input-row {
display: flex;
gap: 6px;
}
.persona-modal .mannerism-input-row input { flex: 1; }
.persona-modal .mannerism-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.persona-modal .mannerism-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
font-size: 0.78rem;
}
.persona-modal .mannerism-list li span {
flex: 1;
word-break: break-word;
}
.persona-modal .draft-error {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--alert);
font-size: 0.74rem;
}
.persona-modal .bd-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.persona-modal .btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.7rem;
letter-spacing: 1.5px;
text-transform: uppercase;
cursor: pointer;
}
.persona-modal .btn.primary { border-color: var(--matrix); color: var(--matrix); }
.persona-modal .btn.ghost { background: transparent; }
.persona-modal .btn:hover:not(:disabled) { border-color: var(--matrix); color: var(--matrix); }

View File

@@ -0,0 +1,560 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Mail, Plus, Pencil, Trash2, Save, X, Check, AlertTriangle,
} from '../icons';
import api from '../utils/api';
import { useToast } from './Toasts/useToast';
import EmptyState from './EmptyState/EmptyState';
import './PersonaGeneration.css';
type Tone = 'formal' | 'direct' | 'casual' | 'technical';
type ReplyLatency = 'fast' | 'normal' | 'slow';
interface EmailPersona {
name: string;
email: string;
role: string;
tone: Tone;
mannerisms: string[];
language: string | null;
signature: string | null;
active_hours: string;
reply_latency: ReplyLatency;
uses_llms_heavily: boolean;
}
interface PersonasResponse {
path: string;
personas: EmailPersona[];
}
const BLANK: EmailPersona = {
name: '',
email: '',
role: '',
tone: 'formal',
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'];
const LATENCIES: ReplyLatency[] = ['fast', 'normal', 'slow'];
function extractErrorDetail(err: unknown, fallback: string): string {
const e = err as {
response?: { status?: number; data?: { detail?: string } };
message?: string;
};
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.mannerisms.length > 12) return 'at most 12 mannerisms per persona';
return null;
}
const PersonaGeneration: React.FC = () => {
const { push } = useToast();
const [path, setPath] = useState<string>('');
const [personas, setPersonas] = useState<EmailPersona[]>([]);
const [serverPersonas, setServerPersonas] = useState<EmailPersona[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [draft, setDraft] = useState<EmailPersona>(BLANK);
const [draftError, setDraftError] = useState<string | null>(null);
const [mannerismDraft, setMannerismDraft] = useState('');
const dirty = useMemo(
() => JSON.stringify(personas) !== JSON.stringify(serverPersonas),
[personas, serverPersonas],
);
const fetchPersonas = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<PersonasResponse>('/emailgen/personas');
const list = res.data.personas ?? [];
setPersonas(list);
setServerPersonas(list);
setPath(res.data.path ?? '');
} catch (err) {
setError(extractErrorDetail(err, 'Failed to load personas'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPersonas();
}, []);
const openAdd = () => {
setEditingIdx(null);
setDraft({ ...BLANK });
setMannerismDraft('');
setDraftError(null);
setModalOpen(true);
};
const openEdit = (idx: number) => {
setEditingIdx(idx);
setDraft({ ...personas[idx] });
setMannerismDraft('');
setDraftError(null);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setDraft(BLANK);
setEditingIdx(null);
setMannerismDraft('');
setDraftError(null);
};
const saveDraft = () => {
const err = validate(draft);
if (err) {
setDraftError(err);
return;
}
// Email uniqueness — same address across two personas would let
// the scheduler pick "John" as both sender and recipient.
const dupeIdx = personas.findIndex(
(p, i) => p.email === draft.email && i !== editingIdx,
);
if (dupeIdx !== -1) {
setDraftError(`email already used by "${personas[dupeIdx].name}"`);
return;
}
if (editingIdx === null) {
setPersonas([...personas, draft]);
} else {
const next = personas.slice();
next[editingIdx] = draft;
setPersonas(next);
}
closeModal();
};
const removePersona = (idx: number) => {
if (!confirm(`Remove ${personas[idx].name}?`)) return;
setPersonas(personas.filter((_, i) => i !== idx));
};
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),
});
};
const saveAll = async () => {
setSaving(true);
setError(null);
try {
const res = await api.put<PersonasResponse>('/emailgen/personas', {
personas,
});
const list = res.data.personas ?? [];
setPersonas(list);
setServerPersonas(list);
setPath(res.data.path ?? path);
push({
text: `SAVED ${list.length} PERSONA${list.length === 1 ? '' : 'S'}`,
tone: 'matrix',
icon: 'check',
});
} catch (err) {
const msg = extractErrorDetail(err, 'Failed to save personas');
setError(msg);
push({ text: msg.toUpperCase(), tone: 'alert', icon: 'alert-triangle' });
} finally {
setSaving(false);
}
};
const discardChanges = () => {
if (!dirty) return;
if (!confirm('Discard unsaved changes?')) return;
setPersonas(serverPersonas);
};
return (
<div className="persona-gen-root">
<div className="page-header">
<div className="page-title-group">
<div className="header-line">
<Mail size={22} className="violet-accent" />
<h1>PERSONA GENERATION</h1>
{dirty && (
<span className="dirty-pill">
<AlertTriangle size={12} />
UNSAVED CHANGES
</span>
)}
</div>
<span className="page-sub">
GLOBAL POOL · FLEET (MACVLAN/IPVLAN) + SWARM-SHARD MAIL DECKIES
</span>
</div>
</div>
<div className="info-banner">
<div>
<strong>Scope:</strong> personas listed here drive emailgen against{' '}
<em>non-MazeNET</em> mail deckies (unihost MACVLAN/IPVLAN, SWARM
shards). MazeNET topologies have their own per-topology persona
list configured in the topology editor.
</div>
{path && (
<div className="info-line">
<span className="dim">FILE</span>{' '}
<span className="mono matrix-text">{path}</span>
</div>
)}
</div>
<div className="controls-row">
<button className="btn primary" onClick={openAdd}>
<Plus size={12} />
ADD PERSONA
</button>
<button
className="btn"
onClick={saveAll}
disabled={!dirty || saving}
>
<Save size={12} />
{saving ? 'SAVING…' : 'SAVE CHANGES'}
</button>
<button
className="btn ghost"
onClick={discardChanges}
disabled={!dirty || saving}
>
DISCARD
</button>
{error && (
<span className="error-line">
<AlertTriangle size={12} /> {error}
</span>
)}
</div>
<div className="persona-list">
{loading ? (
<EmptyState icon={Mail} title="LOADING…" />
) : personas.length === 0 ? (
<EmptyState
icon={Mail}
title="NO PERSONAS CONFIGURED"
hint="add at least 2 to start the emailgen worker against fleet/shard mail deckies"
/>
) : (
<table className="persona-table">
<thead>
<tr>
<th>NAME</th>
<th>EMAIL</th>
<th>ROLE</th>
<th>TONE</th>
<th>LANG</th>
<th>HOURS</th>
<th>REPLY</th>
<th>MANNERISMS</th>
<th>FLAGS</th>
<th />
</tr>
</thead>
<tbody>
{personas.map((p, idx) => (
<tr key={`${p.email}-${idx}`}>
<td>{p.name}</td>
<td className="mono">{p.email}</td>
<td>{p.role}</td>
<td>
<span className={`tone-chip tone-${p.tone}`}>{p.tone}</span>
</td>
<td>
<span className="chip dim-chip">
{(p.language ?? 'en').toUpperCase()}
</span>
</td>
<td className="mono">{p.active_hours}</td>
<td>{p.reply_latency}</td>
<td className="dim">
{p.mannerisms.length === 0
? '—'
: `${p.mannerisms.length} item${p.mannerisms.length === 1 ? '' : 's'}`}
</td>
<td>
{p.uses_llms_heavily && (
<span
className="chip warn-chip"
title="Em-dash suppression lifted for this persona"
>
LLM-HEAVY
</span>
)}
</td>
<td className="row-actions">
<button
className="icon-btn"
onClick={() => openEdit(idx)}
aria-label={`Edit ${p.name}`}
>
<Pencil size={14} />
</button>
<button
className="icon-btn danger"
onClick={() => removePersona(idx)}
aria-label={`Remove ${p.name}`}
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modalOpen && (
<div
className="persona-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
<div className="persona-modal">
<div className="bd-head">
<h3>
<Mail size={14} />
{editingIdx === null ? 'ADD PERSONA' : 'EDIT PERSONA'}
</h3>
<button
className="close-btn"
onClick={closeModal}
aria-label="Close"
>
<X size={16} />
</button>
</div>
<div className="bd-body">
<label className="field">
<span className="field-label">NAME *</span>
<input
type="text"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="John Smith"
/>
</label>
<label className="field">
<span className="field-label">EMAIL *</span>
<input
type="email"
value={draft.email}
onChange={(e) => setDraft({ ...draft, email: e.target.value })}
placeholder="john.smith@corp.com"
/>
</label>
<label className="field">
<span className="field-label">ROLE *</span>
<input
type="text"
value={draft.role}
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
placeholder="Chief Operating Officer"
/>
</label>
<div className="field-row">
<label className="field">
<span className="field-label">TONE</span>
<select
value={draft.tone}
onChange={(e) => setDraft({ ...draft, tone: e.target.value as Tone })}
>
{TONES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">LANGUAGE</span>
<input
type="text"
maxLength={8}
value={draft.language ?? ''}
onChange={(e) =>
setDraft({ ...draft, language: e.target.value || null })
}
placeholder="en"
/>
</label>
<label className="field">
<span className="field-label">REPLY LATENCY</span>
<select
value={draft.reply_latency}
onChange={(e) =>
setDraft({
...draft,
reply_latency: e.target.value as ReplyLatency,
})
}
>
{LATENCIES.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</label>
</div>
<label className="field">
<span className="field-label">ACTIVE HOURS</span>
<input
type="text"
value={draft.active_hours}
onChange={(e) =>
setDraft({ ...draft, active_hours: e.target.value })
}
placeholder="09:00-18:00 (wraps OK e.g. 22:00-06:00)"
/>
</label>
<label className="field">
<span className="field-label">MANNERISMS (12)</span>
<div className="mannerism-input-row">
<input
type="text"
value={mannerismDraft}
onChange={(e) => setMannerismDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addMannerism();
}
}}
placeholder="opens with 'Hey' not 'Dear'"
/>
<button
type="button"
className="btn ghost"
onClick={addMannerism}
disabled={!mannerismDraft.trim() || draft.mannerisms.length >= 12}
>
<Plus size={12} /> ADD
</button>
</div>
<ul className="mannerism-list">
{draft.mannerisms.map((m, i) => (
<li key={i}>
<span>{m}</span>
<button
type="button"
className="icon-btn danger"
onClick={() => removeMannerism(i)}
aria-label={`Remove mannerism ${i + 1}`}
>
<X size={12} />
</button>
</li>
))}
</ul>
</label>
<label className="field">
<span className="field-label">SIGNATURE (optional)</span>
<textarea
rows={3}
value={draft.signature ?? ''}
onChange={(e) =>
setDraft({
...draft,
signature: e.target.value || null,
})
}
placeholder="-- John&#10;COO, ACME Corp"
/>
</label>
<label className="field check-field">
<input
type="checkbox"
checked={draft.uses_llms_heavily}
onChange={(e) =>
setDraft({ ...draft, uses_llms_heavily: e.target.checked })
}
/>
<span>
<strong>Uses LLMs heavily</strong>
<span className="dim">
{' — em-dash suppression lifted; this personas output may '}
contain natural em-dashes.
</span>
</span>
</label>
{draftError && (
<div className="draft-error">
<AlertTriangle size={12} /> {draftError}
</div>
)}
</div>
<div className="bd-actions">
<button className="btn ghost" onClick={closeModal}>
CANCEL
</button>
<button className="btn primary" onClick={saveDraft}>
<Check size={12} />
{editingIdx === null ? 'ADD' : 'UPDATE'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PersonaGeneration;