// ============================================================================
// public/screens/agent-editor.jsx — Full Agent Editor (Phase 4)
// ----------------------------------------------------------------------------
// Opened when clicking an agent card (agents.jsx sends go("agent_edit:ID")).
// Sections:
// Left column — Identity, Prompt, Opening Script, Objection Map
// Right column — Voice Picker, LLM Config, Temperature, Version History
// Bottom — Test Call (Hidrogen Live preview)
//
// The editor auto-saves on blur (debounced PATCH). Publish creates a version.
// ============================================================================
function AgentEditorScreen({ agentId, go }) {
const [agent, setAgent] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const [versions, setVers] = React.useState([]);
const [voices, setVoices] = React.useState([]);
const [showVoice, setShowVoice] = React.useState(false);
const [showVersions, setShowVersions] = React.useState(false);
const [publishNote, setPublishNote] = React.useState("");
const [publishing, setPublishing] = React.useState(false);
const [tab, setTab] = React.useState("prompt"); // prompt | voice | llm | objections | test
// Load agent
const loadAgent = React.useCallback(async () => {
setLoading(true);
setError(null);
const r = await VoaisAPI.get("/api/agents/" + agentId);
if (r.ok && r.data?.ok) {
const a = r.data.agent;
// Parse objection_json if string
if (typeof a.objection_json === "string") {
try { a.objection_json = JSON.parse(a.objection_json); } catch (_) { a.objection_json = null; }
}
setAgent(a);
} else {
setError(r.data?.msg || "Failed to load agent.");
}
setLoading(false);
}, [agentId]);
// Load voices catalog
const loadVoices = React.useCallback(async () => {
const r = await VoaisAPI.get("/api/voices");
if (r.ok && r.data?.ok) setVoices(r.data.voices);
}, []);
// Load versions
const loadVersions = React.useCallback(async () => {
const r = await VoaisAPI.get("/api/agents/" + agentId + "/versions");
if (r.ok && r.data?.ok) setVers(r.data.versions);
}, [agentId]);
React.useEffect(() => {
loadAgent();
loadVoices();
loadVersions();
}, [loadAgent, loadVoices, loadVersions]);
// Update agent field locally
const updateField = (field, value) => {
setAgent(prev => ({ ...prev, [field]: value }));
setDirty(true);
};
// Save (PATCH) — debounced auto-save or manual
const saveRef = React.useRef(null);
const save = React.useCallback(async (fields) => {
if (!fields || !Object.keys(fields).length) return;
setSaving(true);
const r = await VoaisAPI.patch("/api/agents/" + agentId, fields);
setSaving(false);
if (r.ok) setDirty(false);
return r;
}, [agentId]);
// Auto-save on blur with debounce
const debouncedSave = React.useCallback((fields) => {
if (saveRef.current) clearTimeout(saveRef.current);
saveRef.current = setTimeout(() => save(fields), 800);
}, [save]);
// Publish
const handlePublish = async () => {
// Save any pending changes first
if (dirty) {
await save(buildPatchPayload());
}
setPublishing(true);
const r = await VoaisAPI.post("/api/agents/" + agentId + "/publish", { note: publishNote || null });
setPublishing(false);
if (r.ok) {
setPublishNote("");
loadAgent();
loadVersions();
} else {
alert(r.data?.msg || "Publish failed.");
}
};
// Rollback
const handleRollback = async (vid) => {
if (!confirm("Restore this version? Current unsaved changes will be lost.")) return;
const r = await VoaisAPI.post("/api/agents/" + agentId + "/versions/" + vid + "/rollback");
if (r.ok) {
loadAgent();
loadVersions();
} else {
alert(r.data?.msg || "Rollback failed.");
}
};
// Build patch payload from current agent state
const buildPatchPayload = () => {
if (!agent) return {};
return {
name: agent.name, role: agent.role, description: agent.description,
language: agent.language, system_prompt: agent.system_prompt,
opening_script: agent.opening_script, voice_tone: agent.voice_tone,
voice_id: agent.voice_id, llm_provider: agent.llm_provider,
llm_model: agent.llm_model, temperature: agent.temperature,
objection_json: agent.objection_json,
};
};
if (loading) return ;
if (error) return (
{error}
} onClick={() => go("agents")}>Back to agents
);
if (!agent) return null;
const selectedVoice = voices.find(v => v.id === agent.voice_id);
const statusTone = agent.status === "active" ? "green" : agent.status === "draft" ? "gray" : "yellow";
return (
{/* ── Top bar ──────────────────────────────────────────────────── */}
} onClick={() => go("agents")}>Agents
{agent.name}
{agent.code}
{agent.status}
{saving &&
Saving... }
{dirty && !saving &&
Unsaved changes }
setShowVersions(true)} icon={}>
v{agent.published_version || 0}
} onClick={handlePublish} disabled={publishing}>
{publishing ? "Publishing..." : "Publish"}
{/* ── Tab nav ──────────────────────────────────────────────────── */}
{/* ── Prompt tab ───────────────────────────────────────────────── */}
{tab === "prompt" && (
)}
{/* ── Voice tab ────────────────────────────────────────────────── */}
{tab === "voice" && (
Voice
} onClick={() => setShowVoice(true)}>
{selectedVoice ? "Change voice" : "Pick a voice"}
{selectedVoice ? (
{ updateField("voice_id", null); save({ voice_id: null }); }}/>
) : (
setShowVoice(true)}>
No voice selected — click to browse the voice catalog
)}
{/* Quick voice grid */}
Available voices
{voices.slice(0, 8).map(v => (
{ updateField("voice_id", v.id); save({ voice_id: v.id }); }}/>
))}
{voices.length > 8 && (
setShowVoice(true)}>View all {voices.length} voices
)}
)}
{/* ── LLM Config tab ───────────────────────────────────────────── */}
{tab === "llm" && (
)}
{/* ── Objections tab ───────────────────────────────────────────── */}
{tab === "objections" && (
{ updateField("objection_json", obj); debouncedSave({ objection_json: obj }); }}
/>
)}
{/* ── Test Call tab ────────────────────────────────────────────── */}
{tab === "test" && (
)}
{/* ── Voice Picker Modal ───────────────────────────────────────── */}
{showVoice && (
{
updateField("voice_id", id);
save({ voice_id: id });
setShowVoice(false);
}}
onClose={() => setShowVoice(false)}
/>
)}
{/* ── Version History Modal ────────────────────────────────────── */}
{showVersions && (
setShowVersions(false)} width={520}>
{/* Publish new */}
setPublishNote(e.target.value)}/>
{publishing ? "..." : "Publish"}
{versions.length === 0 && (
No versions yet. Click Publish to create the first snapshot.
)}
{versions.map(v => (
v{v.version}
{v.note || "No note"}
{new Date(v.published_at).toLocaleString("en-IN", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" })}
{v.version !== agent.published_version && (
handleRollback(v.id)}>Restore
)}
{v.version === agent.published_version && (
Current
)}
))}
)}
);
}
// ── Voice Card (compact or full) ────────────────────────────────────────
function VoiceCard({ voice, compact, selected, onSelect, onRemove }) {
const providerColors = {
sarvam: "#00D49F", elevenlabs: "#A78BFA", azure: "#5B8FFF",
openai: "#4DD0E1", gemini: "#FFD27D", hidrogen: "#FF8A3D", murf: "#FF6F91",
};
const color = providerColors[voice.provider] || "var(--ink-3)";
const tags = voice.tags || [];
if (compact) {
return (
{voice.provider} · {voice.gender} · {voice.language}
);
}
return (
{voice.display_name.charAt(0)}
{voice.display_name}
{voice.provider} · {voice.gender} · {voice.language}
{onRemove &&
}
{tags.length > 0 && (
{tags.map(t => {t} )}
)}
{voice.tier !== "standard" && (
{voice.tier}
)}
);
}
// ── Voice Picker Modal ──────────────────────────────────────────────────
function VoicePickerModal({ voices, selectedId, onSelect, onClose }) {
const [search, setSearch] = React.useState("");
const [filter, setFilter] = React.useState("all"); // all | sarvam | elevenlabs | ...
const [genderF, setGenderF] = React.useState("all");
const [previewId, setPreviewId] = React.useState(null);
const [previewLoading, setPreviewLoading] = React.useState(false);
const audioRef = React.useRef(null);
const providers = [...new Set(voices.map(v => v.provider))];
const filtered = voices.filter(v => {
if (filter !== "all" && v.provider !== filter) return false;
if (genderF !== "all" && v.gender !== genderF) return false;
if (search) {
const q = search.toLowerCase();
const nameMatch = v.display_name.toLowerCase().includes(q);
const tagMatch = (v.tags || []).some(t => t.toLowerCase().includes(q));
if (!nameMatch && !tagMatch) return false;
}
return true;
});
// Preview voice via Hidrogen
const handlePreview = async (voiceId) => {
if (previewLoading) return;
setPreviewId(voiceId);
setPreviewLoading(true);
try {
const r = await VoaisAPI.post("/api/voices/" + voiceId + "/preview", {});
if (r.ok && r.data?.ok && r.data.audio?.data) {
playPCM16(r.data.audio.data, r.data.audio.sample_rate || 24000);
} else {
console.warn("Preview failed:", r.data?.msg);
}
} catch (e) {
console.warn("Preview error:", e);
}
setPreviewLoading(false);
};
return (
{/* Filters */}
{/* Grid */}
{filtered.map(v => (
onSelect(v.id)}
onPreview={() => handlePreview(v.id)}
/>
))}
{filtered.length === 0 && (
No voices match your filters.
)}
);
}
function VoicePickerCard({ voice, selected, previewing, onSelect, onPreview }) {
const providerColors = {
sarvam: "#00D49F", elevenlabs: "#A78BFA", azure: "#5B8FFF",
openai: "#4DD0E1", gemini: "#FFD27D", hidrogen: "#FF8A3D",
};
const color = providerColors[voice.provider] || "var(--ink-3)";
const tags = voice.tags || [];
return (
{voice.display_name.charAt(0)}
{voice.display_name}
{voice.provider} · {voice.gender} · {voice.language}
{ e.stopPropagation(); onPreview(); }}
style={{ opacity: previewing ? 0.5 : 1 }}>
{previewing ? : }
{tags.length > 0 && (
{tags.map(t => {t} )}
)}
{selected ? "Selected" : "Select"}
);
}
// ── Objection Map Editor ────────────────────────────────────────────────
function ObjectionEditor({ objections, onChange }) {
const entries = Object.entries(objections || {});
const [newKey, setNewKey] = React.useState("");
const [newVal, setNewVal] = React.useState("");
const update = (oldKey, newK, newV) => {
const obj = { ...objections };
if (oldKey !== newK) delete obj[oldKey];
obj[newK] = newV;
onChange(obj);
};
const remove = (key) => {
const obj = { ...objections };
delete obj[key];
onChange(obj);
};
const add = () => {
if (!newKey.trim()) return;
onChange({ ...objections, [newKey.trim()]: newVal.trim() });
setNewKey("");
setNewVal("");
};
return (
Objection handling map
{entries.length} rules
When the caller raises an objection, the AI will use these scripted responses.
The AI matches caller intent to the closest objection key and delivers the response.
{entries.map(([key, val], i) => (
))}
{/* Add new */}
setNewKey(e.target.value)}/>
} onClick={add}
disabled={!newKey.trim()}>Add
);
}
// ── Test Call Panel (Hidrogen Live WebSocket) ───────────────────────────
function TestCallPanel({ agent }) {
const [status, setStatus] = React.useState("idle"); // idle | connecting | ready | active | ended
const [error, setError] = React.useState(null);
const [turns, setTurns] = React.useState([]);
const wsRef = React.useRef(null);
const audioCtx = React.useRef(null);
const streamRef = React.useRef(null);
const processorRef = React.useRef(null);
const playQueue = React.useRef([]);
const playingRef = React.useRef(false);
const startCall = async () => {
setError(null);
setTurns([]);
setStatus("connecting");
try {
// Get Hidrogen API key from tenant settings
// For now we fetch it via a simple check — in production this would be handled server-side
// We'll use a relay endpoint or pass key to browser securely
// For test call, we use browser WebSocket directly with the key from /api/config
const configR = await VoaisAPI.get("/api/voices/1/preview"); // just to test if key exists
// Actually, let's build a proper test-call endpoint later.
// For now, show a placeholder with instructions.
setError("Test call requires the Hidrogen API key to be configured. The full browser-to-Hidrogen WebSocket bridge will be wired in the next iteration.");
setStatus("idle");
} catch (e) {
setError(e.message);
setStatus("idle");
}
};
const endCall = () => {
if (wsRef.current) {
try { wsRef.current.close(1000); } catch (_) {}
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
}
if (audioCtx.current) {
audioCtx.current.close();
}
setStatus("ended");
};
return (
Talk to your agent in real-time using your browser microphone. The agent will use the
current system prompt, opening script, and voice settings.
{error &&
{error}
}
{status === "idle" || status === "ended" ? (
} onClick={startCall}>Start test call
) : status === "connecting" ? (
Connecting...
) : (
} onClick={endCall} style={{ color: "var(--err)" }}>End call
)}
{(status === "active" || status === "ready") && }
{/* Transcript */}
{turns.length > 0 && (
{turns.map((t, i) => (
{t.role === "assistant" ? agent.name : "You"}
{t.text}
))}
)}
{/* Agent config preview */}
Test call will use:
Prompt:
{(agent.system_prompt || "—").slice(0, 80)}{(agent.system_prompt || "").length > 80 ? "..." : ""}
Greeting:
{agent.opening_script || "—"}
LLM:
{agent.llm_provider} / {agent.llm_model}
Temperature:
{agent.temperature ?? 0.7}
);
}
// ── PCM16 audio playback helper ─────────────────────────────────────────
function playPCM16(base64Data, sampleRate) {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
const raw = atob(base64Data);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
const pcm16 = new Int16Array(bytes.buffer);
const floats = new Float32Array(pcm16.length);
for (let i = 0; i < pcm16.length; i++) floats[i] = pcm16[i] / 32768;
const buf = audioCtx.createBuffer(1, floats.length, sampleRate);
buf.getChannelData(0).set(floats);
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start();
src.onended = () => audioCtx.close();
} catch (e) {
console.error("playPCM16 error:", e);
}
}
// Expose globally
window.AgentEditorScreen = AgentEditorScreen;