import { useCallback, useEffect, useMemo, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPenToSquare, faRotateLeft, faRobot } from '@fortawesome/pro-duotone-svg-icons'; import { WizardStep } from './wizard-step'; import { AiRightPane, type AiActorSummary } from './wizard-right-panes'; import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form'; import { Button } from '@/components/base/buttons/button'; import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu'; import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal'; import { apiClient } from '@/lib/api-client'; import { notify } from '@/lib/toast'; import { useAuth } from '@/providers/auth-provider'; import type { WizardStepComponentProps } from './wizard-step-types'; // AI step (post-prompt-config rework). The middle pane has two sections: // // 1. Provider / model / temperature picker — same as before, drives the // provider that all actors use under the hood. // 2. AI personas — list of 7 actor cards, each with name, description, // truncated current template, and an Edit button. Edit triggers a // confirmation modal warning about unintended consequences, then // opens a slideout with the full template + a "variables you can // use" reference + Save / Reset. // // The right pane shows the same 7 personas as compact "last edited" cards // so the admin can scan recent activity at a glance. // // Backend wiring lives in helix-engage-server/src/config/ai.defaults.ts // (DEFAULT_AI_PROMPTS) + ai-config.service.ts (renderPrompt / updatePrompt // / resetPrompt). The 7 service files (widget chat, CC agent helper, // supervisor, lead enrichment, call insight, call assist, recording // analysis) all call AiConfigService.renderPrompt(actor, vars) so any // edit here lands instantly. type ServerPromptConfig = { label: string; description: string; variables: { key: string; description: string }[]; template: string; defaultTemplate: string; lastEditedAt: string | null; lastEditedBy: string | null; }; type ServerAiConfig = { provider?: AiProvider; model?: string; temperature?: number; prompts?: Record; }; // Display order for the actor cards. Mirrors AI_ACTOR_KEYS in // ai.defaults.ts so the wizard renders personas in the same order // admins see them documented elsewhere. const ACTOR_ORDER = [ 'widgetChat', 'ccAgentHelper', 'supervisorChat', 'leadEnrichment', 'callInsight', 'callAssist', 'recordingAnalysis', ] as const; const truncate = (s: string, max: number): string => s.length > max ? s.slice(0, max).trimEnd() + '…' : s; export const WizardStepAi = (props: WizardStepComponentProps) => { const { user } = useAuth(); const [values, setValues] = useState(emptyAiFormValues); const [prompts, setPrompts] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); // Edit flow state — three phases: // 1. confirmingActor: which actor's Edit button was just clicked // (drives the confirmation modal) // 2. editingActor: which actor's slideout is open (only set after // the user confirms past the warning prompt) // 3. draftTemplate: the current textarea contents in the slideout const [confirmingActor, setConfirmingActor] = useState(null); const [editingActor, setEditingActor] = useState(null); const [draftTemplate, setDraftTemplate] = useState(''); const [savingPrompt, setSavingPrompt] = useState(false); const fetchConfig = useCallback(async () => { try { const data = await apiClient.get('/api/config/ai', { silent: true }); setValues({ provider: data.provider ?? 'openai', model: data.model ?? 'gpt-4o-mini', temperature: data.temperature != null ? String(data.temperature) : '0.7', systemPromptAddendum: '', }); setPrompts(data.prompts ?? {}); } catch (err) { console.error('[wizard/ai] fetch failed', err); } finally { setLoading(false); } }, []); useEffect(() => { fetchConfig(); }, [fetchConfig]); const handleSaveProviderConfig = async () => { if (!values.model.trim()) { notify.error('Model is required'); return; } const temperature = Number(values.temperature); setSaving(true); try { await apiClient.put('/api/config/ai', { provider: values.provider, model: values.model.trim(), temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)), }); notify.success('AI settings saved', 'Provider and model updated.'); await fetchConfig(); if (!props.isCompleted) { await props.onComplete('ai'); } } catch (err) { console.error('[wizard/ai] save provider failed', err); } finally { setSaving(false); } }; // Confirmation modal → slideout flow. const handleEditClick = (actor: string) => { setConfirmingActor(actor); }; const handleConfirmEdit = () => { if (!confirmingActor) return; const prompt = prompts[confirmingActor]; if (!prompt) return; setEditingActor(confirmingActor); setDraftTemplate(prompt.template); setConfirmingActor(null); }; const handleSavePrompt = async (close: () => void) => { if (!editingActor) return; if (!draftTemplate.trim()) { notify.error('Prompt cannot be empty'); return; } setSavingPrompt(true); try { await apiClient.put(`/api/config/ai/prompts/${editingActor}`, { template: draftTemplate, editedBy: user?.email ?? null, }); notify.success('Prompt updated', `${prompts[editingActor]?.label ?? editingActor} saved`); await fetchConfig(); close(); setEditingActor(null); } catch (err) { console.error('[wizard/ai] save prompt failed', err); } finally { setSavingPrompt(false); } }; const handleResetPrompt = async (close: () => void) => { if (!editingActor) return; setSavingPrompt(true); try { await apiClient.post(`/api/config/ai/prompts/${editingActor}/reset`); notify.success('Prompt reset', `${prompts[editingActor]?.label ?? editingActor} restored to default`); await fetchConfig(); close(); setEditingActor(null); } catch (err) { console.error('[wizard/ai] reset prompt failed', err); } finally { setSavingPrompt(false); } }; // Build the right-pane summary entries from the loaded prompts. // `isCustom` is true when the template differs from the shipped // default OR when the audit fields are populated — either way the // admin has touched it. const actorSummaries = useMemo(() => { return ACTOR_ORDER.filter((key) => prompts[key]).map((key) => { const p = prompts[key]; return { key, label: p.label, description: p.description, lastEditedAt: p.lastEditedAt, isCustom: p.template !== p.defaultTemplate || p.lastEditedAt !== null, }; }); }, [prompts]); const editingPrompt = editingActor ? prompts[editingActor] : null; const confirmingLabel = confirmingActor ? prompts[confirmingActor]?.label : ''; return ( } > {loading ? (

Loading AI settings…

) : (

Provider & model

AI personas

{actorSummaries.length} configurable prompts

Each persona below is a different AI surface in Helix Engage. Editing a prompt changes how that persona sounds and what rules it follows. Defaults are tuned for hospital call centers — only edit if you have a specific reason and can test the result.

    {ACTOR_ORDER.map((key) => { const prompt = prompts[key]; if (!prompt) return null; const isCustom = prompt.template !== prompt.defaultTemplate || prompt.lastEditedAt !== null; return (
  • {prompt.label}

    {prompt.description}

    {isCustom && ( Edited )}

    {truncate(prompt.template, 220)}

  • ); })}
)} {/* Confirmation modal — reused from the patient-edit gate. */} { if (!open) setConfirmingActor(null); }} onConfirm={handleConfirmEdit} title={`Edit ${confirmingLabel} prompt?`} description={ <> Modifying this prompt can affect call quality, lead summaries, and supervisor insights in ways that are hard to predict. The defaults are tuned for hospital call centers — only edit if you have a specific reason and can test the result. You can always reset back to default from the editor. } confirmLabel="Yes, edit prompt" /> {/* Slideout editor — only opens after the warning is confirmed. */} { if (!open) setEditingActor(null); }} isDismissable > {({ close }) => ( <>

Edit {editingPrompt?.label}

{editingPrompt?.description}