File size: 6,366 Bytes
478df90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a3a7e1
478df90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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 <pre> 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 (
    <div className="ccai-credentials-overlay">
      <div className="ccai-credentials-card">
        <div className="ccai-credentials-header">
          <div>
            <h2>Current chat prompts</h2>
            <div className="ccai-credentials-subtitle">
              Every prompt template the orchestrator and participants
              use during a chat, in conversation order. Variables in
              {' '}<code>{'{braces}'}</code> are filled in at runtime.
            </div>
          </div>
          <div className="ccai-tab-spacer" />
          <button
            className="btn-sm btn-outline"
            onClick={handleDownload}
            disabled={!catalog}
            title="Download the full catalog as a .txt file"
          >
            <Download size={14} style={{ marginRight: 4 }} />
            Download as .txt
          </button>
          <button className="modal-close" onClick={onClose}>&times;</button>
        </div>

        <div className="ccai-credentials-body">
          {!catalog && (
            <div className="ccai-credentials-empty">Loading prompts...</div>
          )}
          {catalog && (catalog.groups || []).map((g) => (
            <div key={g.title} className="ccai-prompt-group">
              <div className="ccai-prompt-group-title">{g.title}</div>
              {(g.items || []).map((item) => (
                <PromptItem key={item.name} item={item} />
              ))}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

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 (
    <div className="ccai-prompt-item">
      <div className="ccai-prompt-item-head">
        <div className="ccai-prompt-item-title">{displayName}</div>
        <button
          className="ccai-prompt-copy-btn"
          onClick={handleCopy}
          title="Copy template to clipboard"
        >
          {copied ? <Check size={12} /> : <Copy size={12} />}
          {copied ? 'Copied' : 'Copy'}
        </button>
      </div>
      <div className="ccai-prompt-purpose">{item.purpose}</div>
      {item.variables && item.variables.length > 0 && (
        <div className="ccai-prompt-vars">
          <strong>Variables:</strong>{' '}
          {item.variables.map((v, i) => (
            <span key={v}>
              <code>{`{${v}}`}</code>
              {i < item.variables.length - 1 ? ', ' : ''}
            </span>
          ))}
        </div>
      )}
      <pre className="ccai-prompt-template">{item.template}</pre>
    </div>
  );
}

/**
 * "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');
}