| 1 | // The system prompt, opened up for editing. The server's PROMPT.md is the |
| 2 | // default and stays the server's; an edit made here is this browser's alone, |
| 3 | // kept in localStorage and sent with each request. Nothing is sent while the |
| 4 | // text still matches the default, so the common case remains "whatever the |
| 5 | // server ships" and a server-side prompt update isn't frozen out by a stale copy. |
| 6 | // |
| 7 | // It's a full-screen editor rather than one of the header popovers because the |
| 8 | // prompt is ~23 KB of Markdown — the popovers are sized for a key or a dropdown. |
| 9 | import { closeAll } from './panel.js'; |
| 10 | |
| 11 | const KEY = 'strudel-claude-system-prompt'; |
| 12 | |
| 13 | // The server's default, once fetched. Null until then — which is also the |
| 14 | // "backend is down" answer, so the editor stays read-only rather than offering |
| 15 | // a Reset it can't perform. |
| 16 | let defaultPrompt = null; |
| 17 | |
| 18 | const stored = () => { |
| 19 | try { |
| 20 | return localStorage.getItem(KEY) || ''; |
| 21 | } catch { |
| 22 | return ''; |
| 23 | } |
| 24 | }; |
| 25 | |
| 26 | const store = (text) => { |
| 27 | try { |
| 28 | if (text) localStorage.setItem(KEY, text); |
| 29 | else localStorage.removeItem(KEY); |
| 30 | } catch { |
| 31 | /* storage blocked — the edit just won't survive a reload */ |
| 32 | } |
| 33 | }; |
| 34 | |
| 35 | /** The override to send, or '' to let the server use PROMPT.md. */ |
| 36 | export const getSystemPrompt = () => stored(); |
| 37 | |
| 38 | export function initPromptUI() { |
| 39 | const button = document.getElementById('prompt-btn'); |
| 40 | const label = document.getElementById('prompt-state'); |
| 41 | const modal = document.getElementById('prompt-modal'); |
| 42 | const field = document.getElementById('prompt-text'); |
| 43 | const note = document.getElementById('prompt-note'); |
| 44 | const saveBtn = document.getElementById('prompt-save'); |
| 45 | const resetBtn = document.getElementById('prompt-reset'); |
| 46 | const closeBtn = document.getElementById('prompt-close'); |
| 47 | |
| 48 | const render = () => { |
| 49 | const custom = Boolean(stored()); |
| 50 | label.textContent = custom ? 'Prompt (edited)' : 'System prompt'; |
| 51 | button.classList.toggle('prompt-custom', custom); |
| 52 | resetBtn.hidden = !custom || defaultPrompt === null; |
| 53 | note.textContent = |
| 54 | defaultPrompt === null |
| 55 | ? "Couldn't load the default prompt from the server — save will still work, but there's nothing to reset to." |
| 56 | : custom |
| 57 | ? 'Edited — this copy is stored in this browser and sent with each request.' |
| 58 | : "The server's PROMPT.md. Edit and save to use your own version instead."; |
| 59 | }; |
| 60 | |
| 61 | const open = () => { |
| 62 | closeAll(); // a header popover left open would float over the overlay |
| 63 | field.value = stored() || defaultPrompt || ''; |
| 64 | modal.hidden = false; |
| 65 | button.setAttribute('aria-expanded', 'true'); |
| 66 | field.focus(); |
| 67 | // Focusing a textarea leaves the caret at the end, which for a document this |
| 68 | // long means opening on the last line. Start at the top, where the prompt does. |
| 69 | field.setSelectionRange(0, 0); |
| 70 | field.scrollTop = 0; |
| 71 | render(); |
| 72 | }; |
| 73 | |
| 74 | const close = () => { |
| 75 | modal.hidden = true; |
| 76 | button.setAttribute('aria-expanded', 'false'); |
| 77 | button.focus(); |
| 78 | }; |
| 79 | |
| 80 | const save = () => { |
| 81 | const text = field.value.trim(); |
| 82 | // Saving the default verbatim means "no override" — otherwise the copy |
| 83 | // would silently pin this browser to today's PROMPT.md forever. |
| 84 | store(text && text !== defaultPrompt?.trim() ? text : ''); |
| 85 | render(); |
| 86 | close(); |
| 87 | }; |
| 88 | |
| 89 | button.addEventListener('click', (e) => { |
| 90 | e.stopPropagation(); |
| 91 | if (modal.hidden) open(); |
| 92 | else close(); |
| 93 | }); |
| 94 | |
| 95 | saveBtn.addEventListener('click', save); |
| 96 | closeBtn.addEventListener('click', close); |
| 97 | resetBtn.addEventListener('click', () => { |
| 98 | store(''); |
| 99 | field.value = defaultPrompt || ''; |
| 100 | render(); |
| 101 | }); |
| 102 | |
| 103 | // Click the backdrop (not the dialog) to dismiss. |
| 104 | modal.addEventListener('click', (e) => { |
| 105 | if (e.target === modal) close(); |
| 106 | }); |
| 107 | modal.addEventListener('keydown', (e) => { |
| 108 | // The global Escape handler closes popovers; this is a modal, so it swallows |
| 109 | // the key rather than letting both fire. |
| 110 | e.stopPropagation(); |
| 111 | if (e.key === 'Escape') close(); |
| 112 | else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save(); |
| 113 | }); |
| 114 | |
| 115 | render(); |
| 116 | |
| 117 | fetch('/api/prompt', { headers: { 'ngrok-skip-browser-warning': 'true' } }) |
| 118 | .then((r) => (r.ok ? r.json() : null)) |
| 119 | .then((data) => { |
| 120 | if (typeof data?.prompt !== 'string') return; |
| 121 | defaultPrompt = data.prompt; |
| 122 | // If the editor is already open showing an empty default, fill it in. |
| 123 | if (!modal.hidden && !field.value) field.value = defaultPrompt; |
| 124 | render(); |
| 125 | }) |
| 126 | .catch(() => render()); |
| 127 | } |