|
1 | | -// EA Toolkit — vanilla JS controller. |
2 | | -// Step 2 (current): form-submit no-op for design review. |
3 | | -// Step 4 (next session): replace handler with fetch-to-Worker + Markdown render. |
| 1 | +// EA Toolkit — Tool 1 (Meeting Brief Generator) frontend controller. |
| 2 | +// |
| 3 | +// Flow: form submit → build prompt → POST to Cloudflare Worker → render Markdown. |
| 4 | +// The Worker holds the Anthropic API key as a secret; the browser never sees it. |
4 | 5 |
|
5 | 6 | (function () { |
6 | 7 | 'use strict'; |
7 | 8 |
|
| 9 | + // ---- Configuration --------------------------------------------------------- |
| 10 | + // Paste the Cloudflare Worker URL here after running `wrangler deploy`. |
| 11 | + // Example: "https://ea-toolkit-proxy.lisa-myburgh.workers.dev" |
| 12 | + const WORKER_URL = "https://WORKER-URL-NOT-YET-CONFIGURED.workers.dev"; |
| 13 | + |
| 14 | + const PROMPT_TEMPLATE = `You are a senior executive assistant preparing a pre-meeting brief for an executive who has 10 minutes to skim it before a call. |
| 15 | +
|
| 16 | +CALENDAR / MEETING CONTEXT: |
| 17 | +{{meeting_context}} |
| 18 | +
|
| 19 | +PERSON THEY'RE MEETING: |
| 20 | +{{person_profile}} |
| 21 | +
|
| 22 | +Generate a brief with exactly these five sections, in this order, using the section headings shown. |
| 23 | +
|
| 24 | +## Who they are |
| 25 | +2–3 sentences distilled from the person's profile. Surface specific signal — current role, length in field, prior moves, public stances. Avoid generic descriptors. |
| 26 | +
|
| 27 | +## Meeting context |
| 28 | +2–4 sentences framing what's actually being discussed and what's likely beneath the surface. Separate the stated agenda from the implicit one. Be honest about ambiguity if the inputs don't reveal motive. |
| 29 | +
|
| 30 | +## Suggested talking points |
| 31 | +4–6 bullets. Each should be specific to this meeting and this person — not generic meeting advice. Lead with what your exec should establish, ask about, or be ready for. |
| 32 | +
|
| 33 | +## Questions to ask |
| 34 | +4–6 sharp, specific questions. Avoid questions answerable from the inputs already provided. Avoid yes/no questions. Each should open useful information. |
| 35 | +
|
| 36 | +## Red flags & sensitivities |
| 37 | +Anything to avoid or be careful around, drawn specifically from this profile or context (e.g. public stances they've taken, sensitive past projects, unusual signals in the invite). If the inputs reveal nothing concerning, write "None identified from the inputs provided." — do not invent. |
| 38 | +
|
| 39 | +Be concise. Use Markdown — \`## Heading\` for the five section titles, \`- bullet\` for lists. Do not include preamble before the first heading or summary after the last section. Do not wrap your output in code fences.`; |
| 40 | + |
| 41 | + // ---- DOM refs -------------------------------------------------------------- |
8 | 42 | const form = document.getElementById('brief-form'); |
9 | | - if (!form) return; |
| 43 | + const meetingInput = document.getElementById('meeting_context'); |
| 44 | + const personInput = document.getElementById('person_profile'); |
| 45 | + const submitBtn = document.getElementById('generate-btn'); |
| 46 | + const outputSection = document.getElementById('output-section'); |
| 47 | + const outputContent = document.getElementById('brief-content'); |
| 48 | + const outputHeader = outputSection?.querySelector('.output-header'); |
| 49 | + |
| 50 | + if (!form || !meetingInput || !personInput || !submitBtn || !outputSection || !outputContent) { |
| 51 | + return; |
| 52 | + } |
| 53 | + |
| 54 | + // ---- Helpers --------------------------------------------------------------- |
| 55 | + function escapeHtml(s) { |
| 56 | + return s |
| 57 | + .replace(/&/g, '&') |
| 58 | + .replace(/</g, '<') |
| 59 | + .replace(/>/g, '>') |
| 60 | + .replace(/"/g, '"') |
| 61 | + .replace(/'/g, '''); |
| 62 | + } |
| 63 | + |
| 64 | + function renderInline(text) { |
| 65 | + let s = escapeHtml(text); |
| 66 | + s = s.replace(/\*\*([^\n*][^\n]*?)\*\*/g, '<strong>$1</strong>'); |
| 67 | + s = s.replace(/(^|[^\*])\*([^\n*][^\n]*?)\*(?!\*)/g, '$1<em>$2</em>'); |
| 68 | + return s; |
| 69 | + } |
| 70 | + |
| 71 | + function renderMarkdown(md) { |
| 72 | + const lines = md.replace(/\r\n/g, '\n').split('\n'); |
| 73 | + const out = []; |
| 74 | + let listOpen = false; |
| 75 | + let paraBuf = []; |
10 | 76 |
|
11 | | - form.addEventListener('submit', function (e) { |
| 77 | + function flushPara() { |
| 78 | + if (paraBuf.length) { |
| 79 | + out.push(`<p>${renderInline(paraBuf.join(' '))}</p>`); |
| 80 | + paraBuf = []; |
| 81 | + } |
| 82 | + } |
| 83 | + function closeList() { |
| 84 | + if (listOpen) { |
| 85 | + out.push('</ul>'); |
| 86 | + listOpen = false; |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + for (const raw of lines) { |
| 91 | + const line = raw.trimEnd(); |
| 92 | + if (!line.trim()) { |
| 93 | + flushPara(); |
| 94 | + closeList(); |
| 95 | + continue; |
| 96 | + } |
| 97 | + const h2 = line.match(/^##\s+(.*)$/); |
| 98 | + const h3 = line.match(/^###\s+(.*)$/); |
| 99 | + const bullet = line.match(/^\s*[-•]\s+(.+)$/); |
| 100 | + if (h2) { |
| 101 | + flushPara(); |
| 102 | + closeList(); |
| 103 | + out.push(`<h2>${renderInline(h2[1])}</h2>`); |
| 104 | + } else if (h3) { |
| 105 | + flushPara(); |
| 106 | + closeList(); |
| 107 | + out.push(`<h3>${renderInline(h3[1])}</h3>`); |
| 108 | + } else if (bullet) { |
| 109 | + flushPara(); |
| 110 | + if (!listOpen) { |
| 111 | + out.push('<ul>'); |
| 112 | + listOpen = true; |
| 113 | + } |
| 114 | + out.push(`<li>${renderInline(bullet[1])}</li>`); |
| 115 | + } else { |
| 116 | + closeList(); |
| 117 | + paraBuf.push(line.trim()); |
| 118 | + } |
| 119 | + } |
| 120 | + flushPara(); |
| 121 | + closeList(); |
| 122 | + return out.join('\n'); |
| 123 | + } |
| 124 | + |
| 125 | + function setLoading(isLoading) { |
| 126 | + submitBtn.classList.toggle('loading', isLoading); |
| 127 | + submitBtn.disabled = isLoading; |
| 128 | + submitBtn.querySelector('.btn-label').textContent = isLoading ? 'Generating' : 'Generate brief'; |
| 129 | + meetingInput.disabled = isLoading; |
| 130 | + personInput.disabled = isLoading; |
| 131 | + } |
| 132 | + |
| 133 | + function showOutput({ kind, html, headerEyebrow, headerMeta }) { |
| 134 | + if (outputHeader) { |
| 135 | + const eyebrow = outputHeader.querySelector('.eyebrow'); |
| 136 | + const meta = outputHeader.querySelector('.output-meta'); |
| 137 | + if (eyebrow && headerEyebrow) { |
| 138 | + const dot = '<span class="dot"></span>'; |
| 139 | + eyebrow.innerHTML = `${dot}${escapeHtml(headerEyebrow)}`; |
| 140 | + } |
| 141 | + if (meta) meta.textContent = headerMeta || ''; |
| 142 | + } |
| 143 | + outputContent.className = `brief brief--${kind}`; |
| 144 | + outputContent.innerHTML = html; |
| 145 | + outputSection.hidden = false; |
| 146 | + outputSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| 147 | + } |
| 148 | + |
| 149 | + function showError(message) { |
| 150 | + showOutput({ |
| 151 | + kind: 'error', |
| 152 | + html: `<p class="error-text">${escapeHtml(message)}</p>`, |
| 153 | + headerEyebrow: 'Error', |
| 154 | + headerMeta: 'Something went wrong generating the brief.', |
| 155 | + }); |
| 156 | + } |
| 157 | + |
| 158 | + function workerNotConfigured() { |
| 159 | + return WORKER_URL.includes('WORKER-URL-NOT-YET-CONFIGURED'); |
| 160 | + } |
| 161 | + |
| 162 | + // ---- Submit ---------------------------------------------------------------- |
| 163 | + form.addEventListener('submit', async function (e) { |
12 | 164 | e.preventDefault(); |
13 | | - // Wiring to the Cloudflare Worker lands in step 4. |
| 165 | + if (submitBtn.disabled) return; |
| 166 | + |
| 167 | + const meeting = meetingInput.value.trim(); |
| 168 | + const person = personInput.value.trim(); |
| 169 | + if (!meeting || !person) { |
| 170 | + showError('Please fill in both fields before generating.'); |
| 171 | + return; |
| 172 | + } |
| 173 | + |
| 174 | + if (workerNotConfigured()) { |
| 175 | + showError( |
| 176 | + 'The Cloudflare Worker URL has not been configured yet. ' + |
| 177 | + 'This site is still being set up — check back shortly, or fork the repo and follow the README to deploy your own.' |
| 178 | + ); |
| 179 | + return; |
| 180 | + } |
| 181 | + |
| 182 | + const prompt = PROMPT_TEMPLATE |
| 183 | + .replace('{{meeting_context}}', meeting) |
| 184 | + .replace('{{person_profile}}', person); |
| 185 | + |
| 186 | + setLoading(true); |
| 187 | + showOutput({ |
| 188 | + kind: 'loading', |
| 189 | + html: '<p class="loading-text">Generating brief…</p>', |
| 190 | + headerEyebrow: 'Brief', |
| 191 | + headerMeta: 'Sending to Claude — usually takes 5–15 seconds.', |
| 192 | + }); |
| 193 | + |
| 194 | + try { |
| 195 | + const res = await fetch(WORKER_URL + '/generate', { |
| 196 | + method: 'POST', |
| 197 | + headers: { 'Content-Type': 'application/json' }, |
| 198 | + body: JSON.stringify({ prompt }), |
| 199 | + }); |
| 200 | + |
| 201 | + const data = await res.json().catch(() => ({})); |
| 202 | + |
| 203 | + if (!res.ok) { |
| 204 | + showError(data?.error || `Request failed (${res.status}). Please try again.`); |
| 205 | + return; |
| 206 | + } |
| 207 | + |
| 208 | + const text = data?.text; |
| 209 | + if (typeof text !== 'string' || !text.trim()) { |
| 210 | + showError('The model returned an empty response. Please try again.'); |
| 211 | + return; |
| 212 | + } |
| 213 | + |
| 214 | + showOutput({ |
| 215 | + kind: 'success', |
| 216 | + html: renderMarkdown(text), |
| 217 | + headerEyebrow: 'Brief', |
| 218 | + headerMeta: 'Generated by Claude. Review before sending to your exec.', |
| 219 | + }); |
| 220 | + } catch (err) { |
| 221 | + showError("Couldn't reach the Worker. Check your connection and try again."); |
| 222 | + } finally { |
| 223 | + setLoading(false); |
| 224 | + } |
14 | 225 | }); |
15 | 226 | })(); |
0 commit comments