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:
@@ -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 />
|
||||
|
||||
375
decnet_web/src/components/PersonaGeneration.css
Normal file
375
decnet_web/src/components/PersonaGeneration.css
Normal 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); }
|
||||
560
decnet_web/src/components/PersonaGeneration.tsx
Normal file
560
decnet_web/src/components/PersonaGeneration.tsx
Normal 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 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 persona’s 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;
|
||||
Reference in New Issue
Block a user