import React, { useMemo, useState } from 'react'; import { Copy, Check, Download } from 'lucide-react'; /** * Transparency modal that surfaces every prompt template the * orchestrator and participants use during a chat, grouped by phase. * Each item shows a humanized title, a 1-2 sentence purpose, the * runtime template variables the backend interpolates, and the full * template in a copy-able
block. A "Download as .txt" button
* in the header dumps the whole catalog in a flat human-readable form.
*
* Same shell pattern as ConversationLimitsModal / CredentialSummaryModal.
*/
export default function PromptCatalogModal({
isOpen,
catalog,
onClose,
}) {
// Note: hooks must run on every render, so this filename memo lives
// ABOVE the `if (!isOpen) return null` early return. The dependency
// on `isOpen` makes the filename timestamp regenerate each time the
// modal opens (i.e. each download gets a fresh stamp).
const filename = useMemo(() => {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
return (
'ccai-prompts-'
+ `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
+ '.txt'
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
if (!isOpen) return null;
const handleDownload = () => {
if (!catalog) return;
const txt = renderCatalogAsText(catalog);
const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
return (
Current chat prompts
Every prompt template the orchestrator and participants
use during a chat, in conversation order. Variables in
{' '}{'{braces}'} are filled in at runtime.
{!catalog && (
Loading prompts...
)}
{catalog && (catalog.groups || []).map((g) => (
{g.title}
{(g.items || []).map((item) => (
))}
))}
);
}
function PromptItem({ item }) {
const [copied, setCopied] = useState(false);
const displayName = humanizeName(item.name);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(item.template || '');
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.warn('Clipboard copy failed:', err);
}
};
return (
{displayName}
{item.purpose}
{item.variables && item.variables.length > 0 && (
Variables:{' '}
{item.variables.map((v, i) => (
{`{${v}}`}
{i < item.variables.length - 1 ? ', ' : ''}
))}
)}
{item.template}
);
}
/**
* "INITIAL_OPINION_PROMPT" -> "Initial Opinion Prompt".
* Splits on underscores, lowercases everything, capitalizes each word.
* Drops nothing (e.g. "Prompt" suffix stays) so the displayed name
* still matches the constant a developer might grep for.
*/
function humanizeName(name) {
if (!name) return '';
return name
.split('_')
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
/**
* Flat human-readable .txt dump used by the Download button. Matches
* the spec format: title banner, per-group separator, per-item header
* with purpose + variables, then the indented template body.
*/
function renderCatalogAsText(catalog) {
const now = new Date().toISOString();
const lines = [];
const banner = '═'.repeat(64);
lines.push(banner);
lines.push('Collaborative Conversational AI (CCAI) Demo — Current chat prompts');
lines.push(`Generated: ${now}`);
lines.push(banner);
lines.push('');
for (const group of catalog.groups || []) {
const sep = '─'.repeat(12);
lines.push(`${sep} ${group.title} ${sep}`);
lines.push('');
for (const item of (group.items || [])) {
lines.push(`## ${humanizeName(item.name)}`);
lines.push(`Purpose: ${item.purpose}`);
if (item.variables && item.variables.length > 0) {
lines.push(
'Variables: '
+ item.variables.map(v => `{${v}}`).join(', '),
);
}
lines.push('');
// Indent each template line by 4 spaces so the body is
// visually distinct from the metadata in a plain-text viewer.
for (const ln of (item.template || '').split('\n')) {
lines.push(' ' + ln);
}
lines.push('');
}
}
return lines.join('\n');
}