876 lines
29 KiB
TypeScript
876 lines
29 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import {
|
|
Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download,
|
|
} from '../icons';
|
|
import api from '../utils/api';
|
|
import { useToast } from './Toasts/useToast';
|
|
import Modal from './Modal/Modal';
|
|
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 {
|
|
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.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<string, unknown>;
|
|
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<string, EmailPersona>();
|
|
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<PersonaCardProps> = ({ persona: p, onEdit, onRemove }) => (
|
|
<div className="decky-card persona-card">
|
|
<div className="decky-head">
|
|
<div className="decky-name">
|
|
<span className={`status-dot ${p.uses_llms_heavily ? 'mutating' : 'active'}`} />
|
|
{p.name}
|
|
</div>
|
|
<span className="decky-ip">{p.email}</span>
|
|
</div>
|
|
|
|
<div className="decky-meta">
|
|
<div className="row">
|
|
<span className="label">ROLE</span>
|
|
<span>{p.role}</span>
|
|
</div>
|
|
<div className="row">
|
|
<span className="label">TONE</span>
|
|
<span
|
|
className={`tone-chip tone-${p.tone}`}
|
|
title={p.tone === 'custom' ? (p.tone_custom ?? '') : undefined}
|
|
>
|
|
{p.tone === 'custom' && p.tone_custom
|
|
? (p.tone_custom.length > 24 ? `${p.tone_custom.slice(0, 22)}…` : p.tone_custom)
|
|
: p.tone}
|
|
</span>
|
|
</div>
|
|
<div className="row">
|
|
<span className="label">LANG</span>
|
|
<span className="dim">{(p.language ?? 'en').toUpperCase()}</span>
|
|
</div>
|
|
<div className="row">
|
|
<span className="label">HOURS</span>
|
|
<span className="mono">{p.active_hours}</span>
|
|
</div>
|
|
<div className="row">
|
|
<span className="label">REPLY</span>
|
|
<span className="violet-accent">{p.reply_latency}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="type-label" style={{ marginBottom: 6, opacity: 0.5, fontSize: '0.62rem', letterSpacing: 1 }}>
|
|
MANNERISMS
|
|
</div>
|
|
<div className="decky-services">
|
|
{p.mannerisms.length === 0 ? (
|
|
<span className="dim" style={{ fontSize: '0.7rem' }}>—</span>
|
|
) : (
|
|
p.mannerisms.map((m, i) => (
|
|
<span key={i} className="service-tag" title={m}>
|
|
{m.length > 24 ? `${m.slice(0, 22)}…` : m}
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="decky-footer">
|
|
<span className="decky-hits">
|
|
{p.uses_llms_heavily ? (
|
|
<span className="alert-text" style={{ fontWeight: 700 }} title="Em-dash suppression lifted">
|
|
LLM-HEAVY
|
|
</span>
|
|
) : (
|
|
<span className="dim">SUPPRESSED EM-DASH</span>
|
|
)}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button className="btn small" onClick={onEdit} title={`Edit ${p.name}`}>
|
|
<Pencil size={10} /> EDIT
|
|
</button>
|
|
<button className="btn alert small" onClick={onRemove} title={`Remove ${p.name}`}>
|
|
<Trash2 size={10} /> REMOVE
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── 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<PersonaEditorProps> = ({
|
|
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 (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title={editing ? 'EDIT PERSONA' : 'ADD PERSONA'}
|
|
icon={Mail}
|
|
accent="violet"
|
|
width="wide"
|
|
footer={
|
|
<>
|
|
<button className="btn ghost" onClick={onClose}>CANCEL</button>
|
|
<button className="btn violet" onClick={onSave}>
|
|
<Check size={12} /> {editing ? 'UPDATE' : 'ADD'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="modal-body">
|
|
<div className="grid-2">
|
|
<div className="tweak-group">
|
|
<label>NAME *</label>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={draft.name}
|
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
|
placeholder="John Smith"
|
|
/>
|
|
</div>
|
|
<div className="tweak-group">
|
|
<label>EMAIL *</label>
|
|
<input
|
|
className="input"
|
|
type="email"
|
|
value={draft.email}
|
|
onChange={(e) => setDraft({ ...draft, email: e.target.value })}
|
|
placeholder="john.smith@corp.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="tweak-group">
|
|
<label>ROLE *</label>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={draft.role}
|
|
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
|
|
placeholder="Chief Operating Officer"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid-2">
|
|
<div className="tweak-group">
|
|
<label>TONE</label>
|
|
<select
|
|
className="input"
|
|
value={draft.tone}
|
|
onChange={(e) => setDraft({ ...draft, tone: e.target.value as Tone })}
|
|
>
|
|
{TONES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
{draft.tone === 'custom' && (
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
maxLength={128}
|
|
style={{ marginTop: 6 }}
|
|
value={draft.tone_custom ?? ''}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, tone_custom: e.target.value || null })
|
|
}
|
|
placeholder="e.g. terse, deadpan, sarcastic-but-polite"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="tweak-group">
|
|
<label>LANGUAGE</label>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
maxLength={8}
|
|
value={draft.language ?? ''}
|
|
onChange={(e) => setDraft({ ...draft, language: e.target.value || null })}
|
|
placeholder="en"
|
|
/>
|
|
</div>
|
|
<div className="tweak-group">
|
|
<label>REPLY LATENCY</label>
|
|
<select
|
|
className="input"
|
|
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>
|
|
</div>
|
|
<div className="tweak-group">
|
|
<label>ACTIVE HOURS</label>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={draft.active_hours}
|
|
onChange={(e) => setDraft({ ...draft, active_hours: e.target.value })}
|
|
placeholder="09:00-18:00 (wraps OK)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="tweak-group">
|
|
<label>MANNERISMS ({draft.mannerisms.length}/12)</label>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
style={{ flex: 1 }}
|
|
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>
|
|
{draft.mannerisms.length > 0 && (
|
|
<div className="decky-services" style={{ marginTop: 8 }}>
|
|
{draft.mannerisms.map((m, i) => (
|
|
<span
|
|
key={i}
|
|
className="service-tag"
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => removeMannerism(i)}
|
|
title="click to remove"
|
|
>
|
|
{m} ✕
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="tweak-group">
|
|
<label>SIGNATURE (optional)</label>
|
|
<textarea
|
|
className="input"
|
|
rows={3}
|
|
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)' }}
|
|
value={draft.signature ?? ''}
|
|
onChange={(e) => setDraft({ ...draft, signature: e.target.value || null })}
|
|
placeholder="-- John COO, ACME Corp"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: 'flex', gap: 10, alignItems: 'center',
|
|
padding: 14, border: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
<input
|
|
id="llm-heavy"
|
|
type="checkbox"
|
|
checked={draft.uses_llms_heavily}
|
|
onChange={(e) => setDraft({ ...draft, uses_llms_heavily: e.target.checked })}
|
|
style={{ accentColor: 'var(--violet)' }}
|
|
/>
|
|
<label htmlFor="llm-heavy" style={{ fontSize: '0.78rem', letterSpacing: 1 }}>
|
|
<strong>USES LLMS HEAVILY</strong>
|
|
<span className="dim" style={{ marginLeft: 8, letterSpacing: 0 }}>
|
|
em-dash suppression lifted; output may contain natural em-dashes
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{draftError && (
|
|
<div
|
|
style={{
|
|
border: '1px solid var(--alert)',
|
|
color: 'var(--alert)',
|
|
padding: '8px 12px',
|
|
fontSize: '0.75rem',
|
|
letterSpacing: 1,
|
|
display: 'inline-flex',
|
|
gap: 8,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<AlertTriangle size={12} /> {draftError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────
|
|
|
|
interface PersonaGenerationProps {
|
|
/** When set, the editor manages the personas attached to the given
|
|
* topology row (Topology.email_personas) instead of the global
|
|
* fleet/SWARM pool. The component negotiates this with two
|
|
* backend endpoints sharing the same wire shape. */
|
|
topologyId?: string;
|
|
}
|
|
|
|
const PersonaGeneration: React.FC<PersonaGenerationProps> = ({ topologyId }) => {
|
|
const { push } = useToast();
|
|
const isTopology = Boolean(topologyId);
|
|
const endpoint = isTopology
|
|
? `/topologies/${topologyId}/personas`
|
|
: '/realism/personas';
|
|
|
|
const [path, setPath] = useState<string>('');
|
|
const [topoName, setTopoName] = useState<string>('');
|
|
const [languageDefault, setLanguageDefault] = useState<string>('en');
|
|
const [personas, setPersonas] = useState<EmailPersona[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [filter, setFilter] = useState<FilterKey>('all');
|
|
|
|
const fileRef = useRef<HTMLInputElement>(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 counts = useMemo(() => {
|
|
const c: Record<FilterKey, number> = {
|
|
all: personas.length,
|
|
formal: 0, direct: 0, casual: 0, technical: 0, custom: 0,
|
|
};
|
|
for (const p of personas) c[p.tone] += 1;
|
|
return c;
|
|
}, [personas]);
|
|
|
|
const visible = useMemo(
|
|
() => filter === 'all' ? personas : personas.filter((p) => p.tone === filter),
|
|
[personas, filter],
|
|
);
|
|
|
|
const fetchPersonas = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await api.get<PersonasResponse>(endpoint);
|
|
const list = res.data.personas ?? [];
|
|
setPersonas(list);
|
|
setPath(res.data.path ?? '');
|
|
setTopoName(res.data.topology_name ?? '');
|
|
setLanguageDefault(res.data.language_default ?? 'en');
|
|
} catch (err) {
|
|
setError(extractErrorDetail(err, 'Failed to load personas'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchPersonas(); /* eslint-disable-next-line */ }, [endpoint]);
|
|
|
|
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);
|
|
};
|
|
|
|
/** PUT *next* and adopt the server's parsed result on success.
|
|
* Returns true if the write committed. Persona changes are saved
|
|
* eagerly (no SAVE/DISCARD staging) so each CRUD action is one round-
|
|
* trip; on failure we leave the local list untouched so the UI never
|
|
* shows phantom rows. */
|
|
const persistPersonas = async (
|
|
next: EmailPersona[],
|
|
successText: string,
|
|
): Promise<boolean> => {
|
|
setError(null);
|
|
try {
|
|
const res = await api.put<PersonasResponse>(endpoint, { personas: next });
|
|
const list = res.data.personas ?? [];
|
|
setPersonas(list);
|
|
setPath(res.data.path ?? path);
|
|
setTopoName(res.data.topology_name ?? topoName);
|
|
setLanguageDefault(res.data.language_default ?? languageDefault);
|
|
push({ text: successText, tone: 'matrix', icon: 'check' });
|
|
return true;
|
|
} catch (err) {
|
|
const msg = extractErrorDetail(err, 'Failed to save personas');
|
|
setError(msg);
|
|
push({ text: msg.toUpperCase(), tone: 'alert', icon: 'alert-triangle' });
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const saveDraft = async () => {
|
|
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;
|
|
}
|
|
let next: EmailPersona[];
|
|
if (editingIdx === null) {
|
|
next = [...personas, draft];
|
|
} else {
|
|
next = personas.slice();
|
|
next[editingIdx] = draft;
|
|
}
|
|
const ok = await persistPersonas(
|
|
next,
|
|
editingIdx === null
|
|
? `ADDED ${draft.name.toUpperCase()}`
|
|
: `UPDATED ${draft.name.toUpperCase()}`,
|
|
);
|
|
if (ok) closeModal();
|
|
};
|
|
|
|
const removePersona = async (idx: number) => {
|
|
const target = personas[idx];
|
|
if (!confirm(`Remove ${target.name}?`)) return;
|
|
await persistPersonas(
|
|
personas.filter((_, i) => i !== idx),
|
|
`REMOVED ${target.name.toUpperCase()}`,
|
|
);
|
|
};
|
|
|
|
const downloadTemplate = () => {
|
|
const blob = new Blob(
|
|
[JSON.stringify(TEMPLATE, null, 2)],
|
|
{ type: 'application/json' },
|
|
);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'email_personas_template.json';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleBulkFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const f = e.target.files?.[0];
|
|
// Reset the input so picking the same file twice still fires onChange.
|
|
e.target.value = '';
|
|
if (!f) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
setError(null);
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(String(reader.result));
|
|
} catch (err) {
|
|
setError(`Could not parse JSON: ${(err as Error).message}`);
|
|
return;
|
|
}
|
|
// Accept either a top-level array or { personas: [...] }.
|
|
let rawList: unknown[] | null = null;
|
|
if (Array.isArray(parsed)) {
|
|
rawList = parsed;
|
|
} else if (parsed && typeof parsed === 'object'
|
|
&& Array.isArray((parsed as { personas?: unknown }).personas)) {
|
|
rawList = (parsed as { personas: unknown[] }).personas;
|
|
}
|
|
if (!rawList) {
|
|
setError('Expected a JSON array or an object with a "personas" array');
|
|
return;
|
|
}
|
|
const accepted: EmailPersona[] = [];
|
|
const reasons: string[] = [];
|
|
for (let i = 0; i < rawList.length; i += 1) {
|
|
const r = coercePersona(rawList[i]);
|
|
if ('ok' in r) accepted.push(r.ok);
|
|
else reasons.push(`#${i + 1}: ${r.error}`);
|
|
}
|
|
if (accepted.length === 0) {
|
|
setError(
|
|
`No valid personas in ${f.name}.` +
|
|
(reasons.length ? ` First issue: ${reasons[0]}` : ''),
|
|
);
|
|
return;
|
|
}
|
|
const { merged, added, replaced } = mergePersonas(personas, accepted);
|
|
const skipped = reasons.length;
|
|
const parts = [`+${added} added`];
|
|
if (replaced) parts.push(`${replaced} replaced`);
|
|
if (skipped) parts.push(`${skipped} skipped`);
|
|
const summary = `IMPORTED ${accepted.length} PERSONA${accepted.length === 1 ? '' : 'S'} (${parts.join(', ')})`;
|
|
void persistPersonas(merged, summary).then((ok) => {
|
|
if (ok && skipped) {
|
|
// Persisted, but show *why* some were dropped so the operator
|
|
// can fix the source file.
|
|
setError(`Skipped ${skipped} invalid entr${skipped === 1 ? 'y' : 'ies'}: ${reasons.slice(0, 3).join('; ')}${reasons.length > 3 ? '…' : ''}`);
|
|
}
|
|
});
|
|
};
|
|
reader.readAsText(f);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="fleet-root">
|
|
<div className="dim" style={{ padding: '40px', textAlign: 'center', letterSpacing: 2 }}>
|
|
LOADING PERSONAS...
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const llmHeavyCount = personas.filter((p) => p.uses_llms_heavily).length;
|
|
|
|
return (
|
|
<div className="fleet-root persona-gen-root">
|
|
<div className="page-header">
|
|
<div className="page-title-group">
|
|
<h1>{isTopology ? 'TOPOLOGY PERSONAS' : 'PERSONA GENERATION'}</h1>
|
|
<span className="page-sub">
|
|
{personas.length} PERSONA{personas.length === 1 ? '' : 'S'} · {llmHeavyCount} LLM-HEAVY
|
|
{isTopology
|
|
? ` · TOPOLOGY ${topoName ? topoName.toUpperCase() : (topologyId ?? '').slice(0, 8)} · DEFAULT LANG ${languageDefault.toUpperCase()}`
|
|
: ' · GLOBAL POOL · FLEET (MACVLAN/IPVLAN) + SWARM-SHARD MAIL DECKIES'}
|
|
</span>
|
|
</div>
|
|
<div className="actions">
|
|
<div className="fleet-filter-group">
|
|
{([['all', 'ALL'], ['formal', 'FORMAL'], ['direct', 'DIRECT'],
|
|
['casual', 'CASUAL'], ['technical', 'TECHNICAL'],
|
|
['custom', 'CUSTOM']] as [FilterKey, string][]).map(
|
|
([v, l]) => (
|
|
<button
|
|
key={v}
|
|
onClick={() => setFilter(v)}
|
|
className={`fleet-filter-btn ${filter === v ? 'active' : ''}`}
|
|
>
|
|
{l} {counts[v]}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
<button className="btn violet" onClick={openAdd}>
|
|
<Plus size={12} /> ADD PERSONA
|
|
</button>
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
onChange={handleBulkFile}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
<button
|
|
className="btn"
|
|
onClick={() => fileRef.current?.click()}
|
|
title="Import personas from a JSON file"
|
|
>
|
|
<Upload size={12} /> BULK UPLOAD
|
|
</button>
|
|
<button
|
|
className="btn ghost"
|
|
onClick={downloadTemplate}
|
|
title="Download a JSON template you can fill out and re-upload"
|
|
>
|
|
<Download size={12} /> TEMPLATE
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="info-banner">
|
|
{isTopology ? (
|
|
<div>
|
|
<strong>Scope:</strong> personas listed here drive emailgen for the
|
|
mail deckies attached to <em>this MazeNET topology only</em>.
|
|
Unset <code>language</code> entries fall back to the topology's
|
|
default ({languageDefault.toUpperCase()}).
|
|
</div>
|
|
) : (
|
|
<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 && !isTopology && (
|
|
<div className="info-line">
|
|
<span className="dim">FILE</span>{' '}
|
|
<span className="mono matrix-text">{path}</span>
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="info-line alert-text" style={{ marginTop: 8 }}>
|
|
<AlertTriangle size={12} /> {error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid-fleet">
|
|
{visible.length === 0 ? (
|
|
<div className="fleet-empty">
|
|
<Mail size={32} className="dim" />
|
|
<span className="dim">
|
|
{personas.length === 0
|
|
? (isTopology
|
|
? 'NO PERSONAS ON THIS TOPOLOGY — ADD AT LEAST 2 SO THE EMAILGEN SCHEDULER CAN PICK SENDER+RECIPIENT'
|
|
: 'NO PERSONAS CONFIGURED — ADD AT LEAST 2 TO START THE EMAILGEN WORKER')
|
|
: 'NO PERSONAS MATCH CURRENT FILTER'}
|
|
</span>
|
|
{personas.length === 0 && (
|
|
<button className="btn violet" onClick={openAdd}>
|
|
<Plus size={12} /> ADD PERSONA
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
visible.map((p, idx) => {
|
|
const realIdx = personas.indexOf(p);
|
|
return (
|
|
<PersonaCard
|
|
key={`${p.email}-${idx}`}
|
|
persona={p}
|
|
onEdit={() => openEdit(realIdx)}
|
|
onRemove={() => removePersona(realIdx)}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<PersonaEditor
|
|
open={modalOpen}
|
|
editing={editingIdx !== null}
|
|
draft={draft}
|
|
setDraft={setDraft}
|
|
draftError={draftError}
|
|
mannerismDraft={mannerismDraft}
|
|
setMannerismDraft={setMannerismDraft}
|
|
onClose={closeModal}
|
|
onSave={saveDraft}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PersonaGeneration;
|
|
|
|
// Topology-bound variant. Mounted at /topologies/:id/personas; the
|
|
// route component reads the id off the URL so callers can `<Link>`
|
|
// straight in from the topology list / MazeNET toolbar.
|
|
export const TopologyPersonaGeneration: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
if (!id) return null;
|
|
return <PersonaGeneration topologyId={id} />;
|
|
};
|