jekyll-theme-zer0 1.28.0 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1143 -13
  3. data/_data/README.md +2 -0
  4. data/_data/ai.yml +5 -3
  5. data/_data/ai_pricing.yml +36 -0
  6. data/_data/backlog.yml +507 -2
  7. data/_data/consumers.yml +157 -9
  8. data/_data/features.yml +303 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/i18n/fr.yml +12 -7
  11. data/_data/i18n/manifest.yml +39 -12
  12. data/_data/ingredient_densities.yml +122 -0
  13. data/_data/landing.yml +5 -2
  14. data/_data/navigation/main.yml +16 -0
  15. data/_data/navigation/quickstart.yml +4 -0
  16. data/_data/recipe_courses.yml +64 -0
  17. data/_data/site_builder.yml +874 -0
  18. data/_data/theme-manifest.yml +160 -124
  19. data/_data/ui-text.yml +26 -0
  20. data/_includes/README.md +26 -2
  21. data/_includes/analytics/posthog.html +2 -2
  22. data/_includes/components/admin-links.html +2 -2
  23. data/_includes/components/admin-tabs.html +2 -2
  24. data/_includes/components/ai-chat.html +14 -11
  25. data/_includes/components/analytics-dashboard.html +8 -8
  26. data/_includes/components/author-bio.html +1 -1
  27. data/_includes/components/author-card.html +10 -2
  28. data/_includes/components/author-eeat.html +4 -4
  29. data/_includes/components/background-customizer.html +10 -10
  30. data/_includes/components/background-image.html +114 -0
  31. data/_includes/components/background-settings.html +28 -15
  32. data/_includes/components/collection-manager.html +5 -5
  33. data/_includes/components/component-showcase.html +13 -13
  34. data/_includes/components/config-editor.html +12 -12
  35. data/_includes/components/config-viewer.html +8 -8
  36. data/_includes/components/cookie-consent.html +15 -15
  37. data/_includes/components/cta-button.html +7 -2
  38. data/_includes/components/dev-shortcuts.html +7 -7
  39. data/_includes/components/env-dashboard.html +8 -8
  40. data/_includes/components/env-switcher.html +9 -9
  41. data/_includes/components/feature-card.html +2 -2
  42. data/_includes/components/halfmoon.html +2 -2
  43. data/_includes/components/info-section.html +42 -37
  44. data/_includes/components/js-cdn.html +15 -15
  45. data/_includes/components/language-toggle.html +168 -21
  46. data/_includes/components/mermaid.html +72 -435
  47. data/_includes/components/nanobar.html +5 -5
  48. data/_includes/components/nav-editor.html +2 -2
  49. data/_includes/components/nav-export.html +2 -2
  50. data/_includes/components/nav-overview.html +2 -2
  51. data/_includes/components/page-feedback.html +45 -30
  52. data/_includes/components/page-views-init.html +55 -0
  53. data/_includes/components/page-views.html +33 -0
  54. data/_includes/components/post-card.html +22 -22
  55. data/_includes/components/post-type-badge.html +2 -2
  56. data/_includes/components/powered-by.html +2 -2
  57. data/_includes/components/preview-image.html +6 -0
  58. data/_includes/components/quick-index.html +2 -2
  59. data/_includes/components/recipe-card.html +67 -0
  60. data/_includes/components/recipe-duration.html +50 -0
  61. data/_includes/components/recipe-grams.html +58 -0
  62. data/_includes/components/recipe-index.html +96 -0
  63. data/_includes/components/recipe-ingredients.html +90 -0
  64. data/_includes/components/recipe-meta.html +96 -0
  65. data/_includes/components/recipe-nutrition.html +57 -0
  66. data/_includes/components/recipe-qty.html +73 -0
  67. data/_includes/components/recipe-ratio.html +151 -0
  68. data/_includes/components/recipe-scaler.html +73 -0
  69. data/_includes/components/recipe-steps.html +86 -0
  70. data/_includes/components/recipe-temp.html +45 -0
  71. data/_includes/components/search-modal.html +29 -4
  72. data/_includes/components/searchbar.html +2 -2
  73. data/_includes/components/shortcuts-modal.html +3 -0
  74. data/_includes/components/svg-background.html +2 -2
  75. data/_includes/components/theme-customizer.html +2 -2
  76. data/_includes/components/theme-info.html +14 -7
  77. data/_includes/components/theme-preview-gallery.html +22 -22
  78. data/_includes/content/giscus.html +2 -2
  79. data/_includes/content/intro.html +8 -8
  80. data/_includes/content/jsonld-faq.html +2 -2
  81. data/_includes/content/jsonld-software.html +24 -5
  82. data/_includes/content/seo.html +4 -4
  83. data/_includes/content/sitemap.html +27 -27
  84. data/_includes/content/toc.html +183 -183
  85. data/_includes/core/branding.html +6 -6
  86. data/_includes/core/console-capture.html +32 -74
  87. data/_includes/core/favicon.html +49 -7
  88. data/_includes/core/footer-fabs.html +17 -3
  89. data/_includes/core/footer.html +49 -34
  90. data/_includes/core/head.html +110 -86
  91. data/_includes/core/header.html +76 -54
  92. data/_includes/docs/bootstrap-docs.html +8 -8
  93. data/_includes/landing/landing-install-cards.html +2 -2
  94. data/_includes/landing/landing-quick-links.html +1 -1
  95. data/_includes/navigation/admin-nav.html +2 -2
  96. data/_includes/navigation/nav-tree.html +8 -8
  97. data/_includes/navigation/navbar.html +12 -12
  98. data/_includes/navigation/section-sidebar.html +109 -27
  99. data/_includes/navigation/sidebar-config.html +36 -2
  100. data/_includes/navigation/sidebar-left.html +17 -16
  101. data/_includes/navigation/sidebar-right.html +8 -7
  102. data/_includes/obsidian/full-graph.html +2 -2
  103. data/_includes/setup/claude-session.html +72 -0
  104. data/_includes/setup/prereq-checklist.html +90 -0
  105. data/_includes/setup/wizard.html +924 -222
  106. data/_includes/stats/stats-categories.html +8 -8
  107. data/_includes/stats/stats-header.html +14 -14
  108. data/_includes/stats/stats-metrics.html +14 -14
  109. data/_includes/stats/stats-no-data.html +12 -12
  110. data/_includes/stats/stats-overview.html +6 -6
  111. data/_includes/stats/stats-tags.html +8 -8
  112. data/_layouts/404.html +38 -24
  113. data/_layouts/README.md +2 -0
  114. data/_layouts/admin.html +24 -24
  115. data/_layouts/article.html +43 -33
  116. data/_layouts/author.html +20 -20
  117. data/_layouts/authors.html +2 -2
  118. data/_layouts/book-abc.html +12 -12
  119. data/_layouts/book-story.html +15 -15
  120. data/_layouts/book.html +12 -12
  121. data/_layouts/collection.html +33 -33
  122. data/_layouts/cookbook.html +88 -0
  123. data/_layouts/default.html +31 -28
  124. data/_layouts/home.html +23 -23
  125. data/_layouts/index.html +10 -10
  126. data/_layouts/landing.html +17 -17
  127. data/_layouts/news.html +44 -44
  128. data/_layouts/note.html +38 -38
  129. data/_layouts/notebook.html +34 -34
  130. data/_layouts/recipe.html +274 -0
  131. data/_layouts/root.html +92 -55
  132. data/_layouts/section.html +62 -33
  133. data/_layouts/setup.html +3 -3
  134. data/_layouts/sitemap-collection.html +49 -49
  135. data/_layouts/stats.html +40 -40
  136. data/_layouts/tag.html +12 -12
  137. data/_layouts/welcome.html +21 -21
  138. data/_sass/components/_callout.scss +1 -1
  139. data/_sass/components/_footer.scss +37 -1
  140. data/_sass/components/_mermaid.scss +375 -0
  141. data/_sass/components/_page-views.scss +36 -0
  142. data/_sass/components/_recipe.scss +506 -0
  143. data/_sass/components/_setup-wizard.scss +764 -0
  144. data/_sass/components/_ui-enhancements.scss +6 -6
  145. data/_sass/core/_navbar.scss +261 -46
  146. data/_sass/layouts/_landing.scss +2 -2
  147. data/_sass/layouts/_navbar-extras.scss +14 -4
  148. data/_sass/tokens/_color.scss +6 -0
  149. data/_sass/tokens/_index.scss +2 -0
  150. data/_sass/tokens/_radius.scss +21 -0
  151. data/_sass/tokens/_typography.scss +4 -0
  152. data/_sass/utilities/_focus.scss +14 -0
  153. data/assets/css/main.scss +4 -0
  154. data/assets/js/ai-chat.js +47 -5
  155. data/assets/js/fleet-feedback-capture.js +124 -0
  156. data/assets/js/fleet-feedback.js +853 -0
  157. data/assets/js/mermaid-diagrams.js +1267 -0
  158. data/assets/js/modules/navigation/config.js +9 -6
  159. data/assets/js/modules/navigation/navbar.js +55 -0
  160. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  161. data/assets/js/modules/theme/appearance.js +8 -2
  162. data/assets/js/obsidian-wiki-links.js +8 -3
  163. data/assets/js/page-feedback.js +125 -192
  164. data/assets/js/page-views.js +372 -0
  165. data/assets/js/recipe-scaler.js +501 -0
  166. data/assets/js/search-modal.js +36 -0
  167. data/assets/js/setup-wizard.js +2279 -226
  168. data/assets/js/site-builder.js +1834 -0
  169. data/assets/js/ui-enhancements.js +11 -3
  170. data/scripts/README.md +44 -0
  171. data/scripts/ai/README.md +38 -0
  172. data/scripts/ai/api_call.rb +124 -0
  173. data/scripts/ai/usage.rb +314 -0
  174. data/scripts/ai/usage_report.rb +225 -0
  175. data/scripts/bin/audit-consumer +39 -7
  176. data/scripts/bin/giscus-discussions +213 -14
  177. data/scripts/bin/manifest +35 -12
  178. data/scripts/ci/agent_review_result.py +164 -0
  179. data/scripts/ci/test_agent_review_result.py +172 -0
  180. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  181. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  182. data/scripts/content-review.rb +20 -1
  183. data/scripts/design-system-check.rb +170 -0
  184. data/scripts/lib/audit.sh +42 -2
  185. data/scripts/lint-liquid-raw.rb +137 -0
  186. data/scripts/test/integration/mermaid +22 -8
  187. data/scripts/test/lib/run_tests.sh +3 -1
  188. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  189. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  190. data/scripts/translate.rb +94 -16
  191. metadata +48 -2
@@ -0,0 +1,1834 @@
1
+ // Feature: ZER0-086
2
+ // Feature: ZER0-085
3
+ // Feature: ZER0-087
4
+ /**
5
+ * ===================================================================
6
+ * Site Builder — embedded AI session (site-builder.js)
7
+ * ===================================================================
8
+ *
9
+ * File: assets/js/site-builder.js
10
+ * Markup: _includes/setup/claude-session.html (inside setup/wizard.html)
11
+ * Config: #siteBuilderConfig (endpoints, defaults) · #siteBuilderData
12
+ * (catalogs, providers, session modes, framework brief, per-step
13
+ * prompts) — both emitted by the include
14
+ * Peer: assets/js/setup-wizard.js exposes window.Zer0SetupWizard; this
15
+ * script never touches form controls directly — it goes through
16
+ * that API so the form stays the single source of truth.
17
+ *
18
+ * What it does
19
+ * 1. Connects to the LOCAL dev proxy (templates/deploy/chat-proxy/
20
+ * dev-proxy.mjs): GET {endpoint}/status. Offline → the panel explains
21
+ * how to connect and every proxy-backed button stays disabled. Online →
22
+ * live prerequisite checks, "Write project" and the compose actions
23
+ * light up; the chat lights up once the chosen PROVIDER has a
24
+ * credential.
25
+ * 2. Bring your own AI — Claude (Anthropic) or Grok (xAI). The Connect
26
+ * step shows which providers the proxy already has keys for (masked)
27
+ * and lets the user paste a token for this session: it is POSTed once
28
+ * to the local proxy (localhost only), never kept in the page or in
29
+ * browser storage, and optionally saved to .env by the proxy.
30
+ * 3. Streams replies from {chatEndpoint}. The proxy translates for Grok,
31
+ * so the client speaks ONE dialect (Anthropic Messages SSE) whichever
32
+ * provider answers, with the same tool-use loop.
33
+ * 4. Two session modes: GUIDED (the nine steps, per-step prompts) and OPEN
34
+ * (chat-driven, any order — create a site, open an existing one, edit
35
+ * files, generate images, rebuild — like a coding session). Long tool
36
+ * loops show a round counter and can be stopped.
37
+ * 5. Gives the model the WHOLE wizard as context on every turn: framework
38
+ * brief, field schema, current step, a summary of every answer, check
39
+ * result and generated file, and the working project when one is open.
40
+ * 6. Tools (see buildTools): read state / files / theme source / docs;
41
+ * set fields, override a generated file, jump steps; run prerequisite
42
+ * checks; resolve the target folder; write the project; open/list/read/
43
+ * write/edit/delete files in an existing project; git status/diff/log;
44
+ * generate an image into assets/; run docker compose (incl. jekyll
45
+ * build); check whether the site answers. Every mutation shows an
46
+ * inline confirmation card first — same contract as ai-chat.js.
47
+ * 7. Persists the visible transcript in sessionStorage (text turns only,
48
+ * never tool blocks, so a restored history can't orphan a tool_result)
49
+ * and the provider/model/mode preferences in localStorage (never a
50
+ * token).
51
+ *
52
+ * Safety contracts (mirror .github/instructions/ai-chat.instructions.md)
53
+ * - Escape-first rendering; cards built with createElement/textContent.
54
+ * - Mutating tools require confirmation; a decline returns a tool_result
55
+ * telling the model not to retry.
56
+ * - History trimming keeps the buffer starting on a plain user text turn;
57
+ * an interrupted run gets synthetic tool_results so no tool_use is left
58
+ * unanswered.
59
+ * - A credential is only ever sent to a localhost proxy endpoint.
60
+ * - Nothing here can reach a host that isn't the same-origin site or the
61
+ * configured proxy endpoints.
62
+ * ===================================================================
63
+ */
64
+
65
+ (function () {
66
+ 'use strict';
67
+
68
+ function readJSON(id, fallback) {
69
+ var el = document.getElementById(id);
70
+ if (!el) return fallback;
71
+ try { return JSON.parse(el.textContent); } catch (e) { return fallback; }
72
+ }
73
+
74
+ var CONFIG = readJSON('siteBuilderConfig', null);
75
+ var DATA = readJSON('siteBuilderData', {}) || {};
76
+ var panel = document.getElementById('siteBuilderPanel');
77
+ if (!CONFIG || !panel || CONFIG.enabled === false) return;
78
+
79
+ var TRANSCRIPT_KEY = 'zer0-site-builder-transcript';
80
+ var PREFS_KEY = 'zer0-site-builder-prefs';
81
+ var MAX_MESSAGES = 60;
82
+ var TOOL_ROUNDS = { guided: 12, open: 24 };
83
+ var MAX_TOOL_TEXT = 40000;
84
+ var STATUS_POLL_MS = 15000;
85
+ var STATUS_POLL_MAX = 40;
86
+ var IMAGE_ASPECTS = ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3', '21:9', '5:2'];
87
+
88
+ var STATUS_MESSAGES = {
89
+ 401: 'The provider rejected the credential. Enter a fresh token on the Connect step.',
90
+ 403: 'Access denied by the proxy (origin not allowed?).',
91
+ 429: 'Rate limit reached. Wait a moment and try again.',
92
+ 500: 'The AI service is temporarily unavailable.',
93
+ 501: 'The proxy has no credential for this provider yet. Add one on the Connect step.',
94
+ 529: 'The AI service is overloaded right now. Try again shortly.'
95
+ };
96
+
97
+ // --- DOM ---------------------------------------------------------
98
+ var els = {
99
+ messages: document.getElementById('sb-messages'),
100
+ offline: document.getElementById('sb-offline'),
101
+ chips: document.getElementById('sb-chips'),
102
+ form: document.getElementById('sb-form'),
103
+ input: document.getElementById('sb-input'),
104
+ send: document.getElementById('sb-send'),
105
+ stop: document.getElementById('sb-stop'),
106
+ badge: document.getElementById('sb-status-badge'),
107
+ modeBadge: document.getElementById('sb-mode-badge'),
108
+ clear: document.getElementById('sb-clear'),
109
+ collapse: document.getElementById('sb-collapse'),
110
+ body: document.getElementById('sb-body'),
111
+ note: document.getElementById('sb-context-note'),
112
+ disclaimer: document.getElementById('sb-disclaimer'),
113
+ title: document.getElementById('sb-title'),
114
+ connectStatus: document.getElementById('connect-status'),
115
+ connectRetry: document.getElementById('btn-connect-retry'),
116
+ connectHowto: document.getElementById('connect-howto'),
117
+ providerFieldset: document.getElementById('sb-providers'),
118
+ credentialForm: document.getElementById('sb-credential-form'),
119
+ tokenInput: document.getElementById('sb-token'),
120
+ tokenUse: document.getElementById('sb-token-use'),
121
+ tokenPersist: document.getElementById('sb-token-persist'),
122
+ tokenHint: document.getElementById('sb-token-hint'),
123
+ credentialStatus: document.getElementById('sb-credential-status'),
124
+ modelSelect: document.getElementById('sb-model'),
125
+ modelNote: document.getElementById('sb-model-note'),
126
+ imageSelect: document.getElementById('sb-image-provider'),
127
+ imageNote: document.getElementById('sb-image-note'),
128
+ runChecks: document.getElementById('btn-run-checks'),
129
+ terminal: document.getElementById('wizard-terminal'),
130
+ targetInput: document.getElementById('cfg-target'),
131
+ targetRoot: document.getElementById('target-root'),
132
+ targetResolved: document.getElementById('target-resolved'),
133
+ projectSelect: document.getElementById('sb-project-select'),
134
+ projectOpen: document.getElementById('btn-project-open'),
135
+ writeProject: document.getElementById('btn-write-project'),
136
+ composeUp: document.getElementById('btn-compose-up'),
137
+ composeBuild: document.getElementById('btn-compose-build'),
138
+ composeLogs: document.getElementById('btn-compose-logs'),
139
+ composeDown: document.getElementById('btn-compose-down'),
140
+ checkLive: document.getElementById('btn-check-live'),
141
+ proxyHint: document.getElementById('build-proxy-hint')
142
+ };
143
+
144
+ // --- State -------------------------------------------------------
145
+ var state = {
146
+ connected: false, // the dev proxy answers
147
+ ready: false, // the chosen provider has a credential
148
+ status: null, // GET /status payload
149
+ provider: null, // 'anthropic' | 'xai'
150
+ model: null,
151
+ imageProvider: '',
152
+ mode: (CONFIG.mode === 'open' ? 'open' : 'guided'),
153
+ project: null, // { name, abs, exists, tree: [] } once a project is opened
154
+ busy: false,
155
+ abort: null, // AbortController for the running turn
156
+ history: [], // Messages API turns
157
+ polls: 0,
158
+ pollTimer: null
159
+ };
160
+
161
+ function W() { return window.Zer0SetupWizard || null; }
162
+
163
+ // --- Preferences (never a token) --------------------------------
164
+ function loadPrefs() {
165
+ try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {}; } catch (e) { return {}; }
166
+ }
167
+
168
+ function savePrefs() {
169
+ try {
170
+ localStorage.setItem(PREFS_KEY, JSON.stringify({ provider: state.provider, model: state.model, mode: state.mode, imageProvider: state.imageProvider }));
171
+ } catch (e) { /* private mode */ }
172
+ }
173
+
174
+ var prefs = loadPrefs();
175
+ if (prefs.mode === 'open' || prefs.mode === 'guided') state.mode = prefs.mode;
176
+ // There is always a selected provider card: an explicit choice (remembered,
177
+ // or pinned by site_builder.provider) or the first catalog entry. Only an
178
+ // explicit choice stops the session from following whatever provider the
179
+ // proxy reports it can answer with.
180
+ if (CONFIG.provider && CONFIG.provider !== 'auto') state.provider = CONFIG.provider;
181
+ if (prefs.provider) state.provider = prefs.provider;
182
+ state.providerExplicit = !!state.provider;
183
+ if (!state.provider) state.provider = (DATA.providers && DATA.providers[0] && DATA.providers[0].id) || 'anthropic';
184
+ if (prefs.model) state.model = prefs.model;
185
+ if (typeof prefs.imageProvider === 'string') state.imageProvider = prefs.imageProvider;
186
+ else if (CONFIG.imageProvider) state.imageProvider = CONFIG.imageProvider;
187
+
188
+ // --- Provider helpers ---------------------------------------------
189
+ function catalogProvider(id) {
190
+ return (DATA.providers || []).filter(function (p) { return p.id === id; })[0] || null;
191
+ }
192
+
193
+ function statusProvider(id) {
194
+ var s = state.status;
195
+ return s && s.providers && s.providers[id || state.provider] ? s.providers[id || state.provider] : null;
196
+ }
197
+
198
+ function providerName(id) {
199
+ var p = statusProvider(id) || catalogProvider(id || state.provider);
200
+ return p ? p.label : 'Claude';
201
+ }
202
+
203
+ function providerVendor(id) {
204
+ var p = statusProvider(id) || catalogProvider(id || state.provider);
205
+ return p ? p.vendor : 'Anthropic';
206
+ }
207
+
208
+ function imageProviderInfo(id) {
209
+ var s = state.status;
210
+ var key = id || state.imageProvider;
211
+ return s && s.image && s.image.providers && s.image.providers[key] ? s.image.providers[key] : null;
212
+ }
213
+
214
+ function providerPin() {
215
+ var s = state.status;
216
+ return s && s.providerPin && s.providerPin !== 'auto' ? s.providerPin : null;
217
+ }
218
+
219
+ function isLocalEndpoint() {
220
+ try {
221
+ var u = new URL(endpoint('/credentials'), window.location.href);
222
+ return ['localhost', '127.0.0.1', '::1', '[::1]'].indexOf(u.hostname) !== -1;
223
+ } catch (e) { return false; }
224
+ }
225
+
226
+ // --- Rendering helpers -------------------------------------------
227
+ function escapeHtml(text) {
228
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
229
+ }
230
+
231
+ /** Escape first, then a small Markdown subset (headings, bold, code, fences, links, bullets). */
232
+ function renderMarkdown(raw) {
233
+ var safe = escapeHtml(raw || '');
234
+ var fences = [];
235
+ safe = safe.replace(/```([a-z0-9_-]*)\n([\s\S]*?)```/gi, function (_m, lang, code) {
236
+ fences.push('<pre class="sb-code"><code' + (lang ? ' data-lang="' + lang + '"' : '') + '>' + code.replace(/\n$/, '') + '</code></pre>');
237
+ return ' F' + (fences.length - 1) + ' ';
238
+ });
239
+ safe = safe
240
+ .replace(/^###\s+(.+)$/gm, '<strong>$1</strong>')
241
+ .replace(/^##\s+(.+)$/gm, '<strong>$1</strong>')
242
+ .replace(/^#\s+(.+)$/gm, '<strong>$1</strong>')
243
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
244
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
245
+ .replace(/\[(.+?)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, function (_m, label, href) {
246
+ var external = /^https?:/i.test(href);
247
+ return '<a href="' + href + '"' + (external ? ' target="_blank" rel="noopener noreferrer"' : '') + '>' + label + '</a>';
248
+ })
249
+ .replace(/^\s*\d+\.\s+(.+)$/gm, '<span class="sb-li">$1</span>')
250
+ .replace(/^\s*[-*]\s+(.+)$/gm, '<span class="sb-li">• $1</span>')
251
+ .replace(/\n{2,}/g, '<br><br>')
252
+ .replace(/\n/g, '<br>');
253
+ safe = safe.replace(/ F(\d+) /g, function (_m, i) { return fences[Number(i)] || ''; });
254
+ return safe;
255
+ }
256
+
257
+ function scrollToBottom() {
258
+ if (els.messages) els.messages.scrollTop = els.messages.scrollHeight;
259
+ }
260
+
261
+ function appendMessage(role, content, opts) {
262
+ if (!els.messages) return null;
263
+ if (els.offline) els.offline.hidden = true;
264
+ var wrapper = document.createElement('div');
265
+ wrapper.className = 'sb-message sb-message--' + role + ' mb-2';
266
+ var bubble = document.createElement('div');
267
+ bubble.className = 'sb-bubble p-2 rounded-3 small';
268
+ if (role === 'assistant') bubble.innerHTML = renderMarkdown(content);
269
+ else bubble.textContent = content;
270
+ wrapper.appendChild(bubble);
271
+ els.messages.appendChild(wrapper);
272
+ scrollToBottom();
273
+ if (!(opts && opts.transient)) persistTranscript(role, content);
274
+ return bubble;
275
+ }
276
+
277
+ function appendNotice(text, kind) {
278
+ if (!els.messages) return;
279
+ var note = document.createElement('div');
280
+ note.className = 'sb-notice small text-body-secondary mb-2 ' + (kind || '');
281
+ note.textContent = text;
282
+ els.messages.appendChild(note);
283
+ scrollToBottom();
284
+ }
285
+
286
+ function showTyping() {
287
+ removeTyping();
288
+ if (!els.messages) return;
289
+ var wrapper = document.createElement('div');
290
+ wrapper.className = 'sb-message sb-message--assistant mb-2';
291
+ wrapper.id = 'sbTyping';
292
+ wrapper.innerHTML = '<div class="sb-bubble p-2 rounded-3 small"><span class="sb-typing"><span>.</span><span>.</span><span>.</span></span></div>';
293
+ els.messages.appendChild(wrapper);
294
+ scrollToBottom();
295
+ }
296
+
297
+ function removeTyping() {
298
+ var t = document.getElementById('sbTyping');
299
+ if (t) t.remove();
300
+ }
301
+
302
+ function addMeta(card, label, value) {
303
+ if (value === undefined || value === null || value === '') return;
304
+ var row = document.createElement('div');
305
+ row.className = 'sb-card-meta';
306
+ var l = document.createElement('span');
307
+ l.className = 'text-body-secondary';
308
+ l.textContent = label + ': ';
309
+ var v = document.createElement('span');
310
+ v.textContent = String(value);
311
+ row.appendChild(l);
312
+ row.appendChild(v);
313
+ card.appendChild(row);
314
+ }
315
+
316
+ /** Inline confirmation card; resolves true/false. */
317
+ function requestConfirmation(opts) {
318
+ return new Promise(function (resolve) {
319
+ var card = document.createElement('div');
320
+ card.className = 'sb-card rounded-3 p-2 mb-2 small';
321
+ var heading = document.createElement('div');
322
+ heading.className = 'fw-semibold mb-1';
323
+ heading.textContent = opts.heading;
324
+ card.appendChild(heading);
325
+ (opts.fields || []).forEach(function (field) { addMeta(card, field.label, field.value); });
326
+ if (opts.list && opts.list.length) {
327
+ var ul = document.createElement('ul');
328
+ ul.className = 'sb-card-list mb-1';
329
+ opts.list.slice(0, 30).forEach(function (item) {
330
+ var li = document.createElement('li');
331
+ li.textContent = item;
332
+ ul.appendChild(li);
333
+ });
334
+ if (opts.list.length > 30) {
335
+ var more = document.createElement('li');
336
+ more.textContent = '… ' + (opts.list.length - 30) + ' more';
337
+ ul.appendChild(more);
338
+ }
339
+ card.appendChild(ul);
340
+ }
341
+ (opts.blocks || []).forEach(function (b) {
342
+ var label = document.createElement('div');
343
+ label.className = 'text-body-secondary mt-1';
344
+ label.textContent = b.label;
345
+ var pre = document.createElement('pre');
346
+ pre.className = 'sb-diff mb-1';
347
+ pre.textContent = String(b.text || '').slice(0, 1200) + (String(b.text || '').length > 1200 ? '\n…' : '');
348
+ card.appendChild(label);
349
+ card.appendChild(pre);
350
+ });
351
+ var buttons = document.createElement('div');
352
+ buttons.className = 'd-flex gap-2 mt-2';
353
+ var ok = document.createElement('button');
354
+ ok.type = 'button';
355
+ ok.className = 'btn btn-primary btn-sm';
356
+ ok.textContent = opts.confirmLabel || 'Confirm';
357
+ var cancel = document.createElement('button');
358
+ cancel.type = 'button';
359
+ cancel.className = 'btn btn-outline-secondary btn-sm';
360
+ cancel.textContent = 'Cancel';
361
+ buttons.appendChild(ok);
362
+ buttons.appendChild(cancel);
363
+ card.appendChild(buttons);
364
+ function finish(result) {
365
+ ok.disabled = true;
366
+ cancel.disabled = true;
367
+ card.classList.add('sb-card--resolved');
368
+ card.classList.add(result ? 'sb-card--accepted' : 'sb-card--declined');
369
+ resolve(result);
370
+ }
371
+ ok.addEventListener('click', function () { finish(true); });
372
+ cancel.addEventListener('click', function () { finish(false); });
373
+ if (els.offline) els.offline.hidden = true;
374
+ els.messages.appendChild(card);
375
+ scrollToBottom();
376
+ });
377
+ }
378
+
379
+ function appendResultCard(title, lines, actions, extra) {
380
+ var card = document.createElement('div');
381
+ card.className = 'sb-card sb-card--result rounded-3 p-2 mb-2 small';
382
+ var heading = document.createElement('div');
383
+ heading.className = 'fw-semibold mb-1';
384
+ heading.textContent = title;
385
+ card.appendChild(heading);
386
+ (lines || []).forEach(function (line) {
387
+ var p = document.createElement('div');
388
+ p.className = 'sb-card-meta';
389
+ p.textContent = line;
390
+ card.appendChild(p);
391
+ });
392
+ if (extra && extra.imageSrc) {
393
+ var img = document.createElement('img');
394
+ img.className = 'sb-image-thumb mt-1';
395
+ img.alt = extra.imageAlt || 'Generated image';
396
+ img.loading = 'lazy';
397
+ img.src = extra.imageSrc;
398
+ card.appendChild(img);
399
+ }
400
+ if (actions && actions.length) {
401
+ var row = document.createElement('div');
402
+ row.className = 'd-flex gap-2 mt-2 flex-wrap';
403
+ actions.forEach(function (a) {
404
+ if (a.href) {
405
+ var link = document.createElement('a');
406
+ link.className = 'btn btn-sm btn-outline-primary';
407
+ link.href = a.href;
408
+ link.target = '_blank';
409
+ link.rel = 'noopener';
410
+ link.textContent = a.label;
411
+ row.appendChild(link);
412
+ } else {
413
+ var btn = document.createElement('button');
414
+ btn.type = 'button';
415
+ btn.className = 'btn btn-sm btn-outline-secondary';
416
+ btn.textContent = a.label;
417
+ btn.addEventListener('click', a.onClick);
418
+ row.appendChild(btn);
419
+ }
420
+ });
421
+ card.appendChild(row);
422
+ }
423
+ els.messages.appendChild(card);
424
+ scrollToBottom();
425
+ }
426
+
427
+ // --- Transcript persistence (text only) --------------------------
428
+ function persistTranscript(role, content) {
429
+ try {
430
+ var raw = sessionStorage.getItem(TRANSCRIPT_KEY);
431
+ var list = raw ? JSON.parse(raw) : [];
432
+ list.push({ role: role, content: String(content).slice(0, 8000) });
433
+ while (list.length > MAX_MESSAGES) list.shift();
434
+ sessionStorage.setItem(TRANSCRIPT_KEY, JSON.stringify(list));
435
+ } catch (e) { /* private mode */ }
436
+ }
437
+
438
+ function restoreTranscript() {
439
+ var list;
440
+ try { list = JSON.parse(sessionStorage.getItem(TRANSCRIPT_KEY) || '[]'); } catch (e) { return; }
441
+ if (!Array.isArray(list) || !list.length) return;
442
+ list.forEach(function (turn) {
443
+ if (turn && (turn.role === 'user' || turn.role === 'assistant') && turn.content) {
444
+ appendMessage(turn.role, turn.content, { transient: true });
445
+ state.history.push({ role: turn.role, content: turn.content });
446
+ }
447
+ });
448
+ trimHistory();
449
+ appendNotice('Conversation restored from this browser session.');
450
+ }
451
+
452
+ function clearTranscript() {
453
+ state.history = [];
454
+ try { sessionStorage.removeItem(TRANSCRIPT_KEY); } catch (e) { /* ignore */ }
455
+ if (els.messages) {
456
+ Array.prototype.slice.call(els.messages.children).forEach(function (child) {
457
+ if (child.id !== 'sb-offline') child.remove();
458
+ });
459
+ }
460
+ if (els.offline) els.offline.hidden = state.connected;
461
+ if (state.connected && state.ready) appendMessage('assistant', welcomeText(), { transient: true });
462
+ }
463
+
464
+ // --- Connection --------------------------------------------------
465
+ function endpoint(path) {
466
+ return String(CONFIG.endpoint || '/api/wizard').replace(/\/$/, '') + path;
467
+ }
468
+
469
+ function trimPath(p) {
470
+ var parts = String(p || '').split(/[\\/]/).filter(Boolean);
471
+ return parts.length > 2 ? '…/' + parts.slice(-2).join('/') : p;
472
+ }
473
+
474
+ function describeSource(p) {
475
+ if (!p || !p.configured) return '';
476
+ return (p.source === 'session' ? 'session token ' : (p.source === 'env' ? '.env ' : '')) + (p.masked || '');
477
+ }
478
+
479
+ /** Reconcile provider/model/image choices with what the proxy reports. */
480
+ function applyStatus(status) {
481
+ state.status = status;
482
+ var providers = status.providers || {};
483
+ var ids = Object.keys(providers);
484
+ var pin = providerPin();
485
+ if (pin && providers[pin]) {
486
+ state.provider = pin;
487
+ } else if (!state.providerExplicit || !providers[state.provider]) {
488
+ // No explicit choice: follow the provider the proxy would answer with.
489
+ var follow = (status.auth && status.auth.provider) || ids.filter(function (id) { return providers[id].configured; })[0];
490
+ if (follow) state.provider = follow;
491
+ else if (!providers[state.provider] && ids.length) state.provider = ids[0];
492
+ }
493
+ var p = providers[state.provider] || null;
494
+ state.ready = !!(p && p.configured);
495
+ if (p) {
496
+ if (p.pinned) state.model = p.model;
497
+ else if (!state.model || (p.models || []).indexOf(state.model) === -1) state.model = p.model || p.defaultModel;
498
+ }
499
+ var image = status.image || { providers: {}, default: null };
500
+ if (state.imageProvider && !(image.providers && image.providers[state.imageProvider])) state.imageProvider = '';
501
+ if (!state.imageProvider && image.default && !prefs.imageProviderCleared) state.imageProvider = image.default;
502
+ // Preferences record explicit choices only (see selectProviderUI / the
503
+ // select handlers); an auto-derived provider must not become "remembered".
504
+ }
505
+
506
+ function setConnected(connected, status, detail) {
507
+ state.connected = connected;
508
+ if (connected && status) applyStatus(status);
509
+ else { state.status = null; state.ready = false; }
510
+ var ready = connected && state.ready;
511
+ var label = providerName();
512
+ panel.setAttribute('data-state', connected ? 'online' : 'offline');
513
+ panel.setAttribute('data-ready', ready ? 'true' : 'false');
514
+ panel.setAttribute('data-provider', state.provider || '');
515
+ panel.setAttribute('data-mode', state.mode);
516
+
517
+ if (els.badge) {
518
+ els.badge.className = 'sb-status-badge badge rounded-pill ' + (ready ? 'text-bg-success' : connected ? 'text-bg-warning' : 'text-bg-secondary');
519
+ els.badge.textContent = ready ? label + ' connected' : connected ? 'needs a token' : 'offline';
520
+ els.badge.title = ready ? 'Connected to ' + label + ' (' + providerVendor() + ') via ' + (describeSource(statusProvider()) || 'the dev proxy') : connected ? 'The dev proxy is up; add a ' + label + ' token on the Connect step' : 'Dev proxy not reachable';
521
+ }
522
+ if (els.modeBadge) { els.modeBadge.textContent = state.mode === 'open' ? 'open session' : 'guided'; els.modeBadge.hidden = !connected; }
523
+ if (els.title) els.title.textContent = (ready ? label : 'AI') + ' session';
524
+ // The status poll runs every 15 s, including in the middle of a turn — it
525
+ // must never hand the composer back while tools are still running, or a
526
+ // long action (docker compose, an image render) looks finished when it is
527
+ // not, and a second send can interleave with the first.
528
+ if (els.input) { els.input.disabled = !ready || state.busy; els.input.placeholder = ready ? (state.mode === 'open' ? 'Tell ' + label + ' what to build or change…' : 'Ask ' + label + ', or describe your site…') : 'Connect a provider to start chatting'; }
529
+ if (els.send) { els.send.disabled = !ready || state.busy; els.send.hidden = state.busy; }
530
+ if (els.stop) { els.stop.hidden = !state.busy; els.stop.disabled = !state.busy; }
531
+ if (els.disclaimer) els.disclaimer.textContent = label + ' sees every answer on this page. Actions ask for confirmation first.';
532
+ if (els.offline) els.offline.hidden = ready || els.messages.children.length > 1;
533
+ document.querySelectorAll('.sb-needs-proxy').forEach(function (btn) { btn.disabled = !connected; });
534
+ if (els.proxyHint) els.proxyHint.hidden = connected;
535
+ if (els.connectHowto) els.connectHowto.hidden = ready;
536
+ document.querySelectorAll('.sb-provider-name').forEach(function (el) { el.textContent = label; });
537
+
538
+ if (els.connectStatus) {
539
+ els.connectStatus.setAttribute('data-state', ready ? 'online' : connected ? 'partial' : 'offline');
540
+ els.connectStatus.className = 'sb-connect-status alert d-flex align-items-start gap-3 mb-3 ' + (ready ? 'alert-success' : connected ? 'alert-info' : 'alert-warning');
541
+ var title = els.connectStatus.querySelector('.sb-status-title');
542
+ var det = els.connectStatus.querySelector('.sb-status-detail');
543
+ var ep = els.connectStatus.querySelector('.sb-endpoint');
544
+ if (title) title.textContent = ready ? label + ' (' + providerVendor() + ') is connected through the local dev proxy.' : connected ? 'Dev proxy connected — ' + label + ' still needs a token.' : 'Dev proxy not reachable.';
545
+ if (ep) ep.textContent = endpoint('/status');
546
+ if (det) det.textContent = ready
547
+ ? 'Auth: ' + (describeSource(statusProvider()) || 'proxy') + ' · model: ' + (state.model || '') + ' · sites are written under ' + (status && status.scaffold && status.scaffold.root ? status.scaffold.root : 'the theme\'s parent folder') + '.'
548
+ : connected
549
+ ? 'Paste a ' + label + ' token below (it stays in the proxy\'s memory) or put it in .env and restart the proxy. Checks, writing and Docker already work.'
550
+ : (detail || 'Start it with the command below; this page re-checks every 15 seconds.') + ' Endpoint: ' + endpoint('/status');
551
+ }
552
+ if (els.targetRoot && status && status.scaffold && status.scaffold.root) {
553
+ els.targetRoot.textContent = trimPath(status.scaffold.root) + '/';
554
+ els.targetRoot.title = status.scaffold.root;
555
+ }
556
+ if (els.note) els.note.textContent = ready ? 'Model: ' + (state.model || '') : '';
557
+ renderProviderControls();
558
+ renderProjects();
559
+ }
560
+
561
+ /** The Connect step's provider cards, token form, model + image selects. */
562
+ function renderProviderControls() {
563
+ var status = state.status;
564
+ var pin = providerPin();
565
+ var connected = state.connected;
566
+ var current = statusProvider();
567
+ var cat = catalogProvider(state.provider);
568
+
569
+ document.querySelectorAll('input[name="sb_provider"]').forEach(function (radio) {
570
+ radio.checked = radio.value === state.provider;
571
+ radio.disabled = !!(pin && pin !== radio.value);
572
+ var card = document.querySelector('label[for="' + radio.id + '"]');
573
+ var badge = document.querySelector('.sb-provider-state[data-provider="' + radio.value + '"]');
574
+ var p = statusProvider(radio.value);
575
+ if (badge) {
576
+ var configured = !!(p && p.configured);
577
+ badge.className = 'badge rounded-pill sb-provider-state ' + (configured ? 'text-bg-success' : connected ? 'text-bg-warning' : 'text-bg-secondary');
578
+ badge.textContent = configured ? (p.source === 'session' ? 'session ' : '.env ') + p.masked : connected ? 'needs a token' : 'proxy offline';
579
+ badge.title = pin && pin !== radio.value ? 'The proxy pins CHAT_PROVIDER=' + pin : '';
580
+ }
581
+ if (card) card.classList.toggle('is-pinned-out', !!(pin && pin !== radio.value));
582
+ });
583
+
584
+ if (els.tokenInput) {
585
+ els.tokenInput.disabled = !connected;
586
+ els.tokenInput.placeholder = cat && cat.token_placeholder ? cat.token_placeholder : 'paste a token';
587
+ }
588
+ if (els.tokenUse) els.tokenUse.disabled = !connected;
589
+ if (els.tokenPersist) els.tokenPersist.disabled = !connected;
590
+ if (els.tokenHint) {
591
+ els.tokenHint.textContent = (cat && cat.token_hint ? cat.token_hint + ' ' : '') + 'The token goes once to the local proxy and stays in its memory — never in this page or your browser storage.';
592
+ }
593
+ if (els.credentialStatus) {
594
+ els.credentialStatus.innerHTML = '';
595
+ if (current && current.configured) {
596
+ var span = document.createElement('span');
597
+ span.className = current.source === 'session' ? 'text-success' : 'text-body-secondary';
598
+ span.textContent = current.source === 'session'
599
+ ? providerName() + ' is using the token you entered this session (' + current.masked + ').'
600
+ : providerName() + ' is using the key from the proxy\'s environment (' + current.masked + '). Enter a new one to override it for this session.';
601
+ els.credentialStatus.appendChild(span);
602
+ if (current.source === 'session') {
603
+ var forget = document.createElement('button');
604
+ forget.type = 'button';
605
+ forget.className = 'btn btn-link btn-sm p-0 ms-2 align-baseline';
606
+ forget.id = 'sb-token-forget';
607
+ forget.textContent = 'Forget it';
608
+ forget.addEventListener('click', function () { forgetCredential(state.provider); });
609
+ els.credentialStatus.appendChild(forget);
610
+ }
611
+ } else if (connected) {
612
+ var hint = document.createElement('span');
613
+ hint.className = 'text-body-secondary';
614
+ hint.textContent = 'No ' + providerName() + ' credential on the proxy yet.';
615
+ els.credentialStatus.appendChild(hint);
616
+ }
617
+ }
618
+
619
+ if (els.modelSelect) {
620
+ els.modelSelect.innerHTML = '';
621
+ var models = (current && current.models) || (cat && cat.models) || [];
622
+ if (state.model && models.indexOf(state.model) === -1) models = [state.model].concat(models);
623
+ models.forEach(function (m) {
624
+ var opt = document.createElement('option');
625
+ opt.value = m;
626
+ opt.textContent = m;
627
+ els.modelSelect.appendChild(opt);
628
+ });
629
+ if (state.model) els.modelSelect.value = state.model;
630
+ els.modelSelect.disabled = !connected || !!(current && current.pinned);
631
+ if (els.modelNote) els.modelNote.textContent = current && current.pinned ? 'Pinned by the proxy (CHAT_MODEL / XAI_CHAT_MODEL).' : 'Sent with every request; the proxy can pin one with CHAT_MODEL / XAI_CHAT_MODEL.';
632
+ }
633
+
634
+ if (els.imageSelect) {
635
+ var image = status && status.image ? status.image : { providers: {}, default: null };
636
+ Array.prototype.forEach.call(els.imageSelect.options, function (opt) {
637
+ if (!opt.value) return;
638
+ var ip = image.providers && image.providers[opt.value];
639
+ var base = opt.getAttribute('data-label') || opt.textContent.replace(/\s*\((needs a key|ready.*)\)$/, '');
640
+ opt.setAttribute('data-label', base);
641
+ opt.textContent = base + (ip && ip.configured ? ' (ready · ' + (ip.masked || '') + ')' : ' (needs a key)');
642
+ });
643
+ els.imageSelect.value = state.imageProvider || '';
644
+ els.imageSelect.disabled = !connected;
645
+ var chosen = imageProviderInfo();
646
+ if (els.imageNote) {
647
+ els.imageNote.textContent = !state.imageProvider
648
+ ? 'Off — generate_image will explain how to enable it.'
649
+ : chosen && chosen.configured
650
+ ? 'Renders with ' + chosen.label + ' (' + chosen.model + ') straight into the project\'s assets/ folder.'
651
+ : 'Needs a key: choose ' + (state.imageProvider === 'xai' ? 'Grok' : 'the matching provider') + ' above and paste its token.';
652
+ }
653
+ }
654
+ document.querySelectorAll('input[name="sb_mode"]').forEach(function (radio) { radio.checked = radio.value === state.mode; });
655
+ }
656
+
657
+ function renderProjects() {
658
+ if (!els.projectSelect) return;
659
+ var list = (state.status && state.status.projects) || [];
660
+ var keep = els.projectSelect.value;
661
+ els.projectSelect.innerHTML = '';
662
+ var first = document.createElement('option');
663
+ first.value = '';
664
+ first.textContent = list.length ? '— pick a site under ' + trimPath(state.status.scaffold.root) + ' —' : (state.connected ? 'No sites found under the target root yet' : 'Connect the dev proxy to list sites');
665
+ els.projectSelect.appendChild(first);
666
+ list.forEach(function (p) {
667
+ var opt = document.createElement('option');
668
+ opt.value = p.name;
669
+ opt.textContent = p.name + (p.compose ? ' (docker)' : '');
670
+ els.projectSelect.appendChild(opt);
671
+ });
672
+ if (keep && list.some(function (p) { return p.name === keep; })) els.projectSelect.value = keep;
673
+ els.projectSelect.disabled = !state.connected || !list.length;
674
+ if (els.projectOpen) els.projectOpen.disabled = !state.connected;
675
+ }
676
+
677
+ function welcomeText() {
678
+ var w = W();
679
+ var step = w ? w.getStep() : 'connect';
680
+ var label = providerName();
681
+ if (state.mode === 'open') {
682
+ return 'Connected to **' + label + '**. This is an open session: tell me what you want — a new site from a brief, changes to an existing one (I can open, read, edit and add files, generate images and rebuild), or questions about the theme. The steps on the left are optional.';
683
+ }
684
+ return 'Connected to **' + label + '**. I can see every answer in this wizard and update it for you. Tell me about the site you want, or pick a suggestion below — we are on the **' + step + '** step.';
685
+ }
686
+
687
+ async function checkStatus(manual) {
688
+ try {
689
+ var resp = await fetch(endpoint('/status'), { method: 'GET', cache: 'no-store' });
690
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
691
+ var data = await resp.json();
692
+ if (!data || !data.ok) throw new Error('unexpected status payload');
693
+ var wasReady = state.connected && state.ready;
694
+ var wasConnected = state.connected;
695
+ setConnected(true, data);
696
+ if (state.ready && !wasReady) {
697
+ if (!state.history.length) appendMessage('assistant', welcomeText(), { transient: true });
698
+ else appendNotice('Reconnected: ' + providerName() + ' is ready.');
699
+ renderChips();
700
+ } else if (!wasConnected) {
701
+ appendNotice('Dev proxy connected. Add a ' + providerName() + ' token on the Connect step to start the session.');
702
+ renderChips();
703
+ }
704
+ state.polls = 0;
705
+ return true;
706
+ } catch (err) {
707
+ setConnected(false, null, manual ? 'Still not reachable (' + (err.message || 'network error') + ').' : null);
708
+ return false;
709
+ }
710
+ }
711
+
712
+ function schedulePoll() {
713
+ clearTimeout(state.pollTimer);
714
+ state.pollTimer = setTimeout(async function () {
715
+ if (!state.connected) {
716
+ state.polls += 1;
717
+ if (state.polls > STATUS_POLL_MAX) return; // give up quietly; Retry still works
718
+ }
719
+ await checkStatus(false);
720
+ schedulePoll();
721
+ }, STATUS_POLL_MS);
722
+ }
723
+
724
+ // --- Credentials (bring your own key) ------------------------------
725
+ function credentialStatus(text, cls) {
726
+ if (!els.credentialStatus) return;
727
+ els.credentialStatus.innerHTML = '';
728
+ var span = document.createElement('span');
729
+ span.className = cls || 'text-body-secondary';
730
+ span.textContent = text;
731
+ els.credentialStatus.appendChild(span);
732
+ }
733
+
734
+ async function submitCredential(e) {
735
+ if (e) e.preventDefault();
736
+ if (!els.tokenInput) return;
737
+ var token = els.tokenInput.value.trim();
738
+ if (!token) { credentialStatus('Paste a token first.', 'text-warning'); return; }
739
+ if (!state.connected) { credentialStatus('Start the dev proxy first — the token has nowhere to go.', 'text-warning'); return; }
740
+ if (!isLocalEndpoint()) { credentialStatus('Tokens are only ever sent to a local dev proxy (localhost). This page is configured for ' + endpoint('') + '.', 'text-danger'); return; }
741
+ var provider = state.provider;
742
+ var persist = !!(els.tokenPersist && els.tokenPersist.checked);
743
+ els.tokenUse.disabled = true;
744
+ els.tokenInput.disabled = true;
745
+ credentialStatus('Checking the token with ' + providerVendor(provider) + '…');
746
+ try {
747
+ var r = await proxyPost('/credentials', { provider: provider, token: token, persist: persist, test: true });
748
+ els.tokenInput.value = '';
749
+ if (els.tokenPersist) els.tokenPersist.checked = false;
750
+ await checkStatus(true);
751
+ credentialStatus(providerName(provider) + ' connected (' + r.masked + ')' + (r.persisted ? ' and saved to ' + trimPath(r.persisted.file) + '.' : ' for this session.'), 'text-success');
752
+ appendNotice(providerName(provider) + ' connected' + (r.persisted ? ' · saved to .env' : ' · this session only') + '.');
753
+ } catch (err) {
754
+ credentialStatus(err.message, 'text-danger');
755
+ } finally {
756
+ els.tokenUse.disabled = !state.connected;
757
+ els.tokenInput.disabled = !state.connected;
758
+ els.tokenInput.focus();
759
+ }
760
+ }
761
+
762
+ async function forgetCredential(provider) {
763
+ try {
764
+ var resp = await fetch(endpoint('/credentials?provider=' + encodeURIComponent(provider)), { method: 'DELETE' });
765
+ if (!resp.ok) throw new Error('proxy request failed (' + resp.status + ')');
766
+ await checkStatus(true);
767
+ appendNotice('Session token for ' + providerName(provider) + ' forgotten.');
768
+ } catch (err) {
769
+ credentialStatus(err.message, 'text-danger');
770
+ }
771
+ }
772
+
773
+ function selectProviderUI(id) {
774
+ if (!id || id === state.provider) return;
775
+ state.provider = id;
776
+ state.providerExplicit = true;
777
+ var p = statusProvider(id);
778
+ state.model = p ? p.model : null;
779
+ state.ready = !!(p && p.configured);
780
+ savePrefs();
781
+ setConnected(state.connected, state.status);
782
+ var w = W();
783
+ if (w) w.applyFields({ ai_provider: id }, { silent: true, source: 'session' });
784
+ renderChips();
785
+ if (state.connected && state.ready && !state.history.length) appendMessage('assistant', welcomeText(), { transient: true });
786
+ }
787
+
788
+ function selectMode(mode) {
789
+ if (mode !== 'open' && mode !== 'guided') return;
790
+ state.mode = mode;
791
+ savePrefs();
792
+ setConnected(state.connected, state.status);
793
+ renderChips();
794
+ if (state.connected && state.ready) appendNotice(mode === 'open' ? 'Open session: any order, any request — the steps are optional.' : 'Guided mode: the assistant follows the steps with you.');
795
+ }
796
+
797
+ // --- Chips (suggested prompts) --------------------------------
798
+ function currentStepData() {
799
+ var w = W();
800
+ var id = w ? w.getStep() : null;
801
+ return (DATA.steps || []).filter(function (s) { return s.id === id; })[0] || null;
802
+ }
803
+
804
+ function fillTemplate(text) {
805
+ var w = W();
806
+ var f = w ? w.getState().fields : {};
807
+ return String(text).replace(/\{(\w+)\}/g, function (_m, key) {
808
+ if (key === 'ai') return providerName();
809
+ var v = f[key];
810
+ return v ? String(v) : '(' + key + ' not set yet)';
811
+ });
812
+ }
813
+
814
+ function renderChips() {
815
+ if (!els.chips) return;
816
+ els.chips.innerHTML = '';
817
+ if (!state.connected || !state.ready) return;
818
+ var prompts = state.mode === 'open'
819
+ ? ((DATA.open_session && DATA.open_session.prompts) || [])
820
+ : ((currentStepData() || {}).prompts || []);
821
+ prompts.forEach(function (prompt) {
822
+ var chip = document.createElement('button');
823
+ chip.type = 'button';
824
+ chip.className = 'sb-chip btn btn-sm btn-outline-secondary rounded-pill';
825
+ chip.textContent = fillTemplate(prompt).replace(/\(brief not set yet\)/, '…');
826
+ chip.addEventListener('click', function () { sendMessage(fillTemplate(prompt)); });
827
+ els.chips.appendChild(chip);
828
+ });
829
+ }
830
+
831
+ // --- System prompt -----------------------------------------------
832
+ function projectSection() {
833
+ if (!state.project) return ['## Working project: none opened yet (open_project, or write_site_files creates one from the wizard).'];
834
+ var lines = ['## Working project: ' + state.project.name + ' (' + state.project.abs + ') — ' + (state.project.exists ? 'exists' : 'not created yet')];
835
+ if (state.project.tree && state.project.tree.length) {
836
+ lines.push('Files (' + state.project.tree.length + (state.project.truncated ? '+' : '') + '):');
837
+ lines.push(state.project.tree.slice(0, 80).join(', '));
838
+ }
839
+ lines.push('Project tools act inside this folder: list_project_files, read_project_file, write_project_file, edit_project_file, delete_project_file, run_project_command, generate_image, run_compose.');
840
+ return lines;
841
+ }
842
+
843
+ function buildSystemPrompt() {
844
+ var w = W();
845
+ var fw = DATA.framework || {};
846
+ var step = currentStepData();
847
+ var label = providerName();
848
+ var lines = [];
849
+ if (state.mode === 'open') {
850
+ lines.push('You are ' + label + ' (' + providerVendor() + '), the site-building agent for the zer0-mistakes Jekyll theme, embedded in the Site Builder page and connected to the user\'s machine through a local proxy. Work like a coding assistant in a terminal: the user says what they want, you plan briefly, use tools, and report what changed. The nine-step form is a convenience, not a script — fill it (set_wizard_fields, set_site_plan) when creating a NEW site, or skip it and work directly on an EXISTING project with open_project + read/edit/write_project_file, run_compose("build") to validate, run_compose("up") to serve, generate_image for artwork. Ask only when a decision is genuinely the user\'s.');
851
+ } else {
852
+ lines.push('You are ' + label + ' (' + providerVendor() + '), the Site Builder guide for the zer0-mistakes Jekyll theme, embedded in a nine-step wizard the user is filling in right now. Your job is to get them from nothing to a complete, personalised, running site — asking short focused questions, proposing concrete values, and applying them with tools so the forms and the generated files update live.');
853
+ }
854
+ lines.push('');
855
+ lines.push('## Framework brief');
856
+ lines.push(fw.summary || 'Docker-first Jekyll theme on Bootstrap 5.');
857
+ if (fw.key_config) { lines.push('Key config:'); fw.key_config.forEach(function (k) { lines.push('- ' + k); }); }
858
+ if (fw.customisation) { lines.push('Customisation:'); fw.customisation.forEach(function (k) { lines.push('- ' + k); }); }
859
+ if (fw.guardrails) { lines.push('Guardrails:'); fw.guardrails.forEach(function (k) { lines.push('- ' + k); }); }
860
+ lines.push('');
861
+ lines.push('## Wizard steps (in order): ' + (DATA.steps || []).map(function (s) { return s.id + ' (' + s.label + ')'; }).join(' → '));
862
+ if (step) lines.push('Current step: ' + step.id + ' — ' + step.summary);
863
+ lines.push('Session mode: ' + state.mode + '. Image generation: ' + (state.imageProvider && imageProviderInfo() && imageProviderInfo().configured ? imageProviderInfo().label + ' (' + imageProviderInfo().model + ')' : 'not configured — tell the user to add an xAI or OpenAI key on the Connect step if they ask for images') + '.');
864
+ lines.push('');
865
+ lines.push('## Fields you may set with set_wizard_fields');
866
+ (w ? w.schema() : []).forEach(function (f) {
867
+ lines.push('- ' + f.key + ' · ' + f.type + (f.options ? ' · options: ' + f.options.join('|') : '') + ' · "' + f.label + '"');
868
+ });
869
+ lines.push('');
870
+ lines.push('## Current wizard state');
871
+ lines.push(w ? w.getSummary() : '(wizard not ready)');
872
+ lines.push('');
873
+ lines.push('## Generated files: ' + (w ? w.getFiles().map(function (f) { return f.path; }).join(', ') : ''));
874
+ lines.push('');
875
+ lines.push.apply(lines, projectSection());
876
+ lines.push('');
877
+ lines.push('## Site plan (set_site_plan) — the schema you must follow');
878
+ lines.push('The plan is validated against this JSON-Schema subset before anything changes; invalid plans are rejected with the errors, so fix and resend.');
879
+ lines.push(JSON.stringify(w ? w.planSchema() : {}));
880
+ var cat = function (name, fmt) { return (DATA[name] || []).map(fmt).join('; '); };
881
+ lines.push('Catalogs: landing_templates = ' + cat('landing_templates', function (t) { return t.id + ' (' + t.description + ')'; }));
882
+ lines.push('section_types = ' + cat('section_types', function (s) { return s.id + ' → ' + s.fields; }));
883
+ lines.push('navigation_styles = ' + cat('navigation_styles', function (n) { return n.id + ' (' + n.description + ')'; }) + '. sidebar_modes = ' + cat('sidebar_modes', function (m) { return m.id + ' (' + m.description + ')'; }));
884
+ lines.push('palettes = ' + cat('palettes', function (p) { return p.id + (p.primary ? ' ' + p.primary + '/' + p.secondary + '/' + p.accent : ''); }) + '; or preset "custom" with primary/secondary/accent hex.');
885
+ lines.push('font_pairings = ' + cat('font_pairings', function (f) { return f.id + ' (' + f.description + ')'; }) + '. radius_options = ' + cat('radius_options', function (r) { return r.id; }));
886
+ lines.push('How the plan becomes files: landing → index.md + _data/landing.yml; navigation → _data/navigation/main.yml (grouped = dropdowns of planned pages) and _data/navigation/docs.yml when sidebar=docs; theme → assets/css/user-overrides.css (+ _includes/custom/head.html for web fonts); pages → one Markdown file each (posts get a date). Bodies are Markdown WITHOUT front matter.');
887
+ lines.push('A good plan for a new site: one template that fits the type, a hero with a real headline (not the site title repeated), 3–5 sections with specific copy, 4–6 pages spread across the enabled collections with 150–300 word bodies, a palette/font pairing/radius that match the tone. Use the theme skin for the base and a palette only when it clearly helps.');
888
+ lines.push('Current plan:\n' + (w ? JSON.stringify(w.getPlan()).slice(0, 4000) : ''));
889
+ lines.push('');
890
+ lines.push('## How to work');
891
+ lines.push('- Be concise. One or two short paragraphs, or a short list. Never restate the whole state.');
892
+ if (state.mode === 'open') {
893
+ lines.push('- New site: draft identity/structure/appearance/voice from the brief, apply with set_wizard_fields + set_site_plan in as few calls as possible, resolve_target, write_site_files, run_compose("up"), check_site_live. Then keep going: the user can ask for anything else.');
894
+ lines.push('- Existing site: open_project first (list_projects shows candidates), read what matters (read_project_file "_config.yml", the file you are about to change), make surgical changes with edit_project_file, add files with write_project_file, then run_compose("build") — read the tail and fix errors before declaring done. Use run_project_command("git-status"/"git-diff") to show what changed.');
895
+ lines.push('- Images: generate_image saves into assets/ and returns the path; then wire it in (front matter preview:, landing hero image, or an <img>) with an edit. Say which provider rendered it.');
896
+ lines.push('- Chain tools freely (up to ' + TOOL_ROUNDS.open + ' rounds per turn) but stop and summarise when a step needs the user\'s judgement or a confirmation was declined.');
897
+ } else {
898
+ lines.push('- When the user describes their site, draft title/subtitle/tagline/description (120–160 chars), pick a site_type, collections, skin and tone that fit, and APPLY them with set_wizard_fields in one call. The page shows a confirmation card; you do not need to ask "shall I?" once values are sensible.');
899
+ lines.push('- For the Voice step, write welcome_title, welcome_body and about_body as real Markdown in the chosen tone and audience, then apply them. Do not put YAML front matter in bodies — the generator adds it.');
900
+ lines.push('- For the Structure and Appearance steps, design the SITE PLAN with set_site_plan (schema above): landing template + hero + sections with real copy, navigation style/sidebar, palette/fonts/radius, and 4–6 example pages with Markdown bodies. One call with the whole plan beats many small ones.');
901
+ lines.push('- Prerequisites: run_prerequisite_check("all") then explain only what failed, with the install command for the user\'s OS.');
902
+ lines.push('- Build: review get_generated_file output for mistakes first; resolve_target before write_site_files; after writing, offer run_compose("up"); read run_compose("logs") when something fails and explain the fix. Then check_site_live. To change a written site afterwards, use the project tools (edit_project_file, write_project_file) and run_compose("build").');
903
+ }
904
+ lines.push('- Ground answers about the theme in real source: use read_theme_file / list_theme_dir / search_theme_docs rather than guessing. Cite the path you read.');
905
+ lines.push('- Never ask for or echo credentials. Keys go in .env or the Connect step; say so.');
906
+ lines.push('- After a tool succeeds, one sentence on what changed and the natural next step (use go_to_step when moving on in guided mode).');
907
+ lines.push('- Today is ' + new Date().toISOString().slice(0, 10) + '. Theme version: ' + (CONFIG.themeVersion || 'unknown') + '.');
908
+ return lines.join('\n');
909
+ }
910
+
911
+ // --- Tools -------------------------------------------------------
912
+ function buildTools() {
913
+ var obj = function (props, required) { return { type: 'object', properties: props, required: required || [] }; };
914
+ var commandIds = (state.status && state.status.projectCommands) || ['git-status', 'git-diff', 'git-log', 'git-init'];
915
+ var composeActions = ['up', 'ps', 'logs', 'down', 'restart', 'config', 'build'];
916
+ var tools = [
917
+ { name: 'get_wizard_state', description: 'Return the full wizard state as JSON: every field, chosen collections, navigation, prerequisite check results, target folder and the list of generated files.', input_schema: obj({}) },
918
+ { name: 'set_wizard_fields', description: 'Set one or more wizard fields (see the schema in the system prompt). Keys are field keys; `collections` takes an array of ids; `navigation` takes an array of {title,url,icon}. The user confirms on an inline card before anything changes. Prefer one call with several fields over many calls.', input_schema: obj({ fields: { type: 'object', description: 'Map of field key → value.' }, note: { type: 'string', description: 'One line shown on the confirmation card explaining the change.' } }, ['fields']) },
919
+ { name: 'get_site_plan', description: 'Return the current site plan (landing, navigation, theme overrides, planned pages) as JSON, plus the resolved landing page (template defaults filled in).', input_schema: obj({}) },
920
+ { name: 'set_site_plan', description: 'Apply a partial or complete site plan: landing {template, hero, sections}, navigation {style, sidebar, items}, theme {palette, fonts, radius}, pages [{collection, slug, title, description, date, categories, tags, body}]. Validated against the schema in the system prompt; errors come back for you to fix. `landing.hero` merges field by field, so a later call may add just an image; `landing.sections`, `navigation.items` and `pages` REPLACE (pages merge when merge_pages is true). The user confirms on a card before anything changes.', input_schema: obj({ plan: { type: 'object', description: 'The plan (partial allowed). Bodies are Markdown without front matter.' }, merge_pages: { type: 'boolean', description: 'true = merge pages by collection+slug instead of replacing the list.' }, note: { type: 'string', description: 'One line shown on the confirmation card.' } }, ['plan']) },
921
+ { name: 'go_to_step', description: 'Move the wizard to a step by id (connect, prereqs, identity, urls, structure, appearance, voice, integrations, build).', input_schema: obj({ step: { type: 'string' } }, ['step']) },
922
+ { name: 'get_generated_file', description: 'Return the current content of one generated project file (e.g. "_config.yml", "pages/_about/index.md"). Omit path to list all files with sizes.', input_schema: obj({ path: { type: 'string' } }) },
923
+ { name: 'set_file_override', description: 'Replace the content of ONE generated file with your own version (e.g. a richer about page or a tweaked _config.yml). Pass the COMPLETE new content. The user confirms first. Pass content null to remove the override and return to the generated version.', input_schema: obj({ path: { type: 'string' }, content: { type: ['string', 'null'] }, summary: { type: 'string', description: 'One line: what changed.' } }, ['path', 'summary']) },
924
+ { name: 'run_prerequisite_check', description: 'Run an allow-listed read-only check on the user\'s machine through the local dev proxy. id is one of the prerequisite check ids, or "all". Results also update the checklist on the Prerequisites step.', input_schema: obj({ id: { type: 'string' } }, ['id']) },
925
+ { name: 'read_theme_file', description: 'Read a text source file from the zer0-mistakes theme checkout (layouts, includes, sass, docs, config, scripts). Use to answer framework questions from real source. Secrets and vendored/build directories are not readable.', input_schema: obj({ path: { type: 'string', description: 'Repo-relative path, e.g. "_layouts/home.html" or "_config.yml".' } }, ['path']) },
926
+ { name: 'list_theme_dir', description: 'List the entries of a directory in the theme checkout, e.g. "_layouts", "_includes/components", "_sass/theme", "pages/_docs/features".', input_schema: obj({ path: { type: 'string' } }) },
927
+ { name: 'search_theme_docs', description: 'Full-text search of this site\'s published documentation (search.json). Returns the best matching pages with URL and snippet.', input_schema: obj({ query: { type: 'string' }, limit: { type: 'integer' } }, ['query']) },
928
+ { name: 'resolve_target', description: 'Check where the project folder would be created and whether it exists / is empty. Uses the wizard\'s target field when omitted.', input_schema: obj({ target: { type: 'string' } }) },
929
+ { name: 'write_site_files', description: 'Write EVERY generated file into the target project folder through the dev proxy. Existing files are skipped unless overwrite is true. The user confirms on a card listing the files first.', input_schema: obj({ target: { type: 'string' }, overwrite: { type: 'boolean' } }) },
930
+ { name: 'list_projects', description: 'List existing sites under the proxy\'s target root — folders with a _config.yml or docker-compose.yml: sites this builder wrote earlier, or any Jekyll site placed there. Use before open_project when the user wants to modify a site.', input_schema: obj({}) },
931
+ { name: 'open_project', description: 'Open a site folder under the target root as the WORKING PROJECT (existing to modify, or new to create): sets the wizard target, resolves it, and returns a tree of its files. Every project tool acts inside it afterwards.', input_schema: obj({ target: { type: 'string', description: 'Folder name under the target root (as listed by list_projects) or an absolute path inside it.' } }, ['target']) },
932
+ { name: 'list_project_files', description: 'Depth-limited tree of the working project. Optional path narrows it (e.g. "pages" or "_data").', input_schema: obj({ path: { type: 'string' }, depth: { type: 'integer' } }) },
933
+ { name: 'read_project_file', description: 'Read a text file from the working project, e.g. "_config.yml" or "pages/_posts/2026-01-01-welcome.md".', input_schema: obj({ path: { type: 'string' } }, ['path']) },
934
+ { name: 'write_project_file', description: 'Create or replace ONE text file in the working project with the COMPLETE content (Markdown, YAML, HTML/Liquid, CSS/SCSS, JS, JSON, SVG). Existing files need overwrite:true. The user confirms on a card first. For small changes prefer edit_project_file.', input_schema: obj({ path: { type: 'string' }, content: { type: 'string' }, overwrite: { type: 'boolean' }, summary: { type: 'string', description: 'One line: what this file is / what changed.' } }, ['path', 'content', 'summary']) },
935
+ { name: 'edit_project_file', description: 'Replace an exact snippet inside a working-project file, like a careful editor: `find` must occur exactly once (pass all:true to replace every occurrence). Whitespace-sensitive. Shows a before/after card for confirmation.', input_schema: obj({ path: { type: 'string' }, find: { type: 'string' }, replace: { type: 'string' }, all: { type: 'boolean' }, summary: { type: 'string' } }, ['path', 'find', 'replace', 'summary']) },
936
+ { name: 'delete_project_file', description: 'Delete one regular file from the working project after confirmation. Never directories.', input_schema: obj({ path: { type: 'string' }, summary: { type: 'string' } }, ['path', 'summary']) },
937
+ { name: 'run_project_command', description: 'Run an allow-listed command inside the working project and return its output: ' + commandIds.join(', ') + '.', input_schema: obj({ id: { type: 'string', enum: commandIds } }, ['id']) },
938
+ { name: 'generate_image', description: 'Render an image with the configured image provider (Grok Imagine on xAI, or OpenAI Images) and save it into the working project under assets/ — path must end in .png, .jpg or .webp, e.g. "assets/images/hero.png". Costs money, so the user confirms first. Returns the saved path; reference it as /assets/... (front matter preview:, landing hero image, or an <img>).', input_schema: obj({ prompt: { type: 'string', description: 'A specific art brief: subject, style, palette, mood, no text in the image.' }, path: { type: 'string' }, aspect_ratio: { type: 'string', enum: IMAGE_ASPECTS }, quality: { type: 'string', enum: ['auto', 'low', 'medium', 'high'] }, provider: { type: 'string', enum: ['xai', 'openai'] }, overwrite: { type: 'boolean' } }, ['prompt', 'path']) },
939
+ { name: 'run_compose', description: 'Run docker compose in the working project: action is "up" (detached, builds), "ps", "logs", "down", "restart", "config" or "build" (runs `jekyll build` inside the running container to validate the site). Use "restart" after ADDING a page or post to a running site — Jekyll\'s watcher only picks up files that existed when it started. Output streams into the Build step terminal and the tail is returned to you. up/down ask the user to confirm.', input_schema: obj({ action: { type: 'string', enum: composeActions }, target: { type: 'string' } }, ['action']) },
940
+ { name: 'check_site_live', description: 'Probe whether the site answers on its dev port (default from the wizard). Returns reachable true/false.', input_schema: obj({ url: { type: 'string' } }) }
941
+ ];
942
+ return tools;
943
+ }
944
+
945
+ function toolResult(id, content, isError) {
946
+ var text = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
947
+ if (text.length > MAX_TOOL_TEXT) text = text.slice(0, MAX_TOOL_TEXT) + '\n[... truncated ...]';
948
+ var r = { type: 'tool_result', tool_use_id: id, content: text };
949
+ if (isError) r.is_error = true;
950
+ return r;
951
+ }
952
+
953
+ function declined(id) {
954
+ return toolResult(id, 'The user declined this action on the confirmation card. Do not retry unless they ask again.');
955
+ }
956
+
957
+ async function proxyJson(path, opts) {
958
+ var resp = await fetch(endpoint(path), Object.assign({ cache: 'no-store' }, opts || {}));
959
+ var data = await resp.json().catch(function () { return {}; });
960
+ if (!resp.ok) throw new Error((data.error && data.error.message) || ('proxy request failed (' + resp.status + ')'));
961
+ return data;
962
+ }
963
+
964
+ async function proxyPost(path, body) {
965
+ return proxyJson(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) });
966
+ }
967
+
968
+ // --- Tool executors -----------------------------------------------
969
+ async function execSetFields(block) {
970
+ var w = W();
971
+ if (!w) return toolResult(block.id, 'Wizard not ready.', true);
972
+ var fields = block.input && block.input.fields;
973
+ if (!fields || typeof fields !== 'object' || !Object.keys(fields).length) return toolResult(block.id, 'fields must be a non-empty object.', true);
974
+ var list = Object.keys(fields).map(function (k) {
975
+ var v = fields[k];
976
+ var shown = Array.isArray(v) ? (typeof v[0] === 'object' ? v.length + ' items' : v.join(', ')) : String(v);
977
+ return k + ' → ' + (shown.length > 90 ? shown.slice(0, 90) + '…' : shown);
978
+ });
979
+ var ok = await requestConfirmation({
980
+ heading: 'Apply ' + list.length + ' change' + (list.length === 1 ? '' : 's') + ' to the wizard?',
981
+ fields: [{ label: 'Why', value: block.input.note || '' }],
982
+ list: list,
983
+ confirmLabel: 'Apply'
984
+ });
985
+ if (!ok) return declined(block.id);
986
+ var result = w.applyFields(fields, { source: 'assistant' });
987
+ w.toast(providerName() + ' updated ' + result.applied.length + ' field' + (result.applied.length === 1 ? '' : 's'));
988
+ return toolResult(block.id, { applied: result.applied, ignored: result.ignored, state: w.getSummary() });
989
+ }
990
+
991
+ async function execSetSitePlan(block) {
992
+ var w = W();
993
+ if (!w) return toolResult(block.id, 'Wizard not ready.', true);
994
+ var plan = block.input && block.input.plan;
995
+ if (!plan || typeof plan !== 'object') return toolResult(block.id, 'plan must be an object.', true);
996
+ var errors = w.validatePlan(plan);
997
+ if (errors.length) return toolResult(block.id, 'Plan rejected by the schema:\n- ' + errors.slice(0, 20).join('\n- ') + '\nFix these and call set_site_plan again.', true);
998
+ var list = [];
999
+ if (plan.landing) list.push('landing: ' + [plan.landing.template && 'template ' + plan.landing.template, plan.landing.hero && 'hero “' + (plan.landing.hero.headline || '') + '”', plan.landing.sections && plan.landing.sections.length + ' sections (' + plan.landing.sections.map(function (s) { return s.type; }).join(', ') + ')'].filter(Boolean).join(', '));
1000
+ if (plan.navigation) list.push('navigation: ' + Object.keys(plan.navigation).map(function (k) { return k + '=' + (Array.isArray(plan.navigation[k]) ? plan.navigation[k].length + ' items' : plan.navigation[k]); }).join(', '));
1001
+ if (plan.theme) list.push('theme: ' + JSON.stringify(plan.theme));
1002
+ if (plan.pages) list.push('pages (' + (block.input.merge_pages ? 'merge' : 'replace') + '): ' + plan.pages.map(function (p) { return p.collection + '/' + (p.slug || '?') + (p.body ? '' : ' [no body]'); }).join(', '));
1003
+ var ok = await requestConfirmation({
1004
+ heading: 'Apply this site plan?',
1005
+ fields: [{ label: 'Why', value: block.input.note || '' }],
1006
+ list: list,
1007
+ confirmLabel: 'Apply plan'
1008
+ });
1009
+ if (!ok) return declined(block.id);
1010
+ var result = w.setPlan(plan, { source: 'assistant', mergePages: block.input.merge_pages === true });
1011
+ if (!result.ok) return toolResult(block.id, 'Plan rejected: ' + result.errors.join('; '), true);
1012
+ w.toast(providerName() + ' updated the site plan');
1013
+ if (plan.landing) w.setActiveFile('_data/landing.yml');
1014
+ else if (plan.pages && plan.pages.length) { var first = w.getFiles().filter(function (f) { return f.description && f.description.indexOf('Planned example page') === 0; })[0]; if (first) w.setActiveFile(first.path); }
1015
+ return toolResult(block.id, { applied: result.applied, files: w.getFiles().map(function (f) { return f.path; }), plan: w.getPlan() });
1016
+ }
1017
+
1018
+ async function execSetOverride(block) {
1019
+ var w = W();
1020
+ if (!w) return toolResult(block.id, 'Wizard not ready.', true);
1021
+ var path = block.input && block.input.path;
1022
+ var file = path ? w.getFile(path) : null;
1023
+ if (!file) return toolResult(block.id, 'Unknown generated file: ' + path + '. Use get_generated_file with no path to list them.', true);
1024
+ var content = block.input.content;
1025
+ var removing = content === null || content === undefined;
1026
+ var ok = await requestConfirmation({
1027
+ heading: removing ? 'Discard the custom version of ' + path + '?' : 'Replace the generated ' + path + '?',
1028
+ fields: [
1029
+ { label: 'Change', value: block.input.summary || '' },
1030
+ { label: 'New length', value: removing ? '' : String(content).length + ' characters' }
1031
+ ],
1032
+ confirmLabel: removing ? 'Discard override' : 'Replace file'
1033
+ });
1034
+ if (!ok) return declined(block.id);
1035
+ w.setOverride(path, removing ? null : String(content));
1036
+ w.setActiveFile(path);
1037
+ return toolResult(block.id, (removing ? 'Override removed for ' : 'Replaced ') + path + '. The preview now shows it.');
1038
+ }
1039
+
1040
+ async function execRunCheck(block) {
1041
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline; checks are unavailable. Ask the user to tick items manually.', true);
1042
+ var id = String((block.input && block.input.id) || 'all');
1043
+ var ids = id === 'all'
1044
+ ? (DATA.prerequisites || []).map(function (p) { return p.check; }).filter(Boolean)
1045
+ : [id];
1046
+ var results = [];
1047
+ for (var i = 0; i < ids.length; i += 1) {
1048
+ var r = await runCheck(ids[i]);
1049
+ results.push(r);
1050
+ }
1051
+ return toolResult(block.id, results.map(function (r) {
1052
+ return r.id + ': ' + (r.ok ? 'ok' + (r.version ? ' v' + r.version : '') : 'FAIL — ' + (r.error || r.output || 'unknown'));
1053
+ }).join('\n'));
1054
+ }
1055
+
1056
+ async function runCheck(checkId) {
1057
+ var w = W();
1058
+ var r;
1059
+ try { r = await proxyPost('/check', { id: checkId }); } catch (e) { r = { id: checkId, ok: false, error: e.message }; }
1060
+ if (w) {
1061
+ // Map a check id back to every prerequisite that uses it.
1062
+ (DATA.prerequisites || []).forEach(function (p) {
1063
+ if (p.check === checkId) w.setCheckResult(p.id, { ok: !!r.ok, installed: r.installed, version: r.version || '', output: r.output || r.error || '', checkedAt: Date.now() });
1064
+ });
1065
+ }
1066
+ return r;
1067
+ }
1068
+
1069
+ async function execReadTheme(block) {
1070
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline; theme source is unavailable.', true);
1071
+ try {
1072
+ var r = await proxyJson('/file?path=' + encodeURIComponent((block.input && block.input.path) || ''));
1073
+ return toolResult(block.id, 'Source of ' + r.path + (r.truncated ? ' (truncated)' : '') + ':\n\n' + r.content);
1074
+ } catch (e) { return toolResult(block.id, 'Could not read: ' + e.message, true); }
1075
+ }
1076
+
1077
+ async function execListTheme(block) {
1078
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline.', true);
1079
+ try {
1080
+ var r = await proxyJson('/ls?path=' + encodeURIComponent((block.input && block.input.path) || '.'));
1081
+ return toolResult(block.id, r.path + ':\n' + r.entries.join('\n'));
1082
+ } catch (e) { return toolResult(block.id, 'Could not list: ' + e.message, true); }
1083
+ }
1084
+
1085
+ var searchIndexCache = null;
1086
+ async function execSearchDocs(block) {
1087
+ var query = String((block.input && block.input.query) || '').toLowerCase().trim();
1088
+ if (!query) return toolResult(block.id, 'query is required.', true);
1089
+ try {
1090
+ if (!searchIndexCache) {
1091
+ var resp = await fetch(CONFIG.searchIndex || '/search.json', { cache: 'force-cache' });
1092
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
1093
+ searchIndexCache = await resp.json();
1094
+ }
1095
+ } catch (e) { return toolResult(block.id, 'Search index unavailable: ' + e.message, true); }
1096
+ var terms = query.split(/\s+/).filter(Boolean);
1097
+ var scored = (searchIndexCache || []).map(function (item) {
1098
+ var hay = ((item.title || '') + ' ' + (item.description || '') + ' ' + (item.content || '')).toLowerCase();
1099
+ var score = 0;
1100
+ terms.forEach(function (t) {
1101
+ if ((item.title || '').toLowerCase().indexOf(t) !== -1) score += 5;
1102
+ if ((item.url || '').toLowerCase().indexOf(t) !== -1) score += 2;
1103
+ var idx = hay.indexOf(t);
1104
+ while (idx !== -1 && score < 60) { score += 1; idx = hay.indexOf(t, idx + t.length); }
1105
+ });
1106
+ return { item: item, score: score };
1107
+ }).filter(function (x) { return x.score > 0; }).sort(function (a, b) { return b.score - a.score; });
1108
+ var limit = Math.min(Number(block.input.limit) || 5, 10);
1109
+ var out = scored.slice(0, limit).map(function (x) {
1110
+ var content = String(x.item.content || '');
1111
+ var pos = content.toLowerCase().indexOf(terms[0]);
1112
+ var snippet = content.slice(Math.max(0, pos - 120), pos + 220).replace(/\s+/g, ' ');
1113
+ return '- ' + x.item.title + ' — ' + x.item.url + (x.item.description ? '\n ' + x.item.description : '') + '\n …' + snippet + '…';
1114
+ });
1115
+ return toolResult(block.id, out.length ? out.join('\n') : 'No documentation pages matched "' + query + '".');
1116
+ }
1117
+
1118
+ function execGetFile(block) {
1119
+ var w = W();
1120
+ if (!w) return toolResult(block.id, 'Wizard not ready.', true);
1121
+ var path = block.input && block.input.path;
1122
+ if (!path) {
1123
+ return toolResult(block.id, w.getFiles().map(function (f) { return f.path + ' (' + f.content.length + ' chars' + (f.overridden ? ', overridden' : '') + ') — ' + f.description; }).join('\n'));
1124
+ }
1125
+ var file = w.getFile(path);
1126
+ if (!file) return toolResult(block.id, 'No generated file at ' + path, true);
1127
+ w.setActiveFile(path);
1128
+ return toolResult(block.id, file.path + ':\n\n' + file.content);
1129
+ }
1130
+
1131
+ function targetValue(input) {
1132
+ var w = W();
1133
+ return String((input && input.target) || (state.project && state.project.name) || (els.targetInput && els.targetInput.value) || (w ? w.getState().target : '') || '').trim();
1134
+ }
1135
+
1136
+ async function resolveTarget(target) {
1137
+ var r = await proxyPost('/target', { target: target });
1138
+ if (els.targetResolved) {
1139
+ els.targetResolved.textContent = r.abs + (r.exists ? (r.empty ? ' — exists, empty.' : ' — exists and is NOT empty; open it as a project to modify it, or enable overwrite.') : ' — will be created.');
1140
+ els.targetResolved.className = 'form-text ' + (r.exists && !r.empty ? 'text-warning' : 'text-success');
1141
+ }
1142
+ return r;
1143
+ }
1144
+
1145
+ async function execResolveTarget(block) {
1146
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline.', true);
1147
+ var target = targetValue(block.input);
1148
+ if (!target) return toolResult(block.id, 'No target folder set. Ask the user for a folder name (the repository name is a good default) and set the `target` field.', true);
1149
+ try {
1150
+ var r = await resolveTarget(target);
1151
+ return toolResult(block.id, r);
1152
+ } catch (e) {
1153
+ if (els.targetResolved) { els.targetResolved.textContent = e.message; els.targetResolved.className = 'form-text text-danger'; }
1154
+ return toolResult(block.id, 'Target rejected: ' + e.message, true);
1155
+ }
1156
+ }
1157
+
1158
+ async function writeProject(target, overwrite, viaModel) {
1159
+ var w = W();
1160
+ var files = w.getFiles();
1161
+ var ok = await requestConfirmation({
1162
+ heading: 'Write ' + files.length + ' files into ' + target + '?',
1163
+ fields: [
1164
+ { label: 'Folder', value: target },
1165
+ { label: 'Overwrite existing', value: overwrite ? 'yes' : 'no' }
1166
+ ],
1167
+ list: files.map(function (f) { return f.path; }),
1168
+ confirmLabel: 'Write project'
1169
+ });
1170
+ if (!ok) return { declined: true };
1171
+ var r = await proxyPost('/scaffold', { target: target, overwrite: !!overwrite, files: files.map(function (f) { return { path: f.path, content: f.content }; }) });
1172
+ var lines = [r.written.length + ' written, ' + r.skipped.length + ' skipped → ' + r.target];
1173
+ r.skipped.slice(0, 8).forEach(function (s) { lines.push('skipped ' + s.path + ': ' + s.reason); });
1174
+ appendResultCard('Project written', lines, [
1175
+ { label: 'docker compose up', onClick: function () { composeAction('up', target); } }
1176
+ ]);
1177
+ w.clearDraft();
1178
+ w.toast('Project written to ' + trimPath(r.target));
1179
+ await openProject(target, { quiet: true });
1180
+ if (!viaModel) appendNotice('Tip: press "docker compose up" to build and serve it.');
1181
+ return r;
1182
+ }
1183
+
1184
+ async function execWriteFiles(block) {
1185
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline; the user can download the bundle instead.', true);
1186
+ var target = targetValue(block.input);
1187
+ if (!target) return toolResult(block.id, 'No target folder set — set the `target` field first.', true);
1188
+ var overwrite = block.input && block.input.overwrite === true;
1189
+ try {
1190
+ var r = await writeProject(target, overwrite, true);
1191
+ if (r.declined) return declined(block.id);
1192
+ return toolResult(block.id, { target: r.target, written: r.written.map(function (x) { return x.path; }), skipped: r.skipped });
1193
+ } catch (e) { return toolResult(block.id, 'Write failed: ' + e.message, true); }
1194
+ }
1195
+
1196
+ // --- Project session executors ------------------------------------
1197
+ function projectQuery(path, extra) {
1198
+ var q = '?target=' + encodeURIComponent(targetValue()) + '&path=' + encodeURIComponent(path || '.');
1199
+ if (extra) q += extra;
1200
+ return q;
1201
+ }
1202
+
1203
+ async function refreshProjectTree(target) {
1204
+ try {
1205
+ var r = await proxyJson('/project/tree' + '?target=' + encodeURIComponent(target) + '&path=.&depth=3');
1206
+ if (state.project && state.project.name === target) { state.project.tree = r.entries; state.project.truncated = r.truncated; }
1207
+ return r;
1208
+ } catch (e) { return null; }
1209
+ }
1210
+
1211
+ async function openProject(target, opts) {
1212
+ opts = opts || {};
1213
+ var w = W();
1214
+ var r = await resolveTarget(target);
1215
+ var name = target;
1216
+ if (/^\//.test(target) && r.root && target.indexOf(r.root) === 0) name = target.slice(r.root.length).replace(/^\/+/, '');
1217
+ state.project = { name: name, abs: r.abs, exists: r.exists, tree: [], truncated: false };
1218
+ if (w) w.applyFields({ target: name }, { silent: true, source: 'session' });
1219
+ if (els.projectSelect && Array.prototype.some.call(els.projectSelect.options, function (o) { return o.value === name; })) els.projectSelect.value = name;
1220
+ var tree = r.exists ? await refreshProjectTree(name) : null;
1221
+ if (els.composeBuild) els.composeBuild.disabled = !state.connected;
1222
+ if (!opts.quiet) {
1223
+ appendResultCard('Working project: ' + name, [r.abs + (r.exists ? (r.empty ? ' — empty folder' : ' — ' + ((tree && tree.entries.length) || 0) + (tree && tree.truncated ? '+' : '') + ' files') : ' — not created yet')], r.exists ? [
1224
+ { label: 'jekyll build', onClick: function () { composeAction('build', name); } },
1225
+ { label: 'git status', onClick: async function () { try { var s = await proxyPost('/project/command', { target: name, id: 'git-status' }); terminalWrite('$ git status --short --branch (' + name + ')\n' + (s.output || '(clean)') + '\n', true); } catch (e) { terminalWrite('[error] ' + e.message + '\n'); } } }
1226
+ ] : []);
1227
+ }
1228
+ return { name: name, abs: r.abs, exists: r.exists, empty: r.empty, tree: tree ? tree.entries : [], truncated: !!(tree && tree.truncated) };
1229
+ }
1230
+
1231
+ async function execListProjects(block) {
1232
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline.', true);
1233
+ try {
1234
+ var r = await proxyJson('/projects');
1235
+ if (state.status) state.status.projects = r.projects;
1236
+ renderProjects();
1237
+ return toolResult(block.id, r.projects.length ? { root: r.root, projects: r.projects.map(function (p) { return { name: p.name, site: p.site, compose: p.compose }; }) } : 'No sites under ' + r.root + ' yet. write_site_files creates one.');
1238
+ } catch (e) { return toolResult(block.id, 'Could not list projects: ' + e.message, true); }
1239
+ }
1240
+
1241
+ async function execOpenProject(block) {
1242
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline.', true);
1243
+ var target = String((block.input && block.input.target) || '').trim();
1244
+ if (!target) return toolResult(block.id, 'target is required (a folder name under the target root).', true);
1245
+ try {
1246
+ var p = await openProject(target);
1247
+ return toolResult(block.id, { project: p.name, abs: p.abs, exists: p.exists, empty: p.empty, files: p.tree.slice(0, 200), truncated: p.truncated });
1248
+ } catch (e) { return toolResult(block.id, 'Could not open ' + target + ': ' + e.message, true); }
1249
+ }
1250
+
1251
+ function needProject(block) {
1252
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline.', true);
1253
+ if (!targetValue()) return toolResult(block.id, 'No working project. Call open_project (or set the target field) first.', true);
1254
+ return null;
1255
+ }
1256
+
1257
+ async function execListProjectFiles(block) {
1258
+ var no = needProject(block); if (no) return no;
1259
+ try {
1260
+ var depth = Number(block.input && block.input.depth) || 3;
1261
+ var r = await proxyJson('/project/tree' + projectQuery(block.input && block.input.path, '&depth=' + depth));
1262
+ return toolResult(block.id, r.path + ' (' + r.entries.length + (r.truncated ? '+' : '') + ' entries):\n' + r.entries.join('\n'));
1263
+ } catch (e) { return toolResult(block.id, 'Could not list: ' + e.message, true); }
1264
+ }
1265
+
1266
+ async function execReadProjectFile(block) {
1267
+ var no = needProject(block); if (no) return no;
1268
+ try {
1269
+ var r = await proxyJson('/project/file' + projectQuery(block.input && block.input.path));
1270
+ return toolResult(block.id, r.path + (r.truncated ? ' (truncated)' : '') + ':\n\n' + r.content);
1271
+ } catch (e) { return toolResult(block.id, 'Could not read: ' + e.message, true); }
1272
+ }
1273
+
1274
+ async function execWriteProjectFile(block) {
1275
+ var no = needProject(block); if (no) return no;
1276
+ var input = block.input || {};
1277
+ var path = String(input.path || '');
1278
+ var content = typeof input.content === 'string' ? input.content : '';
1279
+ if (!path || !content) return toolResult(block.id, 'path and content are required.', true);
1280
+ var target = targetValue();
1281
+ var ok = await requestConfirmation({
1282
+ heading: (input.overwrite ? 'Replace ' : 'Write ') + path + ' in ' + target + '?',
1283
+ fields: [{ label: 'Change', value: input.summary || '' }, { label: 'Size', value: content.length + ' characters' }],
1284
+ blocks: [{ label: 'Content (start)', text: content }],
1285
+ confirmLabel: input.overwrite ? 'Replace file' : 'Write file'
1286
+ });
1287
+ if (!ok) return declined(block.id);
1288
+ try {
1289
+ var r = await proxyPost('/project/write', { target: target, path: path, content: content, overwrite: input.overwrite === true });
1290
+ // A file the watcher never saw start is invisible to a running server.
1291
+ var newCollectionDoc = !r.replaced && /^pages\/_[^/]+\//.test(r.path);
1292
+ appendResultCard((r.replaced ? 'Replaced ' : 'Wrote ') + r.path,
1293
+ [r.bytes + ' bytes in ' + trimPath(r.target)].concat(newCollectionDoc ? ['New collection document — restart the dev server to see it.'] : []),
1294
+ newCollectionDoc ? [{ label: 'docker compose restart', onClick: function () { composeAction('restart', target); } }] : []);
1295
+ refreshProjectTree(target);
1296
+ return toolResult(block.id, Object.assign({ path: r.path, bytes: r.bytes, replaced: r.replaced },
1297
+ newCollectionDoc ? { note: 'This is a NEW collection document. Jekyll\'s watcher only tracks files that existed when it started, so the running site will not list it until you call run_compose("restart").' } : {}));
1298
+ } catch (e) {
1299
+ return toolResult(block.id, 'Write failed: ' + e.message + (/exists/.test(e.message) ? ' Pass overwrite:true to replace it, or use edit_project_file.' : ''), true);
1300
+ }
1301
+ }
1302
+
1303
+ async function execEditProjectFile(block) {
1304
+ var no = needProject(block); if (no) return no;
1305
+ var input = block.input || {};
1306
+ var path = String(input.path || '');
1307
+ if (!path || typeof input.find !== 'string' || !input.find) return toolResult(block.id, 'path and a non-empty find are required.', true);
1308
+ var target = targetValue();
1309
+ var ok = await requestConfirmation({
1310
+ heading: 'Edit ' + path + ' in ' + target + '?',
1311
+ fields: [{ label: 'Change', value: input.summary || '' }, { label: 'Scope', value: input.all ? 'every occurrence' : 'one occurrence' }],
1312
+ blocks: [{ label: 'Find', text: input.find }, { label: 'Replace with', text: typeof input.replace === 'string' ? input.replace : '' }],
1313
+ confirmLabel: 'Apply edit'
1314
+ });
1315
+ if (!ok) return declined(block.id);
1316
+ try {
1317
+ var r = await proxyPost('/project/edit', { target: target, path: path, find: input.find, replace: typeof input.replace === 'string' ? input.replace : '', all: input.all === true });
1318
+ appendResultCard('Edited ' + r.path, [r.replacements + ' replacement' + (r.replacements === 1 ? '' : 's') + ', now ' + r.bytes + ' bytes']);
1319
+ return toolResult(block.id, { path: r.path, replacements: r.replacements, bytes: r.bytes });
1320
+ } catch (e) { return toolResult(block.id, 'Edit failed: ' + e.message, true); }
1321
+ }
1322
+
1323
+ async function execDeleteProjectFile(block) {
1324
+ var no = needProject(block); if (no) return no;
1325
+ var input = block.input || {};
1326
+ var path = String(input.path || '');
1327
+ if (!path) return toolResult(block.id, 'path is required.', true);
1328
+ var target = targetValue();
1329
+ var ok = await requestConfirmation({
1330
+ heading: 'Delete ' + path + ' from ' + target + '?',
1331
+ fields: [{ label: 'Why', value: input.summary || '' }],
1332
+ confirmLabel: 'Delete file'
1333
+ });
1334
+ if (!ok) return declined(block.id);
1335
+ try {
1336
+ var r = await proxyPost('/project/delete', { target: target, path: path });
1337
+ appendResultCard('Deleted ' + r.path, []);
1338
+ refreshProjectTree(target);
1339
+ return toolResult(block.id, 'Deleted ' + r.path + '.');
1340
+ } catch (e) { return toolResult(block.id, 'Delete failed: ' + e.message, true); }
1341
+ }
1342
+
1343
+ async function execProjectCommand(block) {
1344
+ var no = needProject(block); if (no) return no;
1345
+ var id = String((block.input && block.input.id) || '');
1346
+ var target = targetValue();
1347
+ try {
1348
+ var r = await proxyPost('/project/command', { target: target, id: id });
1349
+ terminalWrite('$ ' + id + ' (' + target + ')\n' + (r.output || '(no output)') + '\n', true);
1350
+ return toolResult(block.id, (r.ok ? '' : '[failed] ' + (r.error || '') + '\n') + (r.output || '(no output)'), !r.ok);
1351
+ } catch (e) { return toolResult(block.id, 'Command failed: ' + e.message, true); }
1352
+ }
1353
+
1354
+ async function execGenerateImage(block) {
1355
+ var no = needProject(block); if (no) return no;
1356
+ var input = block.input || {};
1357
+ var prompt = String(input.prompt || '').trim();
1358
+ var path = String(input.path || 'assets/images/generated.png');
1359
+ if (!prompt) return toolResult(block.id, 'prompt is required.', true);
1360
+ var provider = input.provider || state.imageProvider || (state.status && state.status.image && state.status.image.default) || '';
1361
+ var info = imageProviderInfo(provider);
1362
+ if (!provider || !info || !info.configured) {
1363
+ return toolResult(block.id, 'No image provider is ready. Ask the user to pick one on the Connect step and paste its key (Grok Imagine needs an xAI key, OpenAI Images an OpenAI key).', true);
1364
+ }
1365
+ var target = targetValue();
1366
+ var ok = await requestConfirmation({
1367
+ heading: 'Generate an image with ' + info.label + '?',
1368
+ fields: [{ label: 'Prompt', value: prompt.length > 220 ? prompt.slice(0, 220) + '…' : prompt }, { label: 'Saved as', value: target + '/' + path }, { label: 'Aspect', value: input.aspect_ratio || 'provider default' }, { label: 'Model', value: info.model }],
1369
+ confirmLabel: 'Generate'
1370
+ });
1371
+ if (!ok) return declined(block.id);
1372
+ appendNotice('Rendering with ' + info.label + '… this takes a few seconds.');
1373
+ try {
1374
+ var r = await proxyPost('/image', { target: target, path: path, prompt: prompt, provider: provider, aspect_ratio: input.aspect_ratio, quality: input.quality, overwrite: input.overwrite === true });
1375
+ var src = endpoint('/asset?target=' + encodeURIComponent(target) + '&path=' + encodeURIComponent(r.path) + '&t=' + Date.now());
1376
+ appendResultCard('Image saved: ' + r.path, [r.provider + ' · ' + r.model + ' · ' + Math.round(r.bytes / 1024) + ' KB' + (r.replaced ? ' (replaced)' : '')], [], { imageSrc: src, imageAlt: prompt.slice(0, 120) });
1377
+ refreshProjectTree(target);
1378
+ return toolResult(block.id, { path: r.path, url: '/' + r.path, bytes: r.bytes, provider: r.provider, model: r.model, revised_prompt: r.revised_prompt || '', hint: 'Reference it as /' + r.path + ' — e.g. preview: /' + r.path + ' in front matter, landing.hero.image, or an <img>.' });
1379
+ } catch (e) {
1380
+ return toolResult(block.id, 'Image generation failed: ' + e.message + (/exists/.test(e.message) ? ' Pass overwrite:true or a new path.' : ''), true);
1381
+ }
1382
+ }
1383
+
1384
+ function terminalWrite(text, reset) {
1385
+ if (!els.terminal) return;
1386
+ els.terminal.hidden = false;
1387
+ if (reset) els.terminal.textContent = '';
1388
+ els.terminal.textContent += text;
1389
+ if (els.terminal.textContent.length > 60000) els.terminal.textContent = els.terminal.textContent.slice(-60000);
1390
+ els.terminal.scrollTop = els.terminal.scrollHeight;
1391
+ }
1392
+
1393
+ async function composeAction(action, target, viaModel) {
1394
+ var w = W();
1395
+ target = target || targetValue();
1396
+ if (!target) { if (w) w.toast('Set a project folder first'); return { ok: false, error: 'no target' }; }
1397
+ if (action === 'up' || action === 'down') {
1398
+ var ok = await requestConfirmation({
1399
+ heading: action === 'up' ? 'Build and start the dev server?' : 'Stop the dev server?',
1400
+ fields: [{ label: 'Command', value: 'docker compose ' + (action === 'up' ? 'up -d --build' : 'down') }, { label: 'Folder', value: target }],
1401
+ confirmLabel: action === 'up' ? 'Start' : 'Stop'
1402
+ });
1403
+ if (!ok) return { declined: true };
1404
+ }
1405
+ terminalWrite('$ docker compose ' + action + ' (' + target + ')\n', true);
1406
+ var setBusy = function (on) { [els.composeUp, els.composeBuild, els.composeLogs, els.composeDown, els.writeProject].forEach(function (b) { if (b) b.disabled = on || !state.connected; }); };
1407
+ setBusy(true);
1408
+ var tail = '';
1409
+ try {
1410
+ var resp = await fetch(endpoint('/compose'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ target: target, action: action }) });
1411
+ if (!resp.ok || !resp.body) throw new Error('proxy request failed (' + resp.status + ')');
1412
+ var reader = resp.body.getReader();
1413
+ var decoder = new TextDecoder();
1414
+ while (true) {
1415
+ var chunk = await reader.read();
1416
+ if (chunk.done) break;
1417
+ var text = decoder.decode(chunk.value, { stream: true });
1418
+ tail = (tail + text).slice(-6000);
1419
+ terminalWrite(text);
1420
+ }
1421
+ } catch (e) {
1422
+ terminalWrite('\n[error] ' + e.message + '\n');
1423
+ tail += '\n[error] ' + e.message;
1424
+ } finally { setBusy(false); }
1425
+ var okExit = /\[exit\] ok/.test(tail);
1426
+ if (action === 'up' && okExit) {
1427
+ var port = w ? w.getState().port : CONFIG.defaultPort;
1428
+ appendResultCard('Dev server starting', ['First build installs gems and can take a few minutes. The page below reloads on its own once Jekyll is serving.'], [
1429
+ { label: 'Open http://localhost:' + port + '/', href: 'http://localhost:' + port + '/' },
1430
+ { label: 'Show logs', onClick: function () { composeAction('logs', target); } }
1431
+ ]);
1432
+ }
1433
+ if (action === 'build') appendResultCard(okExit ? 'jekyll build passed' : 'jekyll build failed', [okExit ? 'The site builds cleanly with its dev config.' : 'See the terminal on the Build step; the assistant can read the tail and fix it.']);
1434
+ if (action === 'restart' && okExit) appendResultCard('Dev server restarted', ['Newly added pages and posts are picked up on the next build.']);
1435
+ if (!viaModel && w) w.toast('docker compose ' + action + (okExit ? ' finished' : ' failed — see the terminal'));
1436
+ return { ok: okExit, tail: tail };
1437
+ }
1438
+
1439
+ async function execCompose(block) {
1440
+ if (!state.connected) return toolResult(block.id, 'The dev proxy is offline.', true);
1441
+ var action = String((block.input && block.input.action) || '');
1442
+ var r = await composeAction(action, targetValue(block.input), true);
1443
+ if (r.declined) return declined(block.id);
1444
+ return toolResult(block.id, 'docker compose ' + action + ' → ' + (r.ok ? 'ok' : 'failed') + '\n\n' + (r.tail || r.error || ''), !r.ok && action !== 'logs' && action !== 'ps');
1445
+ }
1446
+
1447
+ async function checkLive(url) {
1448
+ var w = W();
1449
+ url = url || ('http://localhost:' + (w ? w.getState().port : CONFIG.defaultPort) + '/');
1450
+ try {
1451
+ // An opaque response resolves when something is listening; a refused
1452
+ // connection rejects. Good enough for "is the dev server up".
1453
+ await fetch(url, { mode: 'no-cors', cache: 'no-store' });
1454
+ return { url: url, reachable: true };
1455
+ } catch (e) { return { url: url, reachable: false, error: e.message }; }
1456
+ }
1457
+
1458
+ async function execCheckLive(block) {
1459
+ var r = await checkLive(block.input && block.input.url);
1460
+ appendResultCard(r.reachable ? 'Site is answering' : 'Nothing is listening yet', [r.url], r.reachable ? [{ label: 'Open', href: r.url }] : []);
1461
+ return toolResult(block.id, r);
1462
+ }
1463
+
1464
+ async function executeTool(block) {
1465
+ var w = W();
1466
+ switch (block.name) {
1467
+ case 'get_wizard_state': return toolResult(block.id, w ? Object.assign(w.getState(), { files: w.getFiles().map(function (f) { return f.path; }), project: state.project ? state.project.name : null }) : 'Wizard not ready.');
1468
+ case 'set_wizard_fields': return execSetFields(block);
1469
+ case 'get_site_plan': return toolResult(block.id, w ? { plan: w.getPlan(), resolvedLanding: w.landingPlan() } : 'Wizard not ready.');
1470
+ case 'set_site_plan': return execSetSitePlan(block);
1471
+ case 'go_to_step': {
1472
+ var ok = w && w.setStep(String((block.input && block.input.step) || ''));
1473
+ renderChips();
1474
+ return toolResult(block.id, ok ? 'Now on step ' + block.input.step + '.' : 'Could not open step ' + (block.input && block.input.step) + ' (unknown, or locked by an invalid earlier field).', !ok);
1475
+ }
1476
+ case 'get_generated_file': return execGetFile(block);
1477
+ case 'set_file_override': return execSetOverride(block);
1478
+ case 'run_prerequisite_check': return execRunCheck(block);
1479
+ case 'read_theme_file': return execReadTheme(block);
1480
+ case 'list_theme_dir': return execListTheme(block);
1481
+ case 'search_theme_docs': return execSearchDocs(block);
1482
+ case 'resolve_target': return execResolveTarget(block);
1483
+ case 'write_site_files': return execWriteFiles(block);
1484
+ case 'list_projects': return execListProjects(block);
1485
+ case 'open_project': return execOpenProject(block);
1486
+ case 'list_project_files': return execListProjectFiles(block);
1487
+ case 'read_project_file': return execReadProjectFile(block);
1488
+ case 'write_project_file': return execWriteProjectFile(block);
1489
+ case 'edit_project_file': return execEditProjectFile(block);
1490
+ case 'delete_project_file': return execDeleteProjectFile(block);
1491
+ case 'run_project_command': return execProjectCommand(block);
1492
+ case 'generate_image': return execGenerateImage(block);
1493
+ case 'run_compose': return execCompose(block);
1494
+ case 'check_site_live': return execCheckLive(block);
1495
+ default: return toolResult(block.id, 'Unknown tool: ' + block.name, true);
1496
+ }
1497
+ }
1498
+
1499
+ // --- Messages API (streaming through the proxy) -------------------
1500
+ async function streamAssistant(payload, onTextDelta, signal) {
1501
+ var response = await fetch(CONFIG.chatEndpoint, {
1502
+ method: 'POST',
1503
+ headers: { 'content-type': 'application/json' },
1504
+ body: JSON.stringify(payload),
1505
+ signal: signal
1506
+ });
1507
+ if (!response.ok) {
1508
+ var errorData = await response.json().catch(function () { return {}; });
1509
+ var apiMessage = errorData.error && errorData.error.message;
1510
+ throw new Error(apiMessage || STATUS_MESSAGES[response.status] || 'API request failed (' + response.status + ')');
1511
+ }
1512
+ var contentType = response.headers.get('content-type') || '';
1513
+ if (contentType.indexOf('text/event-stream') === -1) {
1514
+ var data = await response.json();
1515
+ return { content: data.content || [], stopReason: data.stop_reason || null };
1516
+ }
1517
+
1518
+ var reader = response.body.getReader();
1519
+ var decoder = new TextDecoder();
1520
+ var buffer = '';
1521
+ var blocks = [];
1522
+ var partialJson = {};
1523
+ var stopReason = null;
1524
+
1525
+ function handleEvent(evt) {
1526
+ switch (evt.type) {
1527
+ case 'content_block_start':
1528
+ blocks[evt.index] = Object.assign({}, evt.content_block);
1529
+ if (evt.content_block.type === 'tool_use') partialJson[evt.index] = '';
1530
+ break;
1531
+ case 'content_block_delta':
1532
+ if (evt.delta.type === 'text_delta') {
1533
+ blocks[evt.index].text = (blocks[evt.index].text || '') + evt.delta.text;
1534
+ if (onTextDelta) onTextDelta(evt.delta.text);
1535
+ } else if (evt.delta.type === 'input_json_delta') {
1536
+ partialJson[evt.index] += evt.delta.partial_json;
1537
+ }
1538
+ break;
1539
+ case 'content_block_stop':
1540
+ if (blocks[evt.index] && blocks[evt.index].type === 'tool_use') {
1541
+ try { blocks[evt.index].input = partialJson[evt.index] ? JSON.parse(partialJson[evt.index]) : {}; } catch (e) { blocks[evt.index].input = {}; }
1542
+ }
1543
+ break;
1544
+ case 'message_delta':
1545
+ if (evt.delta && evt.delta.stop_reason) stopReason = evt.delta.stop_reason;
1546
+ break;
1547
+ case 'error':
1548
+ throw new Error((evt.error && evt.error.message) || 'Stream error');
1549
+ }
1550
+ }
1551
+
1552
+ while (true) {
1553
+ var chunk = await reader.read();
1554
+ if (chunk.done) break;
1555
+ buffer += decoder.decode(chunk.value, { stream: true });
1556
+ var nl;
1557
+ while ((nl = buffer.indexOf('\n')) !== -1) {
1558
+ var line = buffer.slice(0, nl).replace(/\r$/, '');
1559
+ buffer = buffer.slice(nl + 1);
1560
+ if (line.indexOf('data:') !== 0) continue;
1561
+ var dataStr = line.slice(5).trim();
1562
+ if (!dataStr) continue;
1563
+ var evt;
1564
+ try { evt = JSON.parse(dataStr); } catch (e) { continue; }
1565
+ handleEvent(evt);
1566
+ }
1567
+ }
1568
+
1569
+ var content = blocks.filter(Boolean).map(function (b) {
1570
+ if (b.type === 'text') return { type: 'text', text: b.text || '' };
1571
+ if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input || {} };
1572
+ return null;
1573
+ }).filter(Boolean);
1574
+ return { content: content, stopReason: stopReason };
1575
+ }
1576
+
1577
+ // Trim from the front until the buffer starts with a plain user text turn —
1578
+ // never orphan a tool_result from its tool_use.
1579
+ function trimHistory() {
1580
+ while (state.history.length > MAX_MESSAGES) state.history.shift();
1581
+ while (state.history.length && !(state.history[0].role === 'user' && typeof state.history[0].content === 'string')) state.history.shift();
1582
+ }
1583
+
1584
+ // An interrupted run may leave an assistant tool_use without its result;
1585
+ // answer it synthetically so the next request is well-formed.
1586
+ function repairHistory(reason) {
1587
+ var last = state.history[state.history.length - 1];
1588
+ if (!last || last.role !== 'assistant' || !Array.isArray(last.content)) return;
1589
+ var pending = last.content.filter(function (b) { return b.type === 'tool_use'; });
1590
+ if (!pending.length) return;
1591
+ state.history.push({ role: 'user', content: pending.map(function (b) { return toolResult(b.id, reason || 'The user stopped the run before this tool executed.', true); }) });
1592
+ }
1593
+
1594
+ function setBusy(on) {
1595
+ state.busy = on;
1596
+ if (els.input) els.input.disabled = on || !(state.connected && state.ready);
1597
+ if (els.send) { els.send.disabled = on || !(state.connected && state.ready); els.send.hidden = on; }
1598
+ if (els.stop) { els.stop.hidden = !on; els.stop.disabled = !on; }
1599
+ }
1600
+
1601
+ function stopRun() {
1602
+ if (state.abort) { try { state.abort.abort(); } catch (e) { /* already done */ } }
1603
+ }
1604
+
1605
+ function errorHint(message) {
1606
+ var label = providerName();
1607
+ if (/revoked|invalid.*(token|key)|authentication|401|incorrect api key/i.test(message)) {
1608
+ return state.provider === 'xai'
1609
+ ? ' The xAI key was rejected: create a new one at console.x.ai and paste it on the Connect step.'
1610
+ : ' The ' + label + ' credential is no longer valid: run `claude setup-token` (or make a new API key) and paste it on the Connect step.';
1611
+ }
1612
+ if (/501|no .*credential/i.test(message)) return ' Add a ' + label + ' token on the Connect step.';
1613
+ return '';
1614
+ }
1615
+
1616
+ async function sendMessage(text) {
1617
+ text = String(text || '').trim();
1618
+ if (!text || state.busy || !state.connected || !state.ready) return;
1619
+ appendMessage('user', text);
1620
+ state.history.push({ role: 'user', content: text });
1621
+ setBusy(true);
1622
+ state.abort = (typeof AbortController !== 'undefined') ? new AbortController() : null;
1623
+ showTyping();
1624
+
1625
+ var tools = buildTools();
1626
+ var maxRounds = TOOL_ROUNDS[state.mode] || TOOL_ROUNDS.guided;
1627
+ var sawText = false;
1628
+ var stopped = false;
1629
+ try {
1630
+ for (var round = 0; round < maxRounds; round += 1) {
1631
+ var bubble = null;
1632
+ var accumulated = '';
1633
+ if (els.note) els.note.textContent = round ? 'Working… round ' + (round + 1) + '/' + maxRounds : 'Thinking…';
1634
+ var payload = {
1635
+ provider: state.provider,
1636
+ model: state.model || CONFIG.model,
1637
+ max_tokens: CONFIG.maxTokens || 4096,
1638
+ system: buildSystemPrompt(), // rebuilt each round: tools may have changed the state
1639
+ messages: state.history.slice(),
1640
+ tools: tools,
1641
+ stream: true
1642
+ };
1643
+ var result = await streamAssistant(payload, function (delta) {
1644
+ if (!bubble) { removeTyping(); bubble = appendMessage('assistant', '', { transient: true }); }
1645
+ accumulated += delta;
1646
+ bubble.textContent = accumulated;
1647
+ scrollToBottom();
1648
+ }, state.abort ? state.abort.signal : undefined);
1649
+ removeTyping();
1650
+ if (bubble && accumulated) {
1651
+ bubble.innerHTML = renderMarkdown(accumulated);
1652
+ persistTranscript('assistant', accumulated);
1653
+ sawText = true;
1654
+ } else if (!bubble) {
1655
+ var textOut = result.content.filter(function (b) { return b.type === 'text'; }).map(function (b) { return b.text || ''; }).join('').trim();
1656
+ if (textOut) { appendMessage('assistant', textOut); sawText = true; }
1657
+ }
1658
+ if (result.content.length) state.history.push({ role: 'assistant', content: result.content });
1659
+
1660
+ var toolUses = result.content.filter(function (b) { return b.type === 'tool_use'; });
1661
+ if (result.stopReason !== 'tool_use' || !toolUses.length) break;
1662
+
1663
+ var results = [];
1664
+ for (var i = 0; i < toolUses.length; i += 1) {
1665
+ if (state.abort && state.abort.signal.aborted) { stopped = true; break; }
1666
+ results.push(await executeTool(toolUses[i]));
1667
+ }
1668
+ if (stopped) { repairHistory(); break; }
1669
+ state.history.push({ role: 'user', content: results });
1670
+ renderChips();
1671
+ if (round === maxRounds - 1) appendNotice('Reached the ' + maxRounds + '-round limit for one turn. Say "continue" to keep going.');
1672
+ showTyping();
1673
+ }
1674
+ if (!sawText && !stopped) appendMessage('assistant', 'Done.');
1675
+ } catch (err) {
1676
+ removeTyping();
1677
+ if (err && (err.name === 'AbortError' || (state.abort && state.abort.signal.aborted))) {
1678
+ stopped = true;
1679
+ repairHistory();
1680
+ appendNotice('Stopped.');
1681
+ } else {
1682
+ repairHistory('The run failed before this tool executed: ' + err.message);
1683
+ appendMessage('assistant', 'Sorry, something went wrong: ' + err.message + errorHint(err.message), { transient: true });
1684
+ console.error('Site Builder error:', err);
1685
+ }
1686
+ } finally {
1687
+ state.abort = null;
1688
+ setBusy(false);
1689
+ if (els.note) els.note.textContent = state.ready ? 'Model: ' + (state.model || '') : '';
1690
+ if (state.connected && state.ready && els.input) els.input.focus();
1691
+ trimHistory();
1692
+ }
1693
+ }
1694
+
1695
+ // --- Wiring ----------------------------------------------------------
1696
+ function autoGrow(textarea) {
1697
+ textarea.style.height = 'auto';
1698
+ textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
1699
+ }
1700
+
1701
+ function init() {
1702
+ if (els.form) {
1703
+ els.form.addEventListener('submit', function (e) {
1704
+ e.preventDefault();
1705
+ var text = els.input.value.trim();
1706
+ if (text) { els.input.value = ''; autoGrow(els.input); sendMessage(text); }
1707
+ });
1708
+ }
1709
+ if (els.input) {
1710
+ els.input.addEventListener('input', function () { autoGrow(els.input); });
1711
+ els.input.addEventListener('keydown', function (e) {
1712
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); els.form.requestSubmit ? els.form.requestSubmit() : els.form.dispatchEvent(new Event('submit', { cancelable: true })); }
1713
+ });
1714
+ }
1715
+ if (els.stop) els.stop.addEventListener('click', stopRun);
1716
+ if (els.clear) els.clear.addEventListener('click', clearTranscript);
1717
+ if (els.collapse) {
1718
+ els.collapse.addEventListener('click', function () {
1719
+ var open = els.collapse.getAttribute('aria-expanded') === 'true';
1720
+ els.collapse.setAttribute('aria-expanded', String(!open));
1721
+ els.body.hidden = open;
1722
+ els.collapse.querySelector('i').className = 'bi ' + (open ? 'bi-chevron-down' : 'bi-chevron-up');
1723
+ els.collapse.title = open ? 'Expand' : 'Collapse';
1724
+ });
1725
+ }
1726
+ if (els.connectRetry) els.connectRetry.addEventListener('click', function () { checkStatus(true); });
1727
+
1728
+ // Connect step: provider cards, token form, model + image selects, mode.
1729
+ document.querySelectorAll('input[name="sb_provider"]').forEach(function (radio) {
1730
+ radio.addEventListener('change', function () { if (radio.checked) selectProviderUI(radio.value); });
1731
+ });
1732
+ if (els.credentialForm) els.credentialForm.addEventListener('submit', submitCredential);
1733
+ if (els.modelSelect) {
1734
+ els.modelSelect.addEventListener('change', function () {
1735
+ state.model = els.modelSelect.value;
1736
+ savePrefs();
1737
+ if (els.note && state.ready) els.note.textContent = 'Model: ' + state.model;
1738
+ });
1739
+ }
1740
+ if (els.imageSelect) {
1741
+ els.imageSelect.addEventListener('change', function () {
1742
+ state.imageProvider = els.imageSelect.value;
1743
+ prefs.imageProviderCleared = !state.imageProvider;
1744
+ savePrefs();
1745
+ renderProviderControls();
1746
+ var w = W();
1747
+ if (w) w.applyFields({ image_provider: state.imageProvider }, { silent: true, source: 'session' });
1748
+ });
1749
+ }
1750
+ document.querySelectorAll('input[name="sb_mode"]').forEach(function (radio) {
1751
+ radio.addEventListener('change', function () { if (radio.checked) selectMode(radio.value); });
1752
+ });
1753
+
1754
+ if (els.runChecks) {
1755
+ els.runChecks.addEventListener('click', async function () {
1756
+ els.runChecks.disabled = true;
1757
+ var ids = (DATA.prerequisites || []).map(function (p) { return p.check; }).filter(Boolean);
1758
+ for (var i = 0; i < ids.length; i += 1) await runCheck(ids[i]);
1759
+ els.runChecks.disabled = !state.connected;
1760
+ var w = W();
1761
+ if (w) w.toast('Prerequisite checks finished');
1762
+ });
1763
+ }
1764
+ var targetCheck = document.getElementById('btn-target-check');
1765
+ if (targetCheck) {
1766
+ targetCheck.addEventListener('click', async function () {
1767
+ var target = targetValue();
1768
+ if (!target) { if (els.targetResolved) els.targetResolved.textContent = 'Enter a folder name first.'; return; }
1769
+ if (!state.connected) { if (els.targetResolved) els.targetResolved.textContent = 'Connect the dev proxy to check the folder; offline, the bundle creates it wherever you run it.'; return; }
1770
+ try { await resolveTarget(target); } catch (e) { if (els.targetResolved) { els.targetResolved.textContent = e.message; els.targetResolved.className = 'form-text text-danger'; } }
1771
+ });
1772
+ }
1773
+ if (els.targetInput) {
1774
+ // Typing a different folder name drops the opened project so the tools
1775
+ // follow the field again (targetValue prefers the field once cleared).
1776
+ els.targetInput.addEventListener('input', function () {
1777
+ if (state.project && els.targetInput.value.trim() !== state.project.name) state.project = null;
1778
+ });
1779
+ }
1780
+ if (els.projectOpen) {
1781
+ els.projectOpen.addEventListener('click', async function () {
1782
+ var pick = (els.projectSelect && els.projectSelect.value) || (els.targetInput && els.targetInput.value.trim());
1783
+ var w = W();
1784
+ if (!pick) { if (w) w.toast('Pick a site or type a folder name first'); return; }
1785
+ try { await openProject(pick); if (w) w.toast('Opened ' + pick); } catch (e) { appendResultCard('Could not open ' + pick, [e.message]); }
1786
+ });
1787
+ }
1788
+ if (els.writeProject) {
1789
+ els.writeProject.addEventListener('click', async function () {
1790
+ var target = targetValue();
1791
+ var w = W();
1792
+ if (!target) { if (w) w.toast('Enter a project folder name first'); return; }
1793
+ var overwrite = !!(document.getElementById('cfg-overwrite') && document.getElementById('cfg-overwrite').checked);
1794
+ try { await writeProject(target, overwrite, false); } catch (e) { appendResultCard('Write failed', [e.message]); }
1795
+ });
1796
+ }
1797
+ if (els.composeUp) els.composeUp.addEventListener('click', function () { composeAction('up'); });
1798
+ if (els.composeBuild) els.composeBuild.addEventListener('click', function () { composeAction('build'); });
1799
+ if (els.composeLogs) els.composeLogs.addEventListener('click', function () { composeAction('logs'); });
1800
+ if (els.composeDown) els.composeDown.addEventListener('click', function () { composeAction('down'); });
1801
+ if (els.checkLive) {
1802
+ els.checkLive.addEventListener('click', async function () {
1803
+ var r = await checkLive();
1804
+ var w = W();
1805
+ if (w) w.toast(r.reachable ? 'The site is answering at ' + r.url : 'Nothing is listening at ' + r.url + ' yet');
1806
+ if (state.connected) appendResultCard(r.reachable ? 'Site is answering' : 'Nothing is listening yet', [r.url], r.reachable ? [{ label: 'Open', href: r.url }] : []);
1807
+ });
1808
+ }
1809
+
1810
+ // Ask buttons in the form ("Draft with Claude" / "… with Grok").
1811
+ document.querySelectorAll('.sb-ask').forEach(function (btn) {
1812
+ btn.addEventListener('click', function () {
1813
+ var w = W();
1814
+ if (!state.connected || !state.ready) { if (w) w.toast('Connect ' + providerName() + ' (step 1) first'); return; }
1815
+ sendMessage(fillTemplate(btn.getAttribute('data-ask') || ''));
1816
+ });
1817
+ });
1818
+
1819
+ var w = W();
1820
+ if (w) {
1821
+ w.on('step', renderChips);
1822
+ w.on('reset', function () { state.project = null; clearTranscript(); renderChips(); });
1823
+ } else {
1824
+ document.getElementById('setup-wizard').addEventListener('zer0:wizard:step', renderChips);
1825
+ }
1826
+
1827
+ restoreTranscript();
1828
+ setConnected(false, null);
1829
+ checkStatus(false).then(schedulePoll);
1830
+ }
1831
+
1832
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
1833
+ else init();
1834
+ })();