openpalm 0.9.8 → 0.9.9

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.
@@ -18,28 +18,35 @@
18
18
  Provider Constants & Defaults
19
19
  ========================================================================= */
20
20
 
21
- const PROVIDER_DEFAULTS = {
22
- openai: { baseUrl: "https://api.openai.com", llmModel: "gpt-4o", embModel: "text-embedding-3-small", embDims: 1536 },
23
- anthropic: { baseUrl: "https://api.anthropic.com", llmModel: "claude-sonnet-4-20250514", embModel: "", embDims: 0 },
24
- groq: { baseUrl: "https://api.groq.com/openai", llmModel: "llama-3.3-70b-versatile", embModel: "", embDims: 0 },
25
- together: { baseUrl: "https://api.together.xyz", llmModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", embModel: "", embDims: 0 },
26
- mistral: { baseUrl: "https://api.mistral.ai", llmModel: "mistral-large-latest", embModel: "mistral-embed", embDims: 1024 },
27
- deepseek: { baseUrl: "https://api.deepseek.com", llmModel: "deepseek-chat", embModel: "", embDims: 0 },
28
- xai: { baseUrl: "https://api.x.ai", llmModel: "grok-2", embModel: "", embDims: 0 },
29
- ollama: { baseUrl: "http://localhost:11434", llmModel: "llama3.2", embModel: "nomic-embed-text", embDims: 768 },
30
- "model-runner": { baseUrl: "http://localhost:12434", llmModel: "ai/llama3.2", embModel: "ai/mxbai-embed-large-v1", embDims: 1024 },
31
- lmstudio: { baseUrl: "http://localhost:1234", llmModel: "loaded-model", embModel: "", embDims: 0 },
32
- };
33
-
34
- const PROVIDER_LABELS = {
35
- openai: "OpenAI", anthropic: "Anthropic", groq: "Groq",
36
- together: "Together AI", mistral: "Mistral", deepseek: "DeepSeek",
37
- xai: "xAI (Grok)", ollama: "Ollama", "model-runner": "Docker Model Runner",
38
- lmstudio: "LM Studio", "ollama-instack": "Ollama (in-stack)",
39
- };
40
-
41
- var CLOUD_PROVIDERS = ["openai", "anthropic", "groq", "together", "mistral", "deepseek", "xai"];
42
- var LOCAL_PROVIDERS = ["ollama", "model-runner", "lmstudio"];
21
+ var PROVIDER_GROUPS = [
22
+ { id: "recommended", label: "Recommended", desc: "Best options to get started quickly" },
23
+ { id: "local", label: "Local", desc: "Run models on your own hardware" },
24
+ { id: "cloud", label: "Cloud", desc: "Hosted inference providers" },
25
+ { id: "advanced", label: "Advanced", desc: "Additional providers" },
26
+ ];
27
+
28
+ var PROVIDERS = [
29
+ // Recommended best first-run experience
30
+ { id: "ollama", name: "Ollama", kind: "local", group: "recommended", order: 1, icon: "\uD83E\uDD99", desc: "Run open models on your hardware", needsKey: false, placeholder: "", baseUrl: "http://localhost:11434", llmModel: "llama3.2", embModel: "nomic-embed-text", embDims: 768, canDetect: true },
31
+ { id: "huggingface", name: "Hugging Face", kind: "cloud", group: "recommended", order: 2, icon: "\uD83E\uDD17", desc: "10,000+ open models via Inference Providers", needsKey: true, placeholder: "hf_...", baseUrl: "https://router.huggingface.co/v1", llmModel: "Qwen/Qwen3-32B", embModel: "intfloat/multilingual-e5-large", embDims: 1024, keyPrefix: "hf_" },
32
+
33
+ { id: "openai", name: "OpenAI", kind: "cloud", group: "recommended", order: 3, icon: "\u25D0", desc: "GPT and o-series reasoning models", needsKey: true, placeholder: "sk-...", baseUrl: "https://api.openai.com", llmModel: "gpt-4o", embModel: "text-embedding-3-small", embDims: 1536 },
34
+ { id: "google", name: "Google", kind: "cloud", group: "recommended", order: 4, icon: "\u25C6", desc: "Gemini models with large context", needsKey: true, placeholder: "AIza...", baseUrl: "https://generativelanguage.googleapis.com", llmModel: "gemini-2.5-flash", embModel: "", embDims: 0, keyPrefix: "AI" },
35
+
36
+ // Local self-hosted model runtimes
37
+ { id: "model-runner", name: "Docker Model Runner", kind: "local", group: "local", order: 1, icon: "\uD83D\uDC33", desc: "Docker-managed model runtime", needsKey: false, placeholder: "", baseUrl: "http://localhost:12434", llmModel: "ai/llama3.2", embModel: "ai/mxbai-embed-large-v1", embDims: 1024, canDetect: true },
38
+ { id: "lmstudio", name: "LM Studio", kind: "local", group: "local", order: 2, icon: "\uD83D\uDD2C", desc: "Desktop app for local inference", needsKey: false, placeholder: "", baseUrl: "http://localhost:1234", llmModel: "loaded-model", embModel: "", embDims: 0, canDetect: true },
39
+
40
+ // Cloud — hosted inference APIs
41
+ { id: "groq", name: "Groq", kind: "cloud", group: "cloud", order: 1, icon: "\u26A1", desc: "Ultra-fast inference", needsKey: true, placeholder: "gsk_...", baseUrl: "https://api.groq.com/openai", llmModel: "llama-3.3-70b-versatile", embModel: "", embDims: 0 },
42
+ { id: "mistral", name: "Mistral", kind: "cloud", group: "cloud", order: 2, icon: "\u25C6", desc: "Mistral & Codestral models", needsKey: true, placeholder: "...", baseUrl: "https://api.mistral.ai", llmModel: "mistral-large-latest", embModel: "mistral-embed", embDims: 1024 },
43
+ { id: "together", name: "Together AI", kind: "cloud", group: "cloud", order: 3, icon: "\u2726", desc: "Open models at scale", needsKey: true, placeholder: "...", baseUrl: "https://api.together.xyz", llmModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", embModel: "", embDims: 0 },
44
+
45
+ // Advanced — niche or specialized providers
46
+ { id: "deepseek", name: "DeepSeek", kind: "cloud", group: "advanced", order: 1, icon: "\u25CE", desc: "DeepSeek chat & reasoning", needsKey: true, placeholder: "sk-...", baseUrl: "https://api.deepseek.com", llmModel: "deepseek-chat", embModel: "", embDims: 0 },
47
+ { id: "xai", name: "xAI (Grok)", kind: "cloud", group: "advanced", order: 2, icon: "\u2726", desc: "Grok models", needsKey: true, placeholder: "xai-...", baseUrl: "https://api.x.ai", llmModel: "grok-2", embModel: "", embDims: 0 },
48
+ { id: "openai-compatible", name: "Custom (OpenAI-compatible)", kind: "cloud", group: "advanced", order: 3, icon: "\uD83D\uDD27", desc: "Any endpoint that speaks the OpenAI API", needsKey: false, needsUrl: true, optionalKey: true, placeholder: "API key (optional)", baseUrl: "", llmModel: "", embModel: "", embDims: 0 },
49
+ ];
43
50
 
44
51
  /** Known embedding dimensions for auto-fill */
45
52
  var KNOWN_EMB_DIMS = {
@@ -48,15 +55,64 @@
48
55
  "mxbai-embed-large": 1024, "mxbai-embed-large-v1": 1024,
49
56
  "ai/mxbai-embed-large-v1": 1024, "mistral-embed": 1024,
50
57
  "all-minilm": 384, "snowflake-arctic-embed": 1024,
58
+ "intfloat/multilingual-e5-large": 1024,
51
59
  };
52
60
 
61
+ var STEP_LABELS = ["Welcome", "Providers", "Models", "Voice", "Options", "Review"];
62
+ var TOTAL_STEPS = 6;
63
+
64
+ /* =========================================================================
65
+ Voice / TTS / STT Options
66
+ ========================================================================= */
67
+
68
+ var TTS_OPTIONS = [
69
+ { id: "kokoro", name: "Kokoro TTS", type: "local", recommended: true, desc: "High-quality local TTS \u2014 runs on CPU" },
70
+ { id: "piper", name: "Piper TTS", type: "local", desc: "Ultra-lightweight \u2014 great for low-power hardware" },
71
+ { id: "openai-tts", name: "OpenAI TTS", type: "cloud", desc: "Cloud voices. Uses your OpenAI API key" },
72
+ { id: "browser-tts", name: "Browser Built-in", type: "builtin", desc: "Native speech synthesis. No setup needed" },
73
+ { id: "skip-tts", name: "Skip \u2014 text only", type: "skip", desc: "Add TTS later from the dashboard" },
74
+ ];
75
+
76
+ var STT_OPTIONS = [
77
+ { id: "whisper-local", name: "Whisper (local)", type: "local", recommended: true, desc: "Whisper in Docker. Accurate, private" },
78
+ { id: "openai-stt", name: "OpenAI Whisper", type: "cloud", desc: "Cloud Whisper API. Uses OpenAI key" },
79
+ { id: "browser-stt", name: "Browser Built-in", type: "builtin", desc: "Web Speech API. No setup" },
80
+ { id: "skip-stt", name: "Skip \u2014 text only", type: "skip", desc: "Add STT later from the dashboard" },
81
+ ];
82
+
83
+ /* =========================================================================
84
+ Channel & Service Constants
85
+ ========================================================================= */
86
+
87
+ var CHANNELS = [
88
+ { id: "chat", name: "Web Chat", icon: "\uD83D\uDCAC", desc: "Browser-based chat \u2014 always available", locked: true },
89
+ { id: "api", name: "API", icon: "\uD83D\uDD0C", desc: "OpenAI-compatible REST API endpoint" },
90
+ {
91
+ id: "discord", name: "Discord", icon: "\uD83C\uDFAE", desc: "Connect to a Discord server",
92
+ credentials: [
93
+ { key: "botToken", label: "Bot Token", placeholder: "Paste Discord bot token", required: true },
94
+ { key: "applicationId", label: "Application ID", placeholder: "Discord application ID", secret: false },
95
+ ]
96
+ },
97
+ {
98
+ id: "slack", name: "Slack", icon: "\uD83D\uDCBC", desc: "Access via Slack bot",
99
+ credentials: [
100
+ { key: "slackBotToken", label: "Bot Token", placeholder: "xoxb-...", required: true },
101
+ { key: "slackAppToken", label: "App Token", placeholder: "xapp-...", required: true },
102
+ ]
103
+ },
104
+ ];
105
+
106
+ var SERVICES = [
107
+ { id: "admin", name: "Admin Dashboard", icon: "\u2699\uFE0F", desc: "Web-based admin UI for managing your stack", recommended: true },
108
+ { id: "openviking", name: "OpenViking", icon: "\u2694\uFE0F", desc: "Agentic task execution engine" },
109
+ ];
110
+
53
111
  /* =========================================================================
54
112
  DOM Helpers
55
113
  ========================================================================= */
56
114
 
57
115
  function $(id) { return document.getElementById(id); }
58
- function qs(sel) { return document.querySelector(sel); }
59
- function qsa(sel) { return document.querySelectorAll(sel); }
60
116
  function show(el) { if (el) el.classList.remove("hidden"); }
61
117
  function hide(el) { if (el) el.classList.add("hidden"); }
62
118
  function showError(el, msg) { if (el) { el.textContent = msg; show(el); } }
@@ -68,27 +124,32 @@
68
124
 
69
125
  var currentStep = 0;
70
126
  var maxVisitedStep = 0;
127
+ var welcomeHeroDismissed = false;
71
128
 
72
- /** @type {Array<{id:string, name:string, provider:string, baseUrl:string, apiKey:string, kind:string, models:string[]}>} */
73
- var connections = [];
129
+ /** Provider selection state: { providerId: { selected, verified, verifying, error, apiKey, baseUrl, models[], ollamaMode } } */
130
+ var providerState = {};
74
131
 
75
- /** Provider detection results from /api/setup/detect-providers */
76
- var detectedProviders = [];
132
+ /** Expanded provider card (only one at a time) */
133
+ var expandedProvider = null;
77
134
 
78
- /** Model lists fetched per connection id */
79
- var modelCache = {};
135
+ /** Provider detection results */
136
+ var detectedProviders = [];
80
137
 
81
- /** Currently editing connection index (-1 = not editing) */
82
- var editingIdx = -1;
138
+ /** Model selection: { llm: {connId, model}, embedding: {connId, model, dims}, small: {connId, model} } */
139
+ var modelSelection = {};
83
140
 
84
- /** Sub-view state for step 1: "hub" | "chooser" | "form" */
85
- var connView = "hub";
141
+ /** Voice selection state */
142
+ var voiceSelection = { tts: null, stt: null };
86
143
 
87
- /** Draft connection kind while adding: "cloud" | "local" */
88
- var draftKind = "cloud";
144
+ /** Channel selection state (chat always on) */
145
+ var channelSelection = {
146
+ chat: true,
147
+ discord: { enabled: false, botToken: "", applicationId: "" },
148
+ slack: { enabled: false, slackBotToken: "", slackAppToken: "" },
149
+ };
89
150
 
90
- /** Draft connection provider while in form */
91
- var draftProvider = "openai";
151
+ /** Services selection state (admin default on) */
152
+ var serviceSelection = { admin: true };
92
153
 
93
154
  /** Deploy polling timer */
94
155
  var deployTimer = null;
@@ -96,6 +157,20 @@
96
157
  /** Whether install is in progress */
97
158
  var installing = false;
98
159
 
160
+ // Initialize provider states
161
+ PROVIDERS.forEach(function (p) {
162
+ providerState[p.id] = {
163
+ selected: false,
164
+ verified: false,
165
+ verifying: false,
166
+ error: false,
167
+ apiKey: "",
168
+ baseUrl: p.baseUrl || "",
169
+ models: [],
170
+ ollamaMode: null, // null | "running" | "instack"
171
+ };
172
+ });
173
+
99
174
  /* =========================================================================
100
175
  Token Generation
101
176
  ========================================================================= */
@@ -106,15 +181,16 @@
106
181
  return Array.from(arr, function (b) { return b.toString(16).padStart(2, "0"); }).join("");
107
182
  }
108
183
 
184
+ function generateId() {
185
+ return Math.random().toString(36).slice(2, 10);
186
+ }
187
+
109
188
  /* =========================================================================
110
189
  Step Navigation
111
190
  ========================================================================= */
112
191
 
113
- var TOTAL_STEPS = 5; // 0..4
114
-
115
192
  function goToStep(n) {
116
193
  if (n < 0 || n > TOTAL_STEPS - 1) return;
117
- // Hide all step sections
118
194
  for (var i = 0; i < TOTAL_STEPS; i++) {
119
195
  var sec = $("step-" + i);
120
196
  if (sec) { if (i === n) show(sec); else hide(sec); }
@@ -123,13 +199,14 @@
123
199
 
124
200
  currentStep = n;
125
201
  if (n > maxVisitedStep) maxVisitedStep = n;
126
- updateStepIndicators();
202
+ renderProgressBar();
127
203
 
128
- // Step-specific init
204
+ if (n === 0) initStep0();
129
205
  if (n === 1) initStep1();
130
206
  if (n === 2) initStep2();
131
207
  if (n === 3) initStep3();
132
208
  if (n === 4) initStep4();
209
+ if (n === 5) initStep5();
133
210
  }
134
211
 
135
212
  function showDeployScreen() {
@@ -138,34 +215,40 @@
138
215
  hide($("step-indicators"));
139
216
  }
140
217
 
141
- function updateStepIndicators() {
218
+ function renderProgressBar() {
142
219
  show($("step-indicators"));
143
- var dots = qsa(".step-dot");
144
- var lines = qsa(".step-line");
145
- dots.forEach(function (dot, i) {
146
- dot.classList.remove("active", "completed");
147
- dot.removeAttribute("aria-current");
148
- if (i === currentStep) {
149
- dot.classList.add("active");
150
- dot.setAttribute("aria-current", "step");
151
- dot.innerHTML = String(i + 1);
152
- } else if (i < maxVisitedStep || (i <= maxVisitedStep && i < currentStep)) {
153
- dot.classList.add("completed");
154
- dot.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
155
- dot.disabled = false;
156
- } else {
157
- dot.innerHTML = String(i + 1);
158
- dot.disabled = (i > maxVisitedStep);
159
- }
160
- });
161
- lines.forEach(function (line, i) {
162
- if (i < maxVisitedStep) line.classList.add("active");
163
- else line.classList.remove("active");
220
+ var segHTML = "";
221
+ var lblHTML = "";
222
+ for (var i = 0; i < TOTAL_STEPS; i++) {
223
+ segHTML += '<div class="prog-seg ' + (i <= currentStep ? "on" : "") + '"></div>';
224
+ var cls = "prog-lbl";
225
+ if (i <= currentStep) cls += " on";
226
+ if (i === currentStep) cls += " active";
227
+ lblHTML += '<span class="' + cls + '" data-prog-step="' + i + '">' + STEP_LABELS[i] + '</span>';
228
+ }
229
+ $("prog-segments").innerHTML = segHTML;
230
+ $("prog-labels").innerHTML = lblHTML;
231
+
232
+ // Bind label clicks
233
+ var labels = document.querySelectorAll("[data-prog-step]");
234
+ labels.forEach(function (lbl) {
235
+ lbl.addEventListener("click", function () {
236
+ var step = parseInt(lbl.dataset.progStep, 10);
237
+ if (isNaN(step) || step > maxVisitedStep) return;
238
+ if (step > currentStep) {
239
+ if (step >= 1 && !validateStep0()) return;
240
+ if (step >= 2 && getVerifiedCount() === 0) return;
241
+ if (step >= 3 && !validateStep2()) return;
242
+ // Step 3 (voice) has no hard validation gate
243
+ if (step >= 5 && !validateStep4()) return;
244
+ }
245
+ goToStep(step);
246
+ });
164
247
  });
165
248
  }
166
249
 
167
250
  /* =========================================================================
168
- Step 0: Welcome & Admin Token
251
+ Step 0: Welcome & Identity
169
252
  ========================================================================= */
170
253
 
171
254
  function initStep0() {
@@ -173,6 +256,14 @@
173
256
  if (tokenInput && !tokenInput.value) {
174
257
  tokenInput.value = generateToken();
175
258
  }
259
+ // Show hero or form based on state
260
+ if (!welcomeHeroDismissed) {
261
+ show($("welcome-hero"));
262
+ hide($("identity-form"));
263
+ } else {
264
+ hide($("welcome-hero"));
265
+ show($("identity-form"));
266
+ }
176
267
  }
177
268
 
178
269
  function validateStep0() {
@@ -187,458 +278,653 @@
187
278
  }
188
279
 
189
280
  /* =========================================================================
190
- Step 1: Connection Setup
281
+ Step 1: Provider Card Grid
191
282
  ========================================================================= */
192
283
 
193
284
  function initStep1() {
194
- renderConnHub();
195
- setConnView("hub");
285
+ renderProviderGrid();
196
286
  // Auto-detect on first visit
197
- if (detectedProviders.length === 0 && connections.length === 0) {
287
+ if (detectedProviders.length === 0 && getVerifiedCount() === 0) {
198
288
  detectProviders();
199
289
  }
200
290
  }
201
291
 
202
- function setConnView(view) {
203
- connView = view;
204
- var hubList = $("conn-hub-list");
205
- var hubEmpty = $("conn-hub-empty");
206
- var chooser = $("conn-type-chooser");
207
- var form = $("conn-detail-form");
208
- var actions = $("step1-actions");
209
-
210
- hide(hubList); hide(hubEmpty); hide(chooser); hide(form); hide(actions);
211
-
212
- if (view === "hub") {
213
- if (connections.length > 0) { show(hubList); } else { show(hubEmpty); }
214
- show(actions);
215
- $("btn-step1-next").disabled = connections.length === 0;
216
- } else if (view === "chooser") {
217
- show(chooser);
218
- } else if (view === "form") {
219
- show(form);
220
- }
292
+ function getVerifiedCount() {
293
+ var count = 0;
294
+ PROVIDERS.forEach(function (p) {
295
+ if (providerState[p.id].verified) count++;
296
+ });
297
+ return count;
221
298
  }
222
299
 
223
- function renderConnHub() {
224
- var list = $("conn-hub-list");
225
- list.innerHTML = "";
226
- connections.forEach(function (conn, i) {
227
- var li = document.createElement("li");
228
- li.className = "hub-row";
229
- li.innerHTML =
230
- '<div class="hub-row-info">' +
231
- '<span class="hub-row-name">' + esc(conn.name || conn.provider) + "</span>" +
232
- '<span class="hub-row-badge">' + (conn.kind === "local" ? "Local" : "Cloud") + "</span>" +
233
- '<span class="hub-row-url">' + esc(conn.baseUrl) + "</span>" +
234
- "</div>" +
235
- '<div class="hub-row-actions">' +
236
- '<button class="hub-action" data-action="edit" data-idx="' + i + '">Edit</button>' +
237
- '<button class="hub-action hub-action--danger" data-action="remove" data-idx="' + i + '">Remove</button>' +
238
- "</div>";
239
- list.appendChild(li);
300
+ function renderProviderGrid() {
301
+ var grid = $("provider-grid");
302
+ var html = "";
303
+
304
+ PROVIDER_GROUPS.forEach(function (g) {
305
+ var members = PROVIDERS.filter(function (p) { return p.group === g.id; })
306
+ .sort(function (a, b) { return a.order - b.order; });
307
+ if (members.length === 0) return;
308
+
309
+ html += '<div class="provider-group">';
310
+ html += '<div class="provider-group-header">';
311
+ html += '<h3 class="provider-group-label">' + esc(g.label) + '</h3>';
312
+ html += '<span class="provider-group-desc">' + esc(g.desc) + '</span>';
313
+ html += '</div>';
314
+ html += '<div class="provider-group-cards">';
315
+ members.forEach(function (p) { html += renderProviderCard(p); });
316
+ html += '</div></div>';
240
317
  });
241
- }
242
318
 
243
- function openAddConnection() {
244
- editingIdx = -1;
245
- setConnView("chooser");
246
- }
319
+ grid.innerHTML = html;
247
320
 
248
- function openConnectionForm(kind) {
249
- draftKind = kind;
250
- if (editingIdx >= 0) {
251
- var conn = connections[editingIdx];
252
- draftProvider = conn.provider;
253
- draftKind = conn.kind;
321
+ // Update nav info
322
+ var vc = getVerifiedCount();
323
+ var info = $("provider-count-info");
324
+ if (vc > 0) {
325
+ info.innerHTML = '<b>' + vc + '</b> provider' + (vc > 1 ? 's' : '') + ' ready';
254
326
  } else {
255
- draftProvider = kind === "cloud" ? "openai" : "ollama";
327
+ info.textContent = 'Connect at least one';
256
328
  }
257
- renderConnectionForm();
258
- setConnView("form");
329
+ $("btn-step1-next").disabled = vc === 0;
330
+
331
+ bindProviderEvents();
259
332
  }
260
333
 
261
- function renderConnectionForm() {
262
- var modeCard = $("conn-mode-card");
263
- var badge = $("conn-mode-badge");
264
- var title = $("conn-mode-title");
265
- var cloudPicks = $("cloud-provider-picks");
266
- var localList = $("local-provider-list");
267
- var apikeyGroup = $("conn-apikey-group");
268
-
269
- // Mode card styling
270
- modeCard.className = "connection-mode-card connection-mode-card--" + draftKind;
271
- if (draftKind === "cloud") {
272
- badge.textContent = "Cloud";
273
- title.textContent = "Remote provider";
274
- show(cloudPicks);
275
- hide(localList);
276
- show(apikeyGroup);
277
- } else {
278
- badge.textContent = "Local";
279
- title.textContent = "Local provider";
280
- hide(cloudPicks);
281
- show(localList);
282
- hide(apikeyGroup);
334
+ function renderProviderCard(p) {
335
+ var st = providerState[p.id];
336
+ var isExpanded = expandedProvider === p.id && st.selected;
337
+ var cls = "pcard";
338
+ if (st.selected) cls += " selected";
339
+ if (st.verified) cls += " verified";
340
+ if (isExpanded) cls += " wide";
341
+
342
+ var badgeCls = p.kind === "cloud" ? "badge-cloud" : p.kind === "local" ? "badge-local" : "badge-hybrid";
343
+ var vi = "";
344
+ if (st.verified) vi = '<span class="vs vs-ok">\u2713</span>';
345
+ else if (st.verifying) vi = '<span class="vs vs-wait">\u27F3</span>';
346
+ else if (st.error) vi = '<span class="vs vs-err">\u2717</span>';
347
+
348
+ var html = '<div class="' + cls + '" data-provider="' + p.id + '">';
349
+ html += '<div class="pcard-header" data-toggle-provider="' + p.id + '">';
350
+ html += '<div class="pcard-icon">' + esc(p.icon) + '</div>';
351
+ html += '<div class="pcard-info">';
352
+ html += '<div class="pcard-name">' + esc(p.name) + ' <span class="badge ' + badgeCls + '">' + p.kind + '</span>' + vi + '</div>';
353
+ html += '<div class="pcard-desc">' + esc(p.desc) + '</div>';
354
+ html += '</div>';
355
+ html += '<div class="pcard-check">' + (st.selected ? '\u2713' : '') + '</div>';
356
+ html += '</div>';
357
+
358
+ if (isExpanded) {
359
+ html += renderProviderAuth(p);
283
360
  }
284
361
 
285
- // Cloud provider chips
286
- cloudPicks.innerHTML = "";
287
- CLOUD_PROVIDERS.forEach(function (p) {
288
- var chip = document.createElement("button");
289
- chip.type = "button";
290
- chip.className = "provider-chip" + (p === draftProvider ? " selected" : "");
291
- chip.textContent = PROVIDER_LABELS[p] || p;
292
- chip.addEventListener("click", function () {
293
- draftProvider = p;
294
- applyProviderDefaults();
295
- renderConnectionForm();
296
- });
297
- cloudPicks.appendChild(chip);
298
- });
362
+ html += '</div>';
363
+ return html;
364
+ }
299
365
 
300
- // Local detected providers
301
- localList.innerHTML = "";
302
- var localDetected = detectedProviders.filter(function (d) { return d.available; });
303
- if (localDetected.length > 0) {
304
- localDetected.forEach(function (dp) {
305
- var opt = document.createElement("button");
306
- opt.type = "button";
307
- opt.className = "provider-option" + (dp.provider === draftProvider ? " selected" : "");
308
- opt.innerHTML =
309
- '<span class="provider-option-status"><span class="status-dot--ok"></span></span>' +
310
- '<span class="provider-option-label">' + esc(PROVIDER_LABELS[dp.provider] || dp.provider) + "</span>" +
311
- '<span class="provider-option-hint">Detected at ' + esc(dp.url) + "</span>";
312
- opt.addEventListener("click", function () {
313
- draftProvider = dp.provider;
314
- $("conn-base-url").value = dp.url;
315
- $("conn-name").value = PROVIDER_LABELS[dp.provider] || dp.provider;
316
- renderConnectionForm();
317
- });
318
- localList.appendChild(opt);
319
- });
366
+ function renderProviderAuth(p) {
367
+ var st = providerState[p.id];
368
+ var html = '<div class="pcard-auth">';
369
+
370
+ if (p.id === "ollama") {
371
+ // Ollama: show mode selector first
372
+ if (!st.ollamaMode) {
373
+ html += '<div class="ollama-mode-prompt">';
374
+ html += '<p>Is Ollama already running on this machine?</p>';
375
+ html += '<div class="ollama-mode-buttons">';
376
+ html += '<button class="ollama-mode-btn ollama-mode-btn-detect" data-ollama-mode="running">Yes, detect it</button>';
377
+ html += '<button class="ollama-mode-btn ollama-mode-btn-stack" data-ollama-mode="instack">No, add to stack</button>';
378
+ html += '</div></div>';
379
+ } else if (st.ollamaMode === "running") {
380
+ html += '<div class="auth-row">';
381
+ html += '<input type="url" placeholder="' + esc(p.baseUrl) + '" value="' + esc(st.baseUrl || p.baseUrl) + '" data-auth-url="' + p.id + '">';
382
+ html += '<button class="auth-btn ' + (st.verified ? 'auth-btn-detected' : 'auth-btn-detect') + '" data-auth-verify="' + p.id + '" ' + (st.verifying ? 'disabled' : '') + '>';
383
+ html += st.verifying ? 'Detecting...' : st.verified ? 'Connected \u2713' : 'Detect';
384
+ html += '</button></div>';
385
+ } else {
386
+ // instack mode
387
+ if (st.verified) {
388
+ html += '<div class="auth-feedback auth-feedback-ok">Ollama will be added to your Docker stack with default models.</div>';
389
+ } else {
390
+ html += '<div class="ollama-mode-prompt">';
391
+ html += '<p>Ollama runs as a container in your stack with recommended models pre-configured.</p>';
392
+ html += '<button class="auth-btn auth-btn-detect" data-auth-verify="' + p.id + '" style="margin-top:4px">Enable Ollama</button>';
393
+ html += '</div>';
394
+ }
395
+ }
396
+ } else if (p.needsUrl) {
397
+ // Custom provider: URL (required) + optional API key
398
+ html += '<div class="auth-row">';
399
+ html += '<input type="url" placeholder="https://your-server.example/v1" value="' + esc(st.baseUrl || '') + '" data-auth-url="' + p.id + '">';
400
+ html += '</div>';
401
+ if (p.optionalKey) {
402
+ html += '<div class="auth-row" style="margin-top:6px">';
403
+ html += '<input type="password" placeholder="' + esc(p.placeholder || 'API key (optional)') + '" value="' + esc(st.apiKey) + '" data-auth-key="' + p.id + '">';
404
+ html += '</div>';
405
+ }
406
+ html += '<div class="auth-row" style="margin-top:6px">';
407
+ html += '<button class="auth-btn ' + (st.verified ? 'auth-btn-verified' : 'auth-btn-verify') + '" data-auth-verify="' + p.id + '" ' + (st.verifying ? 'disabled' : '') + '>';
408
+ html += st.verifying ? 'Checking...' : st.verified ? 'Connected \u2713' : 'Connect';
409
+ html += '</button></div>';
410
+ } else if (p.needsKey) {
411
+ // Cloud provider: API key + verify
412
+ html += '<div class="auth-row">';
413
+ html += '<input type="password" placeholder="' + esc(p.placeholder || 'API key') + '" value="' + esc(st.apiKey) + '" data-auth-key="' + p.id + '">';
414
+ html += '<button class="auth-btn ' + (st.verified ? 'auth-btn-verified' : 'auth-btn-verify') + '" data-auth-verify="' + p.id + '" ' + (st.verifying ? 'disabled' : '') + '>';
415
+ html += st.verifying ? 'Checking...' : st.verified ? 'Verified \u2713' : 'Verify';
416
+ html += '</button></div>';
320
417
  } else {
321
- // Fallback: let user pick local provider
322
- LOCAL_PROVIDERS.forEach(function (p) {
323
- var opt = document.createElement("button");
324
- opt.type = "button";
325
- opt.className = "provider-option" + (p === draftProvider ? " selected" : "");
326
- opt.innerHTML =
327
- '<span class="provider-option-label">' + esc(PROVIDER_LABELS[p] || p) + "</span>";
328
- opt.addEventListener("click", function () {
329
- draftProvider = p;
330
- applyProviderDefaults();
331
- renderConnectionForm();
332
- });
333
- localList.appendChild(opt);
334
- });
418
+ // Local provider with URL
419
+ html += '<div class="auth-row">';
420
+ html += '<input type="url" placeholder="' + esc(p.baseUrl || 'http://localhost:8080') + '" value="' + esc(st.baseUrl || p.baseUrl || '') + '" data-auth-url="' + p.id + '">';
421
+ html += '<button class="auth-btn ' + (st.verified ? 'auth-btn-detected' : 'auth-btn-detect') + '" data-auth-verify="' + p.id + '" ' + (st.verifying ? 'disabled' : '') + '>';
422
+ html += st.verifying ? 'Detecting...' : st.verified ? 'Connected \u2713' : 'Detect';
423
+ html += '</button></div>';
335
424
  }
336
425
 
337
- // Fill defaults if new connection
338
- if (editingIdx < 0) {
339
- applyProviderDefaults();
340
- } else {
341
- var c = connections[editingIdx];
342
- $("conn-name").value = c.name;
343
- $("conn-base-url").value = c.baseUrl;
344
- $("conn-api-key").value = c.apiKey;
426
+ // Feedback messages
427
+ if (st.verified && p.id !== "ollama") {
428
+ html += '<div class="auth-feedback auth-feedback-ok">Credentials verified</div>';
429
+ } else if (st.error) {
430
+ var errMsg = st.errorMessage ? esc(st.errorMessage) : 'check your ' + (p.needsKey ? 'credentials' : 'endpoint');
431
+ html += '<div class="auth-feedback auth-feedback-err">Verification failed -- ' + errMsg + '</div>';
345
432
  }
346
433
 
347
- // Reset test status
348
- hide($("conn-test-success"));
349
- hideError($("conn-detail-error"));
434
+ html += '</div>';
435
+ return html;
350
436
  }
351
437
 
352
- function applyProviderDefaults() {
353
- var def = PROVIDER_DEFAULTS[draftProvider];
354
- if (!def) return;
355
- var nameInput = $("conn-name");
356
- var urlInput = $("conn-base-url");
357
- if (!nameInput.value || isDefaultName(nameInput.value)) {
358
- nameInput.value = PROVIDER_LABELS[draftProvider] || draftProvider;
359
- }
360
- urlInput.value = def.baseUrl;
361
- $("conn-api-key").value = "";
362
- }
363
-
364
- function isDefaultName(name) {
365
- for (var key in PROVIDER_LABELS) {
366
- if (PROVIDER_LABELS[key] === name) return true;
367
- }
368
- return false;
369
- }
438
+ function bindProviderEvents() {
439
+ // Card header toggle (select/expand)
440
+ document.querySelectorAll("[data-toggle-provider]").forEach(function (el) {
441
+ el.addEventListener("click", function (e) {
442
+ var id = el.dataset.toggleProvider;
443
+ var st = providerState[id];
444
+ if (st.selected) {
445
+ // Already selected: toggle expand
446
+ expandedProvider = expandedProvider === id ? null : id;
447
+ } else {
448
+ // Select and expand
449
+ st.selected = true;
450
+ expandedProvider = id;
451
+ // Auto-fill from detection
452
+ var detected = detectedProviders.find(function (d) { return d.provider === id && d.available; });
453
+ if (detected) {
454
+ st.baseUrl = detected.url;
455
+ }
456
+ }
457
+ renderProviderGrid();
458
+ });
459
+ });
370
460
 
371
- function saveConnection() {
372
- var errEl = $("conn-detail-error");
373
- hideError(errEl);
461
+ // Check icon: deselect provider
462
+ document.querySelectorAll(".pcard-check").forEach(function (el) {
463
+ el.addEventListener("click", function (e) {
464
+ e.stopPropagation();
465
+ var card = el.closest("[data-provider]");
466
+ if (!card) return;
467
+ var id = card.dataset.provider;
468
+ var st = providerState[id];
469
+ if (st.selected) {
470
+ st.selected = false;
471
+ st.verified = false;
472
+ st.verifying = false;
473
+ st.error = false;
474
+ st.apiKey = "";
475
+ st.models = [];
476
+ if (id === "ollama") st.ollamaMode = null;
477
+ if (expandedProvider === id) expandedProvider = null;
478
+ renderProviderGrid();
479
+ }
480
+ });
481
+ });
374
482
 
375
- var name = ($("conn-name").value || "").trim();
376
- var baseUrl = ($("conn-base-url").value || "").trim();
377
- var apiKey = ($("conn-api-key").value || "").trim();
483
+ // Auth inputs (don't re-render on typing)
484
+ document.querySelectorAll("[data-auth-key]").forEach(function (el) {
485
+ el.addEventListener("input", function () {
486
+ providerState[el.dataset.authKey].apiKey = el.value;
487
+ });
488
+ el.addEventListener("click", function (e) { e.stopPropagation(); });
489
+ });
378
490
 
379
- if (!name) { showError(errEl, "Connection name is required."); return; }
380
- if (!baseUrl) { showError(errEl, "Base URL is required."); return; }
381
- if (draftKind === "cloud" && !apiKey && draftProvider !== "anthropic") {
382
- showError(errEl, "API key is required for cloud providers.");
383
- return;
384
- }
491
+ document.querySelectorAll("[data-auth-url]").forEach(function (el) {
492
+ el.addEventListener("input", function () {
493
+ providerState[el.dataset.authUrl].baseUrl = el.value;
494
+ });
495
+ el.addEventListener("click", function (e) { e.stopPropagation(); });
496
+ });
385
497
 
386
- var conn = {
387
- id: editingIdx >= 0 ? connections[editingIdx].id : generateId(),
388
- name: name,
389
- provider: draftProvider,
390
- baseUrl: baseUrl,
391
- apiKey: apiKey,
392
- kind: draftKind,
393
- models: editingIdx >= 0 ? connections[editingIdx].models : [],
394
- };
498
+ // Verify buttons
499
+ document.querySelectorAll("[data-auth-verify]").forEach(function (el) {
500
+ el.addEventListener("click", function (e) {
501
+ e.stopPropagation();
502
+ verifyProvider(el.dataset.authVerify);
503
+ });
504
+ });
395
505
 
396
- if (editingIdx >= 0) {
397
- connections[editingIdx] = conn;
398
- } else {
399
- connections.push(conn);
400
- }
506
+ // Ollama mode buttons
507
+ document.querySelectorAll("[data-ollama-mode]").forEach(function (el) {
508
+ el.addEventListener("click", function (e) {
509
+ e.stopPropagation();
510
+ var mode = el.dataset.ollamaMode;
511
+ providerState.ollama.ollamaMode = mode;
512
+ renderProviderGrid();
513
+ });
514
+ });
401
515
 
402
- editingIdx = -1;
403
- renderConnHub();
404
- setConnView("hub");
405
516
  }
406
517
 
407
- function removeConnection(idx) {
408
- connections.splice(idx, 1);
409
- renderConnHub();
410
- setConnView("hub");
411
- }
518
+ /** Monotonic counter to discard stale verification results */
519
+ var verifyGeneration = {};
412
520
 
413
- function editConnectionAt(idx) {
414
- editingIdx = idx;
415
- var conn = connections[idx];
416
- draftKind = conn.kind;
417
- draftProvider = conn.provider;
418
- renderConnectionForm();
419
- setConnView("form");
420
- }
521
+ async function verifyProvider(id) {
522
+ var p = PROVIDERS.find(function (x) { return x.id === id; });
523
+ if (!p) return;
524
+ var st = providerState[id];
421
525
 
422
- /** Test connection by fetching models */
423
- async function testConnection() {
424
- var errEl = $("conn-detail-error");
425
- hideError(errEl);
426
- hide($("conn-test-success"));
526
+ // For ollama instack mode, just mark verified
527
+ if (id === "ollama" && st.ollamaMode === "instack") {
528
+ st.verified = true;
529
+ st.error = false;
530
+ renderProviderGrid();
531
+ return;
532
+ }
427
533
 
428
- var baseUrl = ($("conn-base-url").value || "").trim();
429
- var apiKey = ($("conn-api-key").value || "").trim();
430
- if (!baseUrl) { showError(errEl, "Base URL is required."); return; }
534
+ // Bump generation so any in-flight verify for this provider is ignored
535
+ var gen = (verifyGeneration[id] || 0) + 1;
536
+ verifyGeneration[id] = gen;
431
537
 
432
- var btn = $("btn-conn-test");
433
- btn.disabled = true;
434
- btn.innerHTML = '<span class="spinner"></span> Testing...';
538
+ st.verifying = true;
539
+ st.error = false;
540
+ renderProviderGrid();
541
+
542
+ var baseUrl = st.baseUrl || p.baseUrl;
543
+ var apiKey = st.apiKey || "";
435
544
 
436
545
  try {
437
- var models = await apiFetchModels(draftProvider, baseUrl, apiKey);
438
- if (models && models.length > 0) {
439
- show($("conn-test-success"));
440
- $("conn-test-msg").textContent = "Connected -- " + models.length + " model" + (models.length !== 1 ? "s" : "") + " found.";
441
- // Store models for this draft
442
- if (editingIdx >= 0) {
443
- connections[editingIdx].models = models;
444
- } else {
445
- // Will store when saved
446
- modelCache["_draft"] = models;
447
- }
448
- } else {
449
- show($("conn-test-success"));
450
- $("conn-test-msg").textContent = "Connected (no models listed).";
451
- }
546
+ var result = await apiFetchModels(id, baseUrl, apiKey);
547
+ // Discard if a newer verify was started while we were waiting
548
+ if (verifyGeneration[id] !== gen) return;
549
+ st.verified = true;
550
+ st.error = false;
551
+ st.models = result.models || [];
452
552
  } catch (e) {
453
- showError(errEl, "Connection failed: " + (e.message || "unknown error"));
553
+ if (verifyGeneration[id] !== gen) return;
554
+ st.verified = false;
555
+ st.error = true;
556
+ st.errorMessage = e.message || "";
557
+ st.models = [];
454
558
  }
455
559
 
456
- btn.disabled = false;
457
- btn.textContent = "Test";
458
- }
459
-
460
- function generateId() {
461
- return Math.random().toString(36).slice(2, 10);
560
+ st.verifying = false;
561
+ renderProviderGrid();
462
562
  }
463
563
 
464
564
  /* =========================================================================
465
- Step 2: Model Assignment
565
+ Step 2: Model Assignment (Radio Options)
466
566
  ========================================================================= */
467
567
 
468
568
  function initStep2() {
469
- populateConnectionSelects();
569
+ buildModelOptions();
570
+ }
571
+
572
+ function getVerifiedProviders() {
573
+ return PROVIDERS.filter(function (p) { return providerState[p.id].verified; });
470
574
  }
471
575
 
472
- function populateConnectionSelects() {
473
- var llmSel = $("llm-connection");
474
- var embSel = $("emb-connection");
475
- var prevLlm = llmSel.value;
476
- var prevEmb = embSel.value;
477
-
478
- // Rebuild options
479
- llmSel.innerHTML = '<option value="" disabled>Select a connection</option>';
480
- embSel.innerHTML = '<option value="" disabled>Select a connection</option>';
481
-
482
- connections.forEach(function (conn) {
483
- var o1 = document.createElement("option");
484
- o1.value = conn.id;
485
- o1.textContent = conn.name || conn.provider;
486
- llmSel.appendChild(o1);
487
-
488
- var o2 = document.createElement("option");
489
- o2.value = conn.id;
490
- o2.textContent = conn.name || conn.provider;
491
- embSel.appendChild(o2);
576
+ function getAllModels() {
577
+ var result = [];
578
+ getVerifiedProviders().forEach(function (p) {
579
+ var st = providerState[p.id];
580
+ st.models.forEach(function (m) {
581
+ result.push({ id: m, provider: p.id, providerName: p.name, baseUrl: st.baseUrl || p.baseUrl, apiKey: st.apiKey });
582
+ });
492
583
  });
584
+ return result;
585
+ }
493
586
 
494
- // Restore or auto-select
495
- if (prevLlm && connections.some(function (c) { return c.id === prevLlm; })) {
496
- llmSel.value = prevLlm;
497
- } else if (connections.length > 0) {
498
- llmSel.value = connections[0].id;
499
- }
587
+ var MAX_VISIBLE_MODELS = 6;
588
+
589
+ function buildModelOptions() {
590
+ var allModels = getAllModels();
591
+ var verifiedProviders = getVerifiedProviders();
592
+ var groupsEl = $("model-groups");
593
+
594
+ // Define model roles
595
+ var roles = [
596
+ { id: "llm", label: "Chat Model (LLM)", tag: "required", desc: "Conversations, reasoning, and code" },
597
+ { id: "embedding", label: "Embedding Model", tag: "optional", desc: "Memory search and recall" },
598
+ { id: "small", label: "Small Model", tag: "optional", desc: "Lightweight tasks like memory extraction" },
599
+ ];
600
+
601
+ var html = "";
602
+
603
+ roles.forEach(function (role) {
604
+ // Build options for this role from each verified provider's models
605
+ var options = [];
606
+ verifiedProviders.forEach(function (p) {
607
+ var st = providerState[p.id];
608
+ var defaultModel = role.id === "embedding" ? p.embModel : p.llmModel;
609
+ var models = st.models.length > 0 ? st.models : [];
610
+
611
+ // Add the default model as top pick if in the list
612
+ if (defaultModel && models.indexOf(defaultModel) >= 0) {
613
+ options.push({
614
+ id: defaultModel,
615
+ connId: p.id,
616
+ providerName: p.name,
617
+ baseUrl: st.baseUrl || p.baseUrl,
618
+ isDefault: true,
619
+ dims: role.id === "embedding" ? (KNOWN_EMB_DIMS[defaultModel] || KNOWN_EMB_DIMS[defaultModel.replace(/:.*$/, "")] || p.embDims || 0) : 0,
620
+ });
621
+ }
500
622
 
501
- if (prevEmb && connections.some(function (c) { return c.id === prevEmb; })) {
502
- embSel.value = prevEmb;
503
- } else if (connections.length > 0) {
504
- embSel.value = connections[0].id;
505
- }
623
+ // Add other models
624
+ models.forEach(function (m) {
625
+ if (m === defaultModel) return; // Already added above
626
+ var dims = 0;
627
+ if (role.id === "embedding") {
628
+ dims = KNOWN_EMB_DIMS[m] || KNOWN_EMB_DIMS[m.replace(/:.*$/, "")] || 0;
629
+ }
630
+ options.push({
631
+ id: m,
632
+ connId: p.id,
633
+ providerName: p.name,
634
+ baseUrl: st.baseUrl || p.baseUrl,
635
+ isDefault: false,
636
+ dims: dims,
637
+ });
638
+ });
639
+ });
506
640
 
507
- // Fetch models for selected connections
508
- if (llmSel.value) loadModelsForConnection(llmSel.value, "llm");
509
- if (embSel.value) loadModelsForConnection(embSel.value, "emb");
510
- }
641
+ // For embedding role, filter to models with known dims, plus provider defaults
642
+ if (role.id === "embedding") {
643
+ var embOptions = options.filter(function (o) {
644
+ return o.isDefault || o.dims > 0;
645
+ });
646
+ if (embOptions.length > 0) options = embOptions;
647
+ }
511
648
 
512
- async function loadModelsForConnection(connId, target) {
513
- var conn = connections.find(function (c) { return c.id === connId; });
514
- if (!conn) return;
515
-
516
- var modelSel = $(target + "-model");
517
- var smallSel = target === "llm" ? $("llm-small-model") : null;
518
-
519
- // Try cached models first
520
- var models = conn.models && conn.models.length > 0
521
- ? conn.models
522
- : (modelCache[connId] || null);
523
-
524
- if (!models) {
525
- modelSel.innerHTML = '<option value="">Fetching models...</option>';
526
- if (smallSel) smallSel.innerHTML = '<option value="">(loading...)</option>';
527
- try {
528
- models = await apiFetchModels(conn.provider, conn.baseUrl, conn.apiKey);
529
- conn.models = models;
530
- modelCache[connId] = models;
531
- } catch (e) {
532
- models = [];
649
+ // Small model: same as LLM options but with "(same as chat)" default
650
+ if (role.id === "small" && options.length === 0) {
651
+ // Use llm options
652
+ var llmProvider = verifiedProviders[0];
653
+ if (llmProvider) {
654
+ providerState[llmProvider.id].models.forEach(function (m) {
655
+ options.push({
656
+ id: m,
657
+ connId: llmProvider.id,
658
+ providerName: llmProvider.name,
659
+ baseUrl: providerState[llmProvider.id].baseUrl || llmProvider.baseUrl,
660
+ isDefault: false,
661
+ dims: 0,
662
+ });
663
+ });
664
+ }
533
665
  }
534
- }
535
666
 
536
- var def = PROVIDER_DEFAULTS[conn.provider] || {};
537
- var prevVal = modelSel.value;
667
+ if (options.length === 0 && role.id !== "small") return;
538
668
 
539
- if (models.length > 0) {
540
- modelSel.innerHTML = "";
541
- models.forEach(function (m) {
542
- var o = document.createElement("option");
543
- o.value = m;
544
- o.textContent = m;
545
- modelSel.appendChild(o);
546
- });
669
+ // Auto-select default
670
+ if (!modelSelection[role.id] && options.length > 0) {
671
+ var defaultOpt = options.find(function (o) { return o.isDefault; }) || options[0];
672
+ if (defaultOpt) {
673
+ modelSelection[role.id] = { connId: defaultOpt.connId, model: defaultOpt.id, dims: defaultOpt.dims };
674
+ }
675
+ }
547
676
 
548
- // Select best default
549
- var preferred;
550
- if (target === "llm") {
551
- preferred = def.llmModel;
552
- } else {
553
- preferred = def.embModel;
677
+ html += '<div class="model-group">';
678
+ html += '<div class="model-group-header">';
679
+ html += '<span class="model-group-title">' + role.label + '</span>';
680
+ html += '<span class="model-group-tag ' + (role.tag === "required" ? "model-group-tag-required" : "model-group-tag-optional") + '">' + role.tag + '</span>';
681
+ html += '</div>';
682
+ html += '<div class="model-group-desc">' + role.desc + '</div>';
683
+
684
+ if (role.id === "small") {
685
+ // Add a "same as chat" option
686
+ var smallSel = modelSelection.small;
687
+ var noneOn = !smallSel || !smallSel.model;
688
+ html += '<div class="model-opt ' + (noneOn ? 'on' : '') + '" data-model-select="small:">';
689
+ html += '<div class="model-opt-dot"><div class="model-opt-dot-inner"></div></div>';
690
+ html += '<div style="flex:1"><div class="model-opt-name">(same as chat model)</div>';
691
+ html += '<div class="model-opt-meta">No separate small model</div></div>';
692
+ html += '<span class="model-opt-badge model-opt-badge-auto">Default</span>';
693
+ html += '</div>';
554
694
  }
555
- if (preferred && models.indexOf(preferred) >= 0) {
556
- modelSel.value = preferred;
557
- } else if (prevVal && models.indexOf(prevVal) >= 0) {
558
- modelSel.value = prevVal;
695
+
696
+ var hasOverflow = options.length > MAX_VISIBLE_MODELS;
697
+ var filterId = "model-filter-" + role.id;
698
+
699
+ // Search filter for long model lists
700
+ if (hasOverflow) {
701
+ html += '<div class="model-filter-row">';
702
+ html += '<input type="text" class="model-filter-input" id="' + filterId + '" placeholder="Search ' + options.length + ' models\u2026" autocomplete="off">';
703
+ html += '</div>';
559
704
  }
560
705
 
561
- // Also fill small model
562
- if (smallSel) {
563
- smallSel.innerHTML = '<option value="">(same as chat model)</option>';
564
- models.forEach(function (m) {
565
- var o = document.createElement("option");
566
- o.value = m;
567
- o.textContent = m;
568
- smallSel.appendChild(o);
706
+ options.forEach(function (opt, idx) {
707
+ var sel = modelSelection[role.id];
708
+ var isOn = sel && sel.model === opt.id && sel.connId === opt.connId;
709
+ var meta = "via " + opt.providerName;
710
+ if (opt.dims > 0) meta += " \u00B7 " + opt.dims + "d";
711
+
712
+ // Hide items beyond MAX_VISIBLE_MODELS unless selected — filter will reveal them
713
+ var isHidden = hasOverflow && idx >= MAX_VISIBLE_MODELS && !isOn;
714
+
715
+ html += '<div class="model-opt ' + (isOn ? 'on' : '') + (isHidden ? ' model-opt-filtered' : '') + '" data-model-select="' + role.id + ':' + opt.connId + ':' + esc(opt.id) + ':' + opt.dims + '" data-model-name="' + esc(opt.id.toLowerCase()) + '">';
716
+ html += '<div class="model-opt-dot"><div class="model-opt-dot-inner"></div></div>';
717
+ html += '<div style="flex:1;min-width:0"><div class="model-opt-name">' + esc(opt.id) + '</div>';
718
+ html += '<div class="model-opt-meta">' + esc(meta) + '</div></div>';
719
+ if (idx === 0 && opt.isDefault) {
720
+ html += '<span class="model-opt-badge model-opt-badge-top">Top Pick</span>';
721
+ }
722
+ html += '</div>';
723
+ });
724
+
725
+ html += '</div>';
726
+ });
727
+
728
+ groupsEl.innerHTML = html;
729
+
730
+ // Sync hidden fields for backward compat
731
+ syncHiddenModelFields();
732
+
733
+ // Bind model filter inputs
734
+ document.querySelectorAll(".model-filter-input").forEach(function (input) {
735
+ input.addEventListener("input", function () {
736
+ var query = input.value.toLowerCase().trim();
737
+ var group = input.closest(".model-group");
738
+ if (!group) return;
739
+ var opts = group.querySelectorAll("[data-model-name]");
740
+ var shown = 0;
741
+ opts.forEach(function (el, idx) {
742
+ var name = el.dataset.modelName || "";
743
+ if (query) {
744
+ // When filtering, show all matches
745
+ var match = name.indexOf(query) >= 0;
746
+ el.classList.toggle("model-opt-filtered", !match);
747
+ if (match) shown++;
748
+ } else {
749
+ // No query — show top MAX_VISIBLE_MODELS + selected
750
+ var isOn = el.classList.contains("on");
751
+ el.classList.toggle("model-opt-filtered", idx >= MAX_VISIBLE_MODELS && !isOn);
752
+ shown++;
753
+ }
569
754
  });
570
- }
755
+ });
756
+ });
571
757
 
572
- // Auto-fill embedding dims
573
- if (target === "emb") {
574
- autoFillEmbDims(modelSel.value);
575
- }
576
- } else {
577
- // No models found -- allow manual entry
578
- modelSel.innerHTML = "";
579
- var manualOpt = document.createElement("option");
580
- manualOpt.value = "";
581
- manualOpt.textContent = "(enter model name below)";
582
- modelSel.appendChild(manualOpt);
583
-
584
- // Replace with text input if needed
585
- replaceWithTextInput(modelSel, target + "-model", target === "emb" ? (def.embModel || "") : (def.llmModel || ""));
586
- if (smallSel && target === "llm") {
587
- replaceWithTextInput(smallSel, "llm-small-model", "");
588
- }
589
- }
758
+ // Bind model option clicks
759
+ document.querySelectorAll("[data-model-select]").forEach(function (el) {
760
+ el.addEventListener("click", function () {
761
+ var parts = el.dataset.modelSelect.split(":");
762
+ var role = parts[0];
763
+ if (parts.length < 2 || !parts[1]) {
764
+ // "same as chat" for small model
765
+ delete modelSelection[role];
766
+ } else {
767
+ var connId = parts[1];
768
+ var modelId = parts.slice(2, -1).join(":"); // Model id may contain colons
769
+ var dims = parseInt(parts[parts.length - 1], 10) || 0;
770
+ modelSelection[role] = { connId: connId, model: modelId, dims: dims };
771
+ }
772
+ buildModelOptions();
773
+ });
774
+ });
590
775
  }
591
776
 
592
- function replaceWithTextInput(selectEl, id, defaultVal) {
593
- var parent = selectEl.parentNode;
594
- var input = document.createElement("input");
595
- input.type = "text";
596
- input.id = id;
597
- input.value = defaultVal;
598
- input.placeholder = "Enter model name";
599
- parent.replaceChild(input, selectEl);
600
-
601
- if (id === "emb-model") {
602
- input.addEventListener("input", function () { autoFillEmbDims(input.value); });
603
- }
604
- }
777
+ function syncHiddenModelFields() {
778
+ var llm = modelSelection.llm;
779
+ var emb = modelSelection.embedding;
780
+ var small = modelSelection.small;
605
781
 
606
- function autoFillEmbDims(modelName) {
607
- if (!modelName) return;
608
- var name = modelName.replace(/:.*$/, ""); // strip tag
609
- var dims = KNOWN_EMB_DIMS[modelName] || KNOWN_EMB_DIMS[name];
610
- if (dims) {
611
- $("emb-dims").value = dims;
782
+ if (llm) {
783
+ $("llm-connection").value = llm.connId;
784
+ $("llm-model").value = llm.model;
612
785
  }
786
+ if (emb) {
787
+ $("emb-connection").value = emb.connId;
788
+ $("emb-model").value = emb.model;
789
+ $("emb-dims").value = emb.dims || 1536;
790
+ }
791
+ $("llm-small-model").value = small ? small.model : "";
613
792
  }
614
793
 
615
794
  function validateStep2() {
616
795
  var errEl = $("step2-error");
617
796
  hideError(errEl);
618
797
 
619
- var llmConn = $("llm-connection").value;
620
- var llmModel = ($("llm-model").value || "").trim();
621
- var embConn = $("emb-connection").value;
622
- var embModel = ($("emb-model").value || "").trim();
798
+ var llm = modelSelection.llm;
799
+ var emb = modelSelection.embedding;
623
800
 
624
- if (!llmConn) { showError(errEl, "Select a chat connection."); return false; }
625
- if (!llmModel) { showError(errEl, "Chat model is required."); return false; }
626
- if (!embConn) { showError(errEl, "Select an embedding connection."); return false; }
627
- if (!embModel) { showError(errEl, "Embedding model is required."); return false; }
801
+ if (!llm || !llm.model) {
802
+ showError(errEl, "Select a chat model.");
803
+ return false;
804
+ }
805
+ if (!emb || !emb.model) {
806
+ showError(errEl, "Select an embedding model.");
807
+ return false;
808
+ }
628
809
  return true;
629
810
  }
630
811
 
631
812
  /* =========================================================================
632
- Step 3: Options
813
+ Step 3: Voice (TTS / STT)
633
814
  ========================================================================= */
634
815
 
816
+ function getVoiceDefaults() {
817
+ var hasOpenAI = PROVIDERS.some(function (p) {
818
+ return p.id === "openai" && providerState[p.id].verified;
819
+ });
820
+ if (hasOpenAI) return { tts: "openai-tts", stt: "openai-stt" };
821
+ return { tts: "browser-tts", stt: "browser-stt" };
822
+ }
823
+
824
+ function activeTts() { return voiceSelection.tts || getVoiceDefaults().tts; }
825
+ function activeStt() { return voiceSelection.stt || getVoiceDefaults().stt; }
826
+
635
827
  function initStep3() {
636
- // Show Ollama toggle if any connection uses Ollama
637
- var hasOllama = connections.some(function (c) {
638
- return c.provider === "ollama" || c.provider === "ollama-instack";
828
+ renderVoiceStep();
829
+ }
830
+
831
+ function renderVoiceStep() {
832
+ var container = $("voice-groups");
833
+ var curTts = activeTts();
834
+ var curStt = activeStt();
835
+ var hasOpenAI = PROVIDERS.some(function (p) {
836
+ return p.id === "openai" && providerState[p.id].verified;
837
+ });
838
+
839
+ var hint = hasOpenAI
840
+ ? "OpenAI selected as voice defaults. Kokoro and Whisper recommended for better quality."
841
+ : "Browser voice works out of the box. Kokoro and Whisper recommended for higher quality.";
842
+
843
+ var html = '<p class="voice-hint">' + esc(hint) + '</p>';
844
+
845
+ // TTS group
846
+ html += '<div class="model-group">';
847
+ html += '<div class="model-group-header">';
848
+ html += '<span class="model-group-title">Text-to-Speech</span>';
849
+ html += '<span class="model-group-tag model-group-tag-optional">Optional</span>';
850
+ html += '</div>';
851
+ html += '<div class="model-group-desc">How your assistant speaks</div>';
852
+
853
+ TTS_OPTIONS.forEach(function (o) {
854
+ var isOn = curTts === o.id;
855
+ var defs = getVoiceDefaults();
856
+ var badge = "";
857
+ if (o.recommended) badge = '<span class="model-opt-badge model-opt-badge-top">Recommended</span>';
858
+ else if (defs.tts === o.id && !voiceSelection.tts) badge = '<span class="model-opt-badge model-opt-badge-auto">Auto</span>';
859
+
860
+ html += '<div class="model-opt ' + (isOn ? "on" : "") + '" data-voice-select="tts:' + o.id + '">';
861
+ html += '<div class="model-opt-dot"><div class="model-opt-dot-inner"></div></div>';
862
+ html += '<div style="flex:1;min-width:0"><div class="model-opt-name">' + esc(o.name) + '</div>';
863
+ html += '<div class="model-opt-meta">' + esc(o.desc) + '</div></div>';
864
+ html += badge;
865
+ html += '</div>';
866
+ });
867
+ html += '</div>';
868
+
869
+ // STT group
870
+ html += '<div class="model-group">';
871
+ html += '<div class="model-group-header">';
872
+ html += '<span class="model-group-title">Speech-to-Text</span>';
873
+ html += '<span class="model-group-tag model-group-tag-optional">Optional</span>';
874
+ html += '</div>';
875
+ html += '<div class="model-group-desc">How your assistant hears you</div>';
876
+
877
+ STT_OPTIONS.forEach(function (o) {
878
+ var isOn = curStt === o.id;
879
+ var defs = getVoiceDefaults();
880
+ var badge = "";
881
+ if (o.recommended) badge = '<span class="model-opt-badge model-opt-badge-top">Recommended</span>';
882
+ else if (defs.stt === o.id && !voiceSelection.stt) badge = '<span class="model-opt-badge model-opt-badge-auto">Auto</span>';
883
+
884
+ html += '<div class="model-opt ' + (isOn ? "on" : "") + '" data-voice-select="stt:' + o.id + '">';
885
+ html += '<div class="model-opt-dot"><div class="model-opt-dot-inner"></div></div>';
886
+ html += '<div style="flex:1;min-width:0"><div class="model-opt-name">' + esc(o.name) + '</div>';
887
+ html += '<div class="model-opt-meta">' + esc(o.desc) + '</div></div>';
888
+ html += badge;
889
+ html += '</div>';
890
+ });
891
+ html += '</div>';
892
+
893
+ container.innerHTML = html;
894
+
895
+ // Bind voice option clicks
896
+ document.querySelectorAll("[data-voice-select]").forEach(function (el) {
897
+ el.addEventListener("click", function () {
898
+ var parts = el.dataset.voiceSelect.split(":");
899
+ var kind = parts[0]; // "tts" or "stt"
900
+ var id = parts[1];
901
+ if (kind === "tts") voiceSelection.tts = id;
902
+ if (kind === "stt") voiceSelection.stt = id;
903
+ renderVoiceStep();
904
+ });
905
+ });
906
+ }
907
+
908
+ /* =========================================================================
909
+ Step 4: Options (Channels + Services + Memory)
910
+ ========================================================================= */
911
+
912
+ function initStep4() {
913
+ // Show Ollama toggle if any verified provider is Ollama
914
+ var hasOllama = PROVIDERS.some(function (p) {
915
+ return p.id === "ollama" && providerState[p.id].verified;
639
916
  });
640
917
  var addon = $("ollama-addon");
641
- if (hasOllama) show(addon); else hide(addon);
918
+ if (hasOllama) {
919
+ show(addon);
920
+ // Pre-check if ollamaMode is instack
921
+ var ollamaCb = $("ollama-enabled");
922
+ if (providerState.ollama.ollamaMode === "instack") {
923
+ ollamaCb.checked = true;
924
+ }
925
+ } else {
926
+ hide(addon);
927
+ }
642
928
 
643
929
  // Memory user ID default
644
930
  var memInput = $("memory-user-id");
@@ -646,21 +932,291 @@
646
932
  var email = ($("owner-email").value || "").trim();
647
933
  memInput.value = email || "default_user";
648
934
  }
935
+
936
+ // Render channels and services
937
+ renderChannels();
938
+ renderServices();
939
+ }
940
+
941
+ /** Helper: check if a channel is enabled (handles both boolean and object state) */
942
+ function isChannelEnabled(ch) {
943
+ if (ch.locked) return true;
944
+ var sel = channelSelection[ch.id];
945
+ if (typeof sel === "object" && sel !== null) return sel.enabled;
946
+ return !!sel;
947
+ }
948
+
949
+ function renderChannels() {
950
+ var container = $("channels-grid");
951
+ var html = "";
952
+
953
+ CHANNELS.forEach(function (ch) {
954
+ var isOn = isChannelEnabled(ch);
955
+ var cls = "toggle-card" + (isOn ? " on" : "") + (ch.locked ? " locked" : "");
956
+ if (ch.credentials && isOn) cls += " wide";
957
+
958
+ html += '<div class="' + cls + '" data-channel="' + ch.id + '">';
959
+ html += '<div class="toggle-card-header" data-channel-toggle="' + ch.id + '">';
960
+ html += '<div class="toggle-card-icon">' + esc(ch.icon) + '</div>';
961
+ html += '<div class="toggle-card-info">';
962
+ html += '<div class="toggle-card-name">' + esc(ch.name) + (ch.locked ? ' <span class="badge badge-local">Always on</span>' : '') + '</div>';
963
+ html += '<div class="toggle-card-desc">' + esc(ch.desc) + '</div>';
964
+ html += '</div>';
965
+ html += '<div class="toggle-card-switch">';
966
+ if (ch.locked) {
967
+ html += '<div class="toggle-track on locked"><div class="toggle-thumb"></div></div>';
968
+ } else {
969
+ html += '<div class="toggle-track ' + (isOn ? "on" : "") + '"><div class="toggle-thumb"></div></div>';
970
+ }
971
+ html += '</div>';
972
+ html += '</div>';
973
+
974
+ // Credential fields (expanded when channel with credentials is toggled ON)
975
+ if (ch.credentials && isOn) {
976
+ html += renderChannelCredentials(ch);
977
+ }
978
+
979
+ html += '</div>';
980
+ });
981
+
982
+ container.innerHTML = html;
983
+
984
+ // Bind toggle clicks on header
985
+ document.querySelectorAll("[data-channel-toggle]").forEach(function (el) {
986
+ el.addEventListener("click", function () {
987
+ var id = el.dataset.channelToggle;
988
+ var ch = CHANNELS.find(function (c) { return c.id === id; });
989
+ if (ch && ch.locked) return; // Cannot toggle locked channels
990
+ var sel = channelSelection[id];
991
+ if (typeof sel === "object" && sel !== null) {
992
+ sel.enabled = !sel.enabled;
993
+ } else {
994
+ channelSelection[id] = !sel;
995
+ }
996
+ renderChannels();
997
+ });
998
+ });
999
+
1000
+ // Bind credential inputs (don't re-render on typing)
1001
+ document.querySelectorAll("[data-channel-cred]").forEach(function (el) {
1002
+ el.addEventListener("input", function () {
1003
+ var sep = el.dataset.channelCred.indexOf(":");
1004
+ var chId = el.dataset.channelCred.slice(0, sep);
1005
+ var credKey = el.dataset.channelCred.slice(sep + 1);
1006
+ var sel = channelSelection[chId];
1007
+ if (typeof sel === "object" && sel !== null) {
1008
+ sel[credKey] = el.value;
1009
+ }
1010
+ });
1011
+ el.addEventListener("click", function (e) { e.stopPropagation(); });
1012
+ });
1013
+ }
1014
+
1015
+ function renderChannelCredentials(ch) {
1016
+ var sel = channelSelection[ch.id];
1017
+ var html = '<div class="pcard-auth">';
1018
+
1019
+ ch.credentials.forEach(function (cred) {
1020
+ var val = (typeof sel === "object" && sel !== null) ? (sel[cred.key] || "") : "";
1021
+ var inputType = cred.secret === false ? "text" : "password";
1022
+ html += '<div class="auth-row">';
1023
+ html += '<label class="channel-cred-label">' + esc(cred.label) + (cred.required ? ' <span class="channel-cred-required">*</span>' : '') + '</label>';
1024
+ html += '<input type="' + inputType + '" placeholder="' + esc(cred.placeholder || '') + '" value="' + esc(val) + '" data-channel-cred="' + ch.id + ':' + cred.key + '">';
1025
+ html += '</div>';
1026
+ });
1027
+
1028
+ html += '</div>';
1029
+ return html;
1030
+ }
1031
+
1032
+ function renderServices() {
1033
+ var container = $("services-grid");
1034
+ var html = "";
1035
+
1036
+ SERVICES.forEach(function (svc) {
1037
+ var isOn = serviceSelection[svc.id];
1038
+ var cls = "toggle-card" + (isOn ? " on" : "");
1039
+
1040
+ html += '<div class="' + cls + '" data-service="' + svc.id + '">';
1041
+ html += '<div class="toggle-card-header">';
1042
+ html += '<div class="toggle-card-icon">' + esc(svc.icon) + '</div>';
1043
+ html += '<div class="toggle-card-info">';
1044
+ html += '<div class="toggle-card-name">' + esc(svc.name) + (svc.recommended ? ' <span class="badge badge-cloud">Recommended</span>' : '') + '</div>';
1045
+ html += '<div class="toggle-card-desc">' + esc(svc.desc) + '</div>';
1046
+ html += '</div>';
1047
+ html += '<div class="toggle-card-switch">';
1048
+ html += '<div class="toggle-track ' + (isOn ? "on" : "") + '"><div class="toggle-thumb"></div></div>';
1049
+ html += '</div>';
1050
+ html += '</div>';
1051
+ html += '</div>';
1052
+ });
1053
+
1054
+ container.innerHTML = html;
1055
+
1056
+ // Bind toggle clicks
1057
+ document.querySelectorAll("[data-service]").forEach(function (el) {
1058
+ el.addEventListener("click", function () {
1059
+ var id = el.dataset.service;
1060
+ serviceSelection[id] = !serviceSelection[id];
1061
+ renderServices();
1062
+ });
1063
+ });
1064
+ }
1065
+
1066
+ function validateStep4() {
1067
+ var errEl = $("step4-error");
1068
+ hideError(errEl);
1069
+
1070
+ var errors = [];
1071
+ CHANNELS.forEach(function (ch) {
1072
+ if (!ch.credentials) return;
1073
+ if (!isChannelEnabled(ch)) return;
1074
+ var sel = channelSelection[ch.id];
1075
+ if (typeof sel !== "object" || sel === null) return;
1076
+ ch.credentials.forEach(function (cred) {
1077
+ if (cred.required && !(sel[cred.key] || "").trim()) {
1078
+ errors.push(ch.name + ": " + cred.label + " is required.");
1079
+ }
1080
+ });
1081
+ });
1082
+
1083
+ if (errors.length > 0) {
1084
+ showError(errEl, errors.join(" "));
1085
+ return false;
1086
+ }
1087
+ return true;
649
1088
  }
650
1089
 
651
1090
  /* =========================================================================
652
- Step 4: Review & Install
1091
+ Step 5: Review & Install
653
1092
  ========================================================================= */
654
1093
 
655
- function initStep4() {
1094
+ function initStep5() {
656
1095
  renderReview();
1096
+ // TODO: Remove renderReviewLegacy() once e2e tests (setup-wizard.test.ts)
1097
+ // are updated to use the new #review-summary selectors instead of #review-grid.
1098
+ renderReviewLegacy();
657
1099
  }
658
1100
 
659
1101
  function renderReview() {
1102
+ var container = $("review-summary");
1103
+ var html = "";
1104
+
1105
+ // Account section
1106
+ var adminToken = ($("admin-token").value || "").trim();
1107
+ var ownerName = ($("owner-name").value || "").trim();
1108
+ var ownerEmail = ($("owner-email").value || "").trim();
1109
+
1110
+ html += '<div class="review-card">';
1111
+ html += '<div class="review-card-title"><span>Account</span><button class="review-edit-btn" type="button" data-review-edit="0">Edit</button></div>';
1112
+ html += '<div class="review-row"><span class="review-row-label">Admin Token</span><span class="review-row-value">' + maskToken(adminToken) + '</span></div>';
1113
+ if (ownerName) html += '<div class="review-row"><span class="review-row-label">Name</span><span class="review-row-value">' + esc(ownerName) + '</span></div>';
1114
+ if (ownerEmail) html += '<div class="review-row"><span class="review-row-label">Email</span><span class="review-row-value">' + esc(ownerEmail) + '</span></div>';
1115
+ html += '</div>';
1116
+
1117
+ // Providers section
1118
+ var vp = getVerifiedProviders();
1119
+ html += '<div class="review-card">';
1120
+ html += '<div class="review-card-title"><span>Providers</span><button class="review-edit-btn" type="button" data-review-edit="1">Edit</button></div>';
1121
+ vp.forEach(function (p) {
1122
+ html += '<div class="review-row"><span class="review-row-label">' + esc(p.icon) + ' ' + esc(p.name) + '</span><span class="review-row-value review-row-value-ok">Connected \u2713</span></div>';
1123
+ });
1124
+ html += '</div>';
1125
+
1126
+ // Models section
1127
+ html += '<div class="review-card">';
1128
+ html += '<div class="review-card-title"><span>Models</span><button class="review-edit-btn" type="button" data-review-edit="2">Edit</button></div>';
1129
+ var llm = modelSelection.llm;
1130
+ var emb = modelSelection.embedding;
1131
+ var small = modelSelection.small;
1132
+ if (llm) {
1133
+ var llmProv = PROVIDERS.find(function (p) { return p.id === llm.connId; });
1134
+ html += '<div class="review-row"><span class="review-row-label">Chat Model</span><span class="review-row-value">' + esc(llm.model) + (llmProv ? ' (' + esc(llmProv.name) + ')' : '') + '</span></div>';
1135
+ }
1136
+ if (small && small.model) {
1137
+ var smallProv = PROVIDERS.find(function (p) { return p.id === small.connId; });
1138
+ html += '<div class="review-row"><span class="review-row-label">Small Model</span><span class="review-row-value">' + esc(small.model) + (smallProv ? ' (' + esc(smallProv.name) + ')' : '') + '</span></div>';
1139
+ }
1140
+ if (emb) {
1141
+ var embProv = PROVIDERS.find(function (p) { return p.id === emb.connId; });
1142
+ html += '<div class="review-row"><span class="review-row-label">Embedding Model</span><span class="review-row-value">' + esc(emb.model) + (embProv ? ' (' + esc(embProv.name) + ')' : '') + '</span></div>';
1143
+ html += '<div class="review-row"><span class="review-row-label">Embedding Dims</span><span class="review-row-value">' + (emb.dims || 1536) + '</span></div>';
1144
+ }
1145
+ html += '</div>';
1146
+
1147
+ // Voice section
1148
+ var ttsOpt = TTS_OPTIONS.find(function (o) { return o.id === activeTts(); });
1149
+ var sttOpt = STT_OPTIONS.find(function (o) { return o.id === activeStt(); });
1150
+ html += '<div class="review-card">';
1151
+ html += '<div class="review-card-title"><span>Voice</span><button class="review-edit-btn" type="button" data-review-edit="3">Edit</button></div>';
1152
+ html += '<div class="review-row"><span class="review-row-label">Text-to-Speech</span><span class="review-row-value">' + (ttsOpt ? esc(ttsOpt.name) : "Disabled") + '</span></div>';
1153
+ html += '<div class="review-row"><span class="review-row-label">Speech-to-Text</span><span class="review-row-value">' + (sttOpt ? esc(sttOpt.name) : "Disabled") + '</span></div>';
1154
+ html += '</div>';
1155
+
1156
+ // Channels section
1157
+ var activeChannels = CHANNELS.filter(function (ch) { return isChannelEnabled(ch); });
1158
+ html += '<div class="review-card">';
1159
+ html += '<div class="review-card-title"><span>Channels</span><button class="review-edit-btn" type="button" data-review-edit="4">Edit</button></div>';
1160
+ activeChannels.forEach(function (ch) {
1161
+ html += '<div class="review-row"><span class="review-row-label">' + esc(ch.icon) + ' ' + esc(ch.name) + '</span><span class="review-row-value review-row-value-ok">Enabled \u2713</span></div>';
1162
+ // Show masked credentials if present
1163
+ if (ch.credentials) {
1164
+ var sel = channelSelection[ch.id];
1165
+ if (typeof sel === "object" && sel !== null && sel.enabled) {
1166
+ ch.credentials.forEach(function (cred) {
1167
+ var val = sel[cred.key] || "";
1168
+ if (val) {
1169
+ html += '<div class="review-row"><span class="review-row-label" style="padding-left:24px">' + esc(cred.label) + '</span><span class="review-row-value">' + maskToken(val) + '</span></div>';
1170
+ }
1171
+ });
1172
+ }
1173
+ }
1174
+ });
1175
+ html += '</div>';
1176
+
1177
+ // Services section
1178
+ var activeServices = SERVICES.filter(function (svc) { return serviceSelection[svc.id]; });
1179
+ html += '<div class="review-card">';
1180
+ html += '<div class="review-card-title"><span>Services</span><button class="review-edit-btn" type="button" data-review-edit="4">Edit</button></div>';
1181
+ if (activeServices.length > 0) {
1182
+ activeServices.forEach(function (svc) {
1183
+ html += '<div class="review-row"><span class="review-row-label">' + esc(svc.icon) + ' ' + esc(svc.name) + '</span><span class="review-row-value review-row-value-ok">Enabled \u2713</span></div>';
1184
+ });
1185
+ } else {
1186
+ html += '<div class="review-row"><span class="review-row-label">No extra services</span><span class="review-row-value">Core only</span></div>';
1187
+ }
1188
+ html += '</div>';
1189
+
1190
+ // Options section
1191
+ var ollamaEnabled = $("ollama-enabled") && $("ollama-enabled").checked;
1192
+ var memUserId = ($("memory-user-id").value || "").trim() || "default_user";
1193
+ html += '<div class="review-card">';
1194
+ html += '<div class="review-card-title"><span>Options</span><button class="review-edit-btn" type="button" data-review-edit="4">Edit</button></div>';
1195
+ if (ollamaEnabled) {
1196
+ html += '<div class="review-row"><span class="review-row-label">Ollama In-Stack</span><span class="review-row-value">Enabled</span></div>';
1197
+ }
1198
+ html += '<div class="review-row"><span class="review-row-label">Memory User ID</span><span class="review-row-value">' + esc(memUserId) + '</span></div>';
1199
+ html += '</div>';
1200
+
1201
+ container.innerHTML = html;
1202
+
1203
+ // Build JSON for review
1204
+ var jsonObj = buildPayload();
1205
+ $("review-json-pre").textContent = JSON.stringify(jsonObj, null, 2);
1206
+
1207
+ // Bind edit buttons
1208
+ document.querySelectorAll("[data-review-edit]").forEach(function (btn) {
1209
+ btn.addEventListener("click", function () {
1210
+ goToStep(parseInt(btn.dataset.reviewEdit, 10));
1211
+ });
1212
+ });
1213
+ }
1214
+
1215
+ function renderReviewLegacy() {
1216
+ // Keep the old review-grid populated for test backward compat
660
1217
  var grid = $("review-grid");
661
1218
  grid.innerHTML = "";
662
1219
 
663
- // Account section
664
1220
  grid.appendChild(reviewHeader("Account", 0));
665
1221
  grid.appendChild(reviewItem("Admin Token", maskToken($("admin-token").value)));
666
1222
  var ownerName = ($("owner-name").value || "").trim();
@@ -668,36 +1224,60 @@
668
1224
  var ownerEmail = ($("owner-email").value || "").trim();
669
1225
  if (ownerEmail) grid.appendChild(reviewItem("Email", ownerEmail));
670
1226
 
671
- // Connections section
672
1227
  grid.appendChild(reviewHeader("Connections", 1));
673
- connections.forEach(function (conn) {
674
- grid.appendChild(reviewItem(conn.name || conn.provider, conn.kind + " -- " + conn.baseUrl, true));
1228
+ getVerifiedProviders().forEach(function (p) {
1229
+ var st = providerState[p.id];
1230
+ grid.appendChild(reviewItem(p.name, p.kind + " -- " + (st.baseUrl || p.baseUrl), true));
675
1231
  });
676
1232
 
677
- // Models section
678
1233
  grid.appendChild(reviewHeader("Models", 2));
679
- var llmConnId = $("llm-connection").value;
680
- var llmConn = connections.find(function (c) { return c.id === llmConnId; });
681
- var llmModel = ($("llm-model").value || "").trim();
682
- var smallModel = ($("llm-small-model").value || "").trim();
683
- var embConnId = $("emb-connection").value;
684
- var embConn = connections.find(function (c) { return c.id === embConnId; });
685
- var embModel = ($("emb-model").value || "").trim();
686
- var embDims = ($("emb-dims").value || "1536").trim();
687
-
688
- grid.appendChild(reviewItem("Chat Model", llmModel + " (" + (llmConn ? llmConn.name : "?") + ")", true));
689
- if (smallModel) {
690
- grid.appendChild(reviewItem("Small Model", smallModel + " (" + (llmConn ? llmConn.name : "?") + ")", true));
1234
+ var llm = modelSelection.llm;
1235
+ var small = modelSelection.small;
1236
+ var emb = modelSelection.embedding;
1237
+ var llmProv = llm ? PROVIDERS.find(function (pp) { return pp.id === llm.connId; }) : null;
1238
+ var embProv = emb ? PROVIDERS.find(function (pp) { return pp.id === emb.connId; }) : null;
1239
+ if (llm) grid.appendChild(reviewItem("Chat Model", llm.model + " (" + (llmProv ? llmProv.name : "?") + ")", true));
1240
+ if (small && small.model) grid.appendChild(reviewItem("Small Model", small.model + " (" + (llmProv ? llmProv.name : "?") + ")", true));
1241
+ if (emb) {
1242
+ grid.appendChild(reviewItem("Embedding Model", emb.model + " (" + (embProv ? embProv.name : "?") + ")", true));
1243
+ grid.appendChild(reviewItem("Embedding Dims", String(emb.dims || 1536), true));
691
1244
  }
692
- grid.appendChild(reviewItem("Embedding Model", embModel + " (" + (embConn ? embConn.name : "?") + ")", true));
693
- grid.appendChild(reviewItem("Embedding Dims", embDims, true));
694
1245
 
695
- // Options section
696
- grid.appendChild(reviewHeader("Options", 3));
1246
+ grid.appendChild(reviewHeader("Voice", 3));
1247
+ var ttsOpt = TTS_OPTIONS.find(function (o) { return o.id === activeTts(); });
1248
+ var sttOpt = STT_OPTIONS.find(function (o) { return o.id === activeStt(); });
1249
+ grid.appendChild(reviewItem("TTS", ttsOpt ? ttsOpt.name : "Disabled"));
1250
+ grid.appendChild(reviewItem("STT", sttOpt ? sttOpt.name : "Disabled"));
1251
+
1252
+ grid.appendChild(reviewHeader("Channels", 4));
1253
+ CHANNELS.forEach(function (ch) {
1254
+ if (isChannelEnabled(ch)) {
1255
+ grid.appendChild(reviewItem(ch.name, "Enabled"));
1256
+ // Show masked credentials in legacy review
1257
+ if (ch.credentials) {
1258
+ var sel = channelSelection[ch.id];
1259
+ if (typeof sel === "object" && sel !== null && sel.enabled) {
1260
+ ch.credentials.forEach(function (cred) {
1261
+ var val = sel[cred.key] || "";
1262
+ if (val) {
1263
+ grid.appendChild(reviewItem(" " + cred.label, maskToken(val)));
1264
+ }
1265
+ });
1266
+ }
1267
+ }
1268
+ }
1269
+ });
1270
+
1271
+ grid.appendChild(reviewHeader("Services", 4));
1272
+ SERVICES.forEach(function (svc) {
1273
+ if (serviceSelection[svc.id]) {
1274
+ grid.appendChild(reviewItem(svc.name, "Enabled"));
1275
+ }
1276
+ });
1277
+
1278
+ grid.appendChild(reviewHeader("Options", 4));
697
1279
  var ollamaEnabled = $("ollama-enabled") && $("ollama-enabled").checked;
698
- if (ollamaEnabled) {
699
- grid.appendChild(reviewItem("Ollama In-Stack", "Enabled"));
700
- }
1280
+ if (ollamaEnabled) grid.appendChild(reviewItem("Ollama In-Stack", "Enabled"));
701
1281
  grid.appendChild(reviewItem("Memory User ID", $("memory-user-id").value || "default_user"));
702
1282
  }
703
1283
 
@@ -739,53 +1319,92 @@
739
1319
  Install & Deploy
740
1320
  ========================================================================= */
741
1321
 
742
- async function handleInstall() {
743
- if (installing) return;
744
-
745
- var errEl = $("install-error");
746
- hideError(errEl);
1322
+ function buildChannelsConfig() {
1323
+ var result = {};
1324
+ CHANNELS.forEach(function (ch) {
1325
+ var sel = channelSelection[ch.id];
1326
+ if (ch.locked) {
1327
+ result[ch.id] = true;
1328
+ } else if (typeof sel === "object" && sel !== null) {
1329
+ if (sel.enabled) {
1330
+ // Include credentials (copy enabled + credential fields)
1331
+ var entry = { enabled: true };
1332
+ if (ch.credentials) {
1333
+ ch.credentials.forEach(function (cred) {
1334
+ if (sel[cred.key]) entry[cred.key] = sel[cred.key];
1335
+ });
1336
+ }
1337
+ result[ch.id] = entry;
1338
+ }
1339
+ } else if (sel) {
1340
+ result[ch.id] = true;
1341
+ }
1342
+ });
1343
+ return result;
1344
+ }
747
1345
 
1346
+ function buildPayload() {
748
1347
  var adminToken = ($("admin-token").value || "").trim();
749
1348
  var ownerName = ($("owner-name").value || "").trim();
750
1349
  var ownerEmail = ($("owner-email").value || "").trim();
751
- var memoryUserId = ($("memory-user-id").value || "").trim() || ownerEmail || "default_user";
1350
+ var memoryUserId = ($("memory-user-id").value || "").trim() || (ownerName ? ownerName.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "") : "") || "default_user";
752
1351
  var ollamaEnabled = $("ollama-enabled") ? $("ollama-enabled").checked : false;
753
1352
 
754
- var llmConnId = $("llm-connection").value;
755
- var llmModel = ($("llm-model").value || "").trim();
756
- var smallModel = ($("llm-small-model").value || "").trim();
757
- var embConnId = $("emb-connection").value;
758
- var embModel = ($("emb-model").value || "").trim();
759
- var embDims = parseInt($("emb-dims").value, 10) || 1536;
760
-
761
- var payload = {
762
- adminToken: adminToken,
763
- ownerName: ownerName || undefined,
764
- ownerEmail: ownerEmail || undefined,
765
- memoryUserId: memoryUserId,
766
- ollamaEnabled: ollamaEnabled,
767
- connections: connections.map(function (c) {
768
- return {
769
- id: c.id,
770
- name: c.name,
771
- provider: c.provider,
772
- baseUrl: c.baseUrl,
773
- apiKey: c.apiKey,
774
- };
775
- }),
1353
+ var llm = modelSelection.llm;
1354
+ var emb = modelSelection.embedding;
1355
+ var small = modelSelection.small;
1356
+
1357
+ // Build connections from verified providers
1358
+ var connections = getVerifiedProviders().map(function (p) {
1359
+ var st = providerState[p.id];
1360
+ return {
1361
+ id: p.id,
1362
+ name: p.name,
1363
+ provider: p.id,
1364
+ baseUrl: st.baseUrl || p.baseUrl,
1365
+ apiKey: st.apiKey || "",
1366
+ };
1367
+ });
1368
+
1369
+ var ttsVal = activeTts() !== "skip-tts" ? activeTts() : null;
1370
+ var sttVal = activeStt() !== "skip-stt" ? activeStt() : null;
1371
+
1372
+ return {
1373
+ version: 1,
1374
+ owner: (ownerName || ownerEmail) ? { name: ownerName || undefined, email: ownerEmail || undefined } : undefined,
1375
+ security: { adminToken: adminToken },
1376
+ connections: connections,
776
1377
  assignments: {
777
1378
  llm: {
778
- connectionId: llmConnId,
779
- model: llmModel,
780
- smallModel: smallModel || undefined,
1379
+ connectionId: llm ? llm.connId : "",
1380
+ model: llm ? llm.model : "",
1381
+ smallModel: (small && small.model) ? small.model : undefined,
781
1382
  },
782
1383
  embeddings: {
783
- connectionId: embConnId,
784
- model: embModel,
785
- embeddingDims: embDims,
1384
+ connectionId: emb ? emb.connId : "",
1385
+ model: emb ? emb.model : "",
1386
+ embeddingDims: emb ? (emb.dims || 1536) : 1536,
786
1387
  },
1388
+ tts: ttsVal,
1389
+ stt: sttVal,
1390
+ },
1391
+ memory: { userId: memoryUserId },
1392
+ channels: buildChannelsConfig(),
1393
+ services: {
1394
+ admin: serviceSelection.admin || false,
1395
+ openviking: serviceSelection.openviking || false,
1396
+ ollama: ollamaEnabled,
787
1397
  },
788
1398
  };
1399
+ }
1400
+
1401
+ async function handleInstall() {
1402
+ if (installing) return;
1403
+
1404
+ var errEl = $("install-error");
1405
+ hideError(errEl);
1406
+
1407
+ var payload = buildPayload();
789
1408
 
790
1409
  installing = true;
791
1410
  var installBtn = $("btn-install");
@@ -808,7 +1427,6 @@
808
1427
  return;
809
1428
  }
810
1429
 
811
- // Success -- go to deploy screen
812
1430
  showDeployScreen();
813
1431
  startDeployPolling();
814
1432
  } catch (e) {
@@ -837,12 +1455,15 @@
837
1455
 
838
1456
  updateDeployUI(data);
839
1457
 
840
- if (data.setupComplete && !data.deployError) {
841
- stopDeployPolling();
842
- showDeployDone(data);
843
- } else if (data.deployError) {
1458
+ if (data.deployError) {
844
1459
  stopDeployPolling();
845
1460
  showDeployError(data.deployError);
1461
+ } else if (data.setupComplete && data.deployStatus && data.deployStatus.length > 0) {
1462
+ var allRunning = data.deployStatus.every(function (s) { return s.status === "running"; });
1463
+ if (allRunning) {
1464
+ stopDeployPolling();
1465
+ showDeployDone(data);
1466
+ }
846
1467
  }
847
1468
  } catch (e) {
848
1469
  // silently retry
@@ -865,7 +1486,6 @@
865
1486
  var row = document.createElement("div");
866
1487
  row.className = "deploy-service-row";
867
1488
 
868
- // Indicator
869
1489
  var indicator = document.createElement("div");
870
1490
  indicator.className = "deploy-service-indicator";
871
1491
  if (svc.status === "running") {
@@ -877,7 +1497,6 @@
877
1497
  }
878
1498
  row.appendChild(indicator);
879
1499
 
880
- // Info
881
1500
  var info = document.createElement("div");
882
1501
  info.className = "deploy-service-info";
883
1502
  info.innerHTML =
@@ -885,7 +1504,6 @@
885
1504
  '<span class="deploy-service-status">' + esc(svc.label || svc.status) + "</span>";
886
1505
  row.appendChild(info);
887
1506
 
888
- // Bar
889
1507
  var bar = document.createElement("div");
890
1508
  bar.className = "deploy-service-bar";
891
1509
  var fill = document.createElement("div");
@@ -900,7 +1518,6 @@
900
1518
  container.appendChild(row);
901
1519
  });
902
1520
 
903
- // Progress
904
1521
  var pct = total > 0 ? Math.round((running / total) * 100) : 0;
905
1522
  $("deploy-progress-value").textContent = pct + "%";
906
1523
  $("deploy-progress-fill").style.width = pct + "%";
@@ -925,7 +1542,6 @@
925
1542
  $("deploy-progress-value").textContent = "100%";
926
1543
  $("deploy-progress-fill").style.width = "100%";
927
1544
 
928
- // Service list
929
1545
  var list = $("deploy-service-list");
930
1546
  list.innerHTML = "";
931
1547
  (data.deployStatus || []).forEach(function (svc) {
@@ -946,8 +1562,6 @@
946
1562
  $("deploy-failure-summary").textContent = typeof error === "string" ? error : "Deployment failed.";
947
1563
  $("deploy-error-pre").textContent = typeof error === "string" ? error : JSON.stringify(error, null, 2);
948
1564
 
949
- var bar = $("deploy-progress-bar");
950
- if (bar) bar.parentElement.querySelector(".deploy-progress-bar").classList.add("deploy-progress-bar--error");
951
1565
  $("deploy-progress-value").textContent = "Error";
952
1566
  $("deploy-progress-value").classList.add("deploy-progress-value--error");
953
1567
  }
@@ -963,11 +1577,30 @@
963
1577
  if (res.ok) {
964
1578
  var data = await res.json();
965
1579
  detectedProviders = data.providers || [];
1580
+
1581
+ // Auto-select and pre-verify detected providers
1582
+ detectedProviders.forEach(function (dp) {
1583
+ if (!dp.available) return;
1584
+ var st = providerState[dp.provider];
1585
+ if (st) {
1586
+ st.baseUrl = dp.url;
1587
+ // Auto-select detected providers
1588
+ if (!st.selected) {
1589
+ st.selected = true;
1590
+ if (dp.provider === "ollama") {
1591
+ st.ollamaMode = "running";
1592
+ }
1593
+ }
1594
+ // Auto-verify by fetching models
1595
+ verifyProvider(dp.provider);
1596
+ }
1597
+ });
966
1598
  }
967
1599
  } catch (e) {
968
1600
  detectedProviders = [];
969
1601
  }
970
1602
  hide($("conn-detecting"));
1603
+ renderProviderGrid();
971
1604
  }
972
1605
 
973
1606
  async function apiFetchModels(provider, baseUrl, apiKey) {
@@ -977,9 +1610,11 @@
977
1610
  headers: { "Content-Type": "application/json" },
978
1611
  body: JSON.stringify({ apiKey: apiKey || "", baseUrl: baseUrl || "" }),
979
1612
  });
980
- if (!res.ok) throw new Error("Failed to fetch models (HTTP " + res.status + ")");
981
1613
  var data = await res.json();
982
- return data.models || [];
1614
+ if (!res.ok || data.status === "recoverable_error") {
1615
+ throw new Error(data.error || "Failed to fetch models (HTTP " + res.status + ")");
1616
+ }
1617
+ return data;
983
1618
  }
984
1619
 
985
1620
  /* =========================================================================
@@ -1005,54 +1640,26 @@
1005
1640
  .then(function (r) { return r.json(); })
1006
1641
  .then(function (data) {
1007
1642
  if (data.setupComplete) {
1008
- // Already set up -- redirect
1009
1643
  window.location.href = "/";
1010
1644
  }
1011
1645
  })
1012
1646
  .catch(function () { /* ignore */ });
1013
1647
 
1014
- // ── Step indicator clicks ──
1015
- qsa(".step-dot").forEach(function (dot) {
1016
- dot.addEventListener("click", function () {
1017
- var step = parseInt(dot.dataset.step, 10);
1018
- if (!isNaN(step) && step <= maxVisitedStep) {
1019
- goToStep(step);
1020
- }
1021
- });
1648
+ // ── Step 0: Welcome ──
1649
+ $("btn-get-started").addEventListener("click", function () {
1650
+ welcomeHeroDismissed = true;
1651
+ hide($("welcome-hero"));
1652
+ show($("identity-form"));
1022
1653
  });
1023
1654
 
1024
- // ── Step 0: Welcome ──
1025
1655
  $("btn-step0-next").addEventListener("click", function () {
1026
1656
  if (validateStep0()) goToStep(1);
1027
1657
  });
1028
1658
 
1029
- // ── Step 1: Connections ──
1659
+ // ── Step 1: Providers ──
1030
1660
  $("btn-step1-back").addEventListener("click", function () { goToStep(0); });
1031
- $("btn-step1-add").addEventListener("click", function () { openAddConnection(); });
1032
1661
  $("btn-step1-next").addEventListener("click", function () {
1033
- if (connections.length > 0) goToStep(2);
1034
- });
1035
-
1036
- // Connection type chooser
1037
- $("btn-add-cloud").addEventListener("click", function () { openConnectionForm("cloud"); });
1038
- $("btn-add-local").addEventListener("click", function () { openConnectionForm("local"); });
1039
-
1040
- // Connection detail form
1041
- $("btn-conn-cancel").addEventListener("click", function () {
1042
- editingIdx = -1;
1043
- renderConnHub();
1044
- setConnView("hub");
1045
- });
1046
- $("btn-conn-test").addEventListener("click", function () { testConnection(); });
1047
- $("btn-conn-save").addEventListener("click", function () { saveConnection(); });
1048
-
1049
- // Hub list delegation (edit/remove)
1050
- $("conn-hub-list").addEventListener("click", function (e) {
1051
- var btn = e.target.closest("[data-action]");
1052
- if (!btn) return;
1053
- var idx = parseInt(btn.dataset.idx, 10);
1054
- if (btn.dataset.action === "edit") editConnectionAt(idx);
1055
- if (btn.dataset.action === "remove") removeConnection(idx);
1662
+ if (getVerifiedCount() > 0) goToStep(2);
1056
1663
  });
1057
1664
 
1058
1665
  // ── Step 2: Models ──
@@ -1060,35 +1667,41 @@
1060
1667
  $("btn-step2-next").addEventListener("click", function () {
1061
1668
  if (validateStep2()) goToStep(3);
1062
1669
  });
1063
- $("btn-models-add-conn").addEventListener("click", function () {
1064
- goToStep(1);
1065
- openAddConnection();
1066
- });
1067
-
1068
- $("llm-connection").addEventListener("change", function () {
1069
- loadModelsForConnection(this.value, "llm");
1070
- });
1071
- $("emb-connection").addEventListener("change", function () {
1072
- loadModelsForConnection(this.value, "emb");
1073
- });
1074
- $("emb-model").addEventListener("change", function () {
1075
- autoFillEmbDims(this.value);
1076
- });
1077
1670
 
1078
- // ── Step 3: Options ──
1671
+ // ── Step 3: Voice ──
1079
1672
  $("btn-step3-back").addEventListener("click", function () { goToStep(2); });
1080
1673
  $("btn-step3-next").addEventListener("click", function () { goToStep(4); });
1081
1674
 
1082
- // ── Step 4: Review ──
1675
+ // ── Step 4: Options ──
1083
1676
  $("btn-step4-back").addEventListener("click", function () { goToStep(3); });
1677
+ $("btn-step4-next").addEventListener("click", function () {
1678
+ if (validateStep4()) goToStep(5);
1679
+ });
1680
+
1681
+ // ── Step 5: Review ──
1682
+ $("btn-step5-back").addEventListener("click", function () { goToStep(4); });
1084
1683
  $("btn-install").addEventListener("click", function () { handleInstall(); });
1085
1684
 
1685
+ // ── JSON toggle ──
1686
+ $("btn-toggle-json").addEventListener("click", function () {
1687
+ var jsonEl = $("review-json");
1688
+ var btn = $("btn-toggle-json");
1689
+ if (jsonEl.classList.contains("hidden")) {
1690
+ show(jsonEl);
1691
+ btn.textContent = "Hide Setup JSON";
1692
+ } else {
1693
+ hide(jsonEl);
1694
+ btn.textContent = "Show Setup JSON";
1695
+ }
1696
+ });
1697
+
1086
1698
  // ── Deploy error actions ──
1087
1699
  $("btn-deploy-back").addEventListener("click", function () {
1088
1700
  installing = false;
1089
- goToStep(4);
1701
+ goToStep(5);
1090
1702
  });
1091
1703
  $("btn-deploy-retry").addEventListener("click", function () {
1704
+ installing = false;
1092
1705
  hide($("deploy-failure"));
1093
1706
  hide($("deploy-error-actions"));
1094
1707
  show($("deploy-tips"));
@@ -1099,6 +1712,6 @@
1099
1712
  });
1100
1713
 
1101
1714
  // Start on step 0
1102
- updateStepIndicators();
1715
+ renderProgressBar();
1103
1716
  });
1104
1717
  })();