openpalm 0.9.7 → 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.
package/src/main.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, mock } from 'bun:test';
2
- import { existsSync, mkdirSync, writeFileSync, chmodSync, mkdtempSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { detectHostInfo, main, reconcileStackEnvImageTag, resolveRequestedImageTag, upsertEnvValue } from './main.ts';
@@ -57,92 +57,57 @@ describe('cli main', () => {
57
57
  process.env.OPENPALM_ADMIN_TOKEN = originalOpenPalmAdminToken;
58
58
  });
59
59
 
60
- it('calls containers pull for update (via admin when reachable)', async () => {
61
- const calls: string[] = [];
62
- process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
63
- globalThis.fetch = mock(async (input: string | URL) => {
64
- calls.push(String(input));
65
- return new Response('{"ok":true}', { status: 200 });
66
- }) as typeof fetch;
67
- console.log = mock(() => {}) as typeof console.log;
68
-
69
- await main(['update']);
70
-
71
- // tryAdminRequest attempts directly — no separate health check
72
- expect(calls).toEqual([
73
- 'http://localhost:8100/admin/containers/pull',
74
- ]);
75
- });
76
-
77
- it('uses ADMIN_TOKEN from the environment for admin requests', async () => {
78
- const adminTokens: string[] = [];
79
- process.env.ADMIN_TOKEN = 'env-admin-token';
80
- delete process.env.OPENPALM_ADMIN_TOKEN;
81
-
82
- globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
83
- const headers = new Headers(init?.headers);
84
- adminTokens.push(headers.get('X-Admin-Token') ?? '');
85
- return new Response('{"ok":true}', { status: 200 });
86
- }) as typeof fetch;
87
- console.log = mock(() => {}) as typeof console.log;
88
-
89
- await main(['update']);
90
-
91
- // Direct request — single call with token header
92
- expect(adminTokens).toEqual(['env-admin-token']);
93
- });
94
-
95
- it('falls back to the legacy parent secrets.env for admin requests', async () => {
96
- const base = mkdtempSync(join(tmpdir(), 'openpalm-config-'));
97
- const configHome = join(base, 'openpalm');
98
- const adminTokens: string[] = [];
60
+ it('runs bootstrap install directly without admin delegation', async () => {
61
+ const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
62
+ const configHome = join(base, 'config');
63
+ const dataHome = join(base, 'data');
64
+ const stateHome = join(base, 'state');
65
+ const workDir = join(base, 'work');
66
+ const binDir = join(stateHome, 'bin');
99
67
 
100
- mkdirSync(configHome, { recursive: true });
101
- writeFileSync(join(configHome, 'secrets.env'), 'OPENPALM_ADMIN_TOKEN=\nADMIN_TOKEN=\n');
102
- writeFileSync(join(base, 'secrets.env'), 'export ADMIN_TOKEN="legacy-admin-token"\n');
68
+ mkdirSync(binDir, { recursive: true });
69
+ writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
70
+ chmodSync(join(binDir, 'varlock'), 0o755);
103
71
 
104
72
  process.env.OPENPALM_CONFIG_HOME = configHome;
73
+ process.env.OPENPALM_DATA_HOME = dataHome;
74
+ process.env.OPENPALM_STATE_HOME = stateHome;
75
+ process.env.OPENPALM_WORK_DIR = workDir;
105
76
  delete process.env.ADMIN_TOKEN;
106
77
  delete process.env.OPENPALM_ADMIN_TOKEN;
107
78
 
108
- globalThis.fetch = mock(async (_input: string | URL, init?: RequestInit) => {
109
- const headers = new Headers(init?.headers);
110
- adminTokens.push(headers.get('X-Admin-Token') ?? '');
111
- return new Response('{"ok":true}', { status: 200 });
112
- }) as typeof fetch;
113
- console.log = mock(() => {}) as typeof console.log;
114
-
115
- try {
116
- await main(['update']);
117
- // Direct request — single call with legacy token
118
- expect(adminTokens).toEqual(['legacy-admin-token']);
119
- } finally {
120
- rmSync(base, { recursive: true, force: true });
121
- }
122
- });
123
-
124
- it('calls admin install when stack is already running and token exists', async () => {
125
- const calls: string[] = [];
126
- process.env.OPENPALM_ADMIN_TOKEN = 'test-token';
79
+ mockDockerCli();
80
+ const fetchedUrls: string[] = [];
127
81
  globalThis.fetch = mock(async (input: string | URL) => {
128
82
  const url = String(input);
129
- calls.push(url);
83
+ fetchedUrls.push(url);
130
84
  if (url.endsWith('/health')) {
131
85
  return new Response('ok', { status: 200 });
132
86
  }
133
- return new Response('{\"ok\":true}', { status: 200 });
87
+ if (url.includes('/docker-compose.yml')) {
88
+ return new Response('services: {}\n', { status: 200 });
89
+ }
90
+ if (url.includes('/Caddyfile')) {
91
+ return new Response(':80 {\n}\n', { status: 200 });
92
+ }
93
+ if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
94
+ return new Response('KEY=string\n', { status: 200 });
95
+ }
96
+ return new Response('', { status: 503 });
134
97
  }) as typeof fetch;
135
98
  console.log = mock(() => {}) as typeof console.log;
99
+ console.warn = mock(() => {}) as typeof console.warn;
136
100
 
137
- await main(['install']);
138
-
139
- expect(calls).toEqual([
140
- 'http://localhost:8100/health',
141
- 'http://localhost:8100/admin/install',
142
- ]);
101
+ try {
102
+ await main(['install', '--no-start', '--force', '--no-open']);
103
+ // Bootstrap runs directly, creating directories
104
+ expect(existsSync(join(dataHome, 'admin'))).toBe(true);
105
+ } finally {
106
+ rmSync(base, { recursive: true, force: true });
107
+ }
143
108
  });
144
109
 
145
- it('falls back to bootstrap when stack is running but no token exists', async () => {
110
+ it('creates the admin data directory during bootstrap install', async () => {
146
111
  const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
147
112
  const configHome = join(base, 'config');
148
113
  const dataHome = join(base, 'data');
@@ -158,16 +123,12 @@ describe('cli main', () => {
158
123
  process.env.OPENPALM_DATA_HOME = dataHome;
159
124
  process.env.OPENPALM_STATE_HOME = stateHome;
160
125
  process.env.OPENPALM_WORK_DIR = workDir;
161
- delete process.env.ADMIN_TOKEN;
162
- delete process.env.OPENPALM_ADMIN_TOKEN;
163
126
 
164
127
  mockDockerCli();
165
- const fetchedUrls: string[] = [];
166
128
  globalThis.fetch = mock(async (input: string | URL) => {
167
129
  const url = String(input);
168
- fetchedUrls.push(url);
169
130
  if (url.endsWith('/health')) {
170
- return new Response('ok', { status: 200 });
131
+ throw new TypeError('fetch failed');
171
132
  }
172
133
  if (url.includes('/docker-compose.yml')) {
173
134
  return new Response('services: {}\n', { status: 200 });
@@ -181,24 +142,23 @@ describe('cli main', () => {
181
142
  return new Response('', { status: 503 });
182
143
  }) as typeof fetch;
183
144
  console.log = mock(() => {}) as typeof console.log;
184
- console.warn = mock(() => {}) as typeof console.warn;
185
145
 
186
146
  try {
187
147
  await main(['install', '--no-start', '--force', '--no-open']);
188
- // Should have fallen through to bootstrap, creating directories
189
148
  expect(existsSync(join(dataHome, 'admin'))).toBe(true);
190
149
  } finally {
191
150
  rmSync(base, { recursive: true, force: true });
192
151
  }
193
152
  });
194
153
 
195
- it('creates the admin data directory during bootstrap install', async () => {
154
+ it('uses main as the default install ref for bootstrap asset downloads', async () => {
196
155
  const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
197
156
  const configHome = join(base, 'config');
198
157
  const dataHome = join(base, 'data');
199
158
  const stateHome = join(base, 'state');
200
159
  const workDir = join(base, 'work');
201
160
  const binDir = join(stateHome, 'bin');
161
+ const fetchedUrls: string[] = [];
202
162
 
203
163
  mkdirSync(binDir, { recursive: true });
204
164
  writeFileSync(join(binDir, 'varlock'), '#!/bin/sh\nexit 0\n');
@@ -212,31 +172,55 @@ describe('cli main', () => {
212
172
  mockDockerCli();
213
173
  globalThis.fetch = mock(async (input: string | URL) => {
214
174
  const url = String(input);
175
+ fetchedUrls.push(url);
215
176
  if (url.endsWith('/health')) {
216
177
  throw new TypeError('fetch failed');
217
178
  }
218
- if (url.includes('/docker-compose.yml')) {
179
+ if (url.includes('/main/assets/docker-compose.yml')) {
219
180
  return new Response('services: {}\n', { status: 200 });
220
181
  }
221
- if (url.includes('/Caddyfile')) {
182
+ if (url.includes('/main/assets/Caddyfile')) {
222
183
  return new Response(':80 {\n}\n', { status: 200 });
223
184
  }
224
- if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
185
+ if (url.includes('/main/assets/secrets.env.schema') || url.includes('/main/assets/stack.env.schema')) {
225
186
  return new Response('KEY=string\n', { status: 200 });
226
187
  }
227
188
  return new Response('', { status: 503 });
228
189
  }) as typeof fetch;
229
190
  console.log = mock(() => {}) as typeof console.log;
191
+ console.warn = mock(() => {}) as typeof console.warn;
230
192
 
231
193
  try {
232
194
  await main(['install', '--no-start', '--force', '--no-open']);
233
- expect(existsSync(join(dataHome, 'admin'))).toBe(true);
195
+
196
+ expect(fetchedUrls).toContain(
197
+ 'https://raw.githubusercontent.com/itlackey/openpalm/main/assets/docker-compose.yml',
198
+ );
199
+ expect(fetchedUrls).toContain(
200
+ 'https://raw.githubusercontent.com/itlackey/openpalm/main/assets/Caddyfile',
201
+ );
234
202
  } finally {
235
203
  rmSync(base, { recursive: true, force: true });
236
204
  }
237
205
  });
238
206
  });
239
207
 
208
+ describe('npm bin launcher', () => {
209
+ it('points the published bin to a Bun launcher script instead of a TypeScript source file', () => {
210
+ const cliPkg = JSON.parse(
211
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
212
+ ) as {
213
+ bin?: Record<string, string>;
214
+ };
215
+
216
+ expect(cliPkg.bin?.openpalm).toBe('./bin/openpalm.js');
217
+
218
+ const launcher = readFileSync(new URL('../bin/openpalm.js', import.meta.url), 'utf8');
219
+
220
+ expect(launcher.startsWith('#!/usr/bin/env bun\n')).toBe(true);
221
+ });
222
+ });
223
+
240
224
  describe('validate command', () => {
241
225
  it('is a recognized command (does not throw Unknown command)', async () => {
242
226
  const tempStateHome = mkdtempSync(join(tmpdir(), 'openpalm-test-'));
package/src/main.ts CHANGED
@@ -8,7 +8,7 @@ export type { HostInfo } from './lib/host-info.ts';
8
8
  export { upsertEnvValue, resolveRequestedImageTag, reconcileStackEnvImageTag } from './lib/env.ts';
9
9
  export { bootstrapInstall } from './commands/install.ts';
10
10
 
11
- const mainCommand = defineCommand({
11
+ export const mainCommand = defineCommand({
12
12
  meta: {
13
13
  name: 'openpalm',
14
14
  version: cliPkg.version,
@@ -12,227 +12,149 @@
12
12
 
13
13
  <!-- ── Header ─────────────────────────────────────────────── -->
14
14
  <div class="wizard-header">
15
- <h1>OpenPalm Setup</h1>
16
- <p class="wizard-subtitle">Configure your stack in a few steps.</p>
15
+ <div class="hdr-logo">OP</div>
16
+ <h1>OpenPalm <span class="hdr-suffix">Setup</span></h1>
17
17
  </div>
18
18
 
19
19
  <div class="wizard-body">
20
20
 
21
- <!-- ── Step Indicators ────────────────────────────────────── -->
22
- <nav class="step-indicators" aria-label="Wizard steps" id="step-indicators">
23
- <button class="step-dot active" data-step="0" aria-label="Step 1: Welcome" aria-current="step">1</button>
24
- <span class="step-line" data-line="0"></span>
25
- <button class="step-dot" data-step="1" aria-label="Step 2: Connections" disabled>2</button>
26
- <span class="step-line" data-line="1"></span>
27
- <button class="step-dot" data-step="2" aria-label="Step 3: Models" disabled>3</button>
28
- <span class="step-line" data-line="2"></span>
29
- <button class="step-dot" data-step="3" aria-label="Step 4: Options" disabled>4</button>
30
- <span class="step-line" data-line="3"></span>
31
- <button class="step-dot" data-step="4" aria-label="Step 5: Review" disabled>5</button>
21
+ <!-- ── Progress Bar ────────────────────────────────────── -->
22
+ <nav class="prog-bar" aria-label="Wizard steps" id="step-indicators">
23
+ <div class="prog-segments" id="prog-segments"></div>
24
+ <div class="prog-labels" id="prog-labels"></div>
32
25
  </nav>
33
26
 
34
27
  <!-- ═══════════════════════════════════════════════════════════
35
- Step 0: Welcome & Admin Token
28
+ Step 0: Welcome & Identity
36
29
  ═══════════════════════════════════════════════════════════ -->
37
30
  <section class="step-content" id="step-0" data-testid="step-welcome">
38
- <h2>Welcome</h2>
39
- <p class="step-description">Set up your admin credentials, then connect your AI providers and choose default models.</p>
40
-
41
- <div class="field-group">
42
- <label for="admin-token">Admin Token</label>
43
- <input id="admin-token" type="text" autocomplete="off" placeholder="Min 8 characters">
44
- <p class="field-hint">Protects the admin console. A random token has been generated for you.</p>
31
+ <!-- Welcome Hero -->
32
+ <div class="welcome-hero" id="welcome-hero">
33
+ <div class="welcome-icon">&#x1F44B;</div>
34
+ <h2>Welcome to OpenPalm</h2>
35
+ <p class="welcome-subtitle">Your self-hosted AI assistant. Pick your providers, choose models, and you're up and running.</p>
36
+ <div class="welcome-pills">
37
+ <span class="pill">Cloud or local</span>
38
+ <span class="pill">Smart defaults</span>
39
+ <span class="pill">Privacy first</span>
40
+ </div>
41
+ <button class="btn btn-primary-lg" id="btn-get-started">Get Started</button>
45
42
  </div>
46
43
 
47
- <div class="field-group">
48
- <label for="owner-name">Your Name <span style="color:var(--color-text-tertiary);font-weight:normal">(optional)</span></label>
49
- <input id="owner-name" type="text" placeholder="Jane Doe" autocomplete="name">
50
- </div>
44
+ <!-- Identity Form (hidden until Get Started) -->
45
+ <div class="identity-form hidden" id="identity-form">
46
+ <h2>About You</h2>
47
+ <p class="step-description">Set up admin credentials and optional identity details.</p>
51
48
 
52
- <div class="field-group">
53
- <label for="owner-email">Email <span style="color:var(--color-text-tertiary);font-weight:normal">(optional)</span></label>
54
- <input id="owner-email" type="email" placeholder="jane@example.com" autocomplete="email">
55
- </div>
49
+ <div class="field-group">
50
+ <label for="admin-token">Admin Token</label>
51
+ <input id="admin-token" type="text" autocomplete="off" placeholder="Min 8 characters">
52
+ <p class="field-hint">Protects the admin console. A random token has been generated for you.</p>
53
+ </div>
54
+
55
+ <div class="field-group">
56
+ <label for="owner-name">Your Name <span style="color:var(--color-text-tertiary);font-weight:normal">(optional)</span></label>
57
+ <input id="owner-name" type="text" placeholder="Jane Doe" autocomplete="name">
58
+ </div>
56
59
 
57
- <div id="step0-error" class="field-error hidden" role="alert"></div>
60
+ <div class="field-group">
61
+ <label for="owner-email">Email <span style="color:var(--color-text-tertiary);font-weight:normal">(optional)</span></label>
62
+ <input id="owner-email" type="email" placeholder="jane@example.com" autocomplete="email">
63
+ </div>
58
64
 
59
- <div class="step-actions">
60
- <button class="btn btn-primary" id="btn-step0-next">Next</button>
65
+ <div id="step0-error" class="field-error hidden" role="alert"></div>
66
+
67
+ <div class="step-actions">
68
+ <button class="btn btn-primary" id="btn-step0-next">Set Up Providers</button>
69
+ </div>
61
70
  </div>
62
71
  </section>
63
72
 
64
73
  <!-- ═══════════════════════════════════════════════════════════
65
- Step 1: Connection Setup
74
+ Step 1: Providers (Card Grid)
66
75
  ═══════════════════════════════════════════════════════════ -->
67
76
  <section class="step-content hidden" id="step-1" data-testid="step-connections">
68
- <h2>Connections</h2>
69
- <p class="step-description">Add one or more model endpoints. Mix local and cloud providers as you like.</p>
70
-
71
- <!-- Existing connections list -->
72
- <ul class="hub-list hidden" id="conn-hub-list" aria-label="Connections"></ul>
73
-
74
- <!-- Empty state -->
75
- <div class="hub-empty" id="conn-hub-empty">
76
- <p class="hub-empty-headline">No connections yet</p>
77
- <p class="hub-empty-body">Add a cloud API provider or a local model server to get started.</p>
78
- </div>
77
+ <h2>Where should your models run?</h2>
78
+ <p class="step-description">Select one or more providers. Click a card to configure it.</p>
79
79
 
80
80
  <!-- Loading state for provider detection -->
81
81
  <div class="loading-state hidden" id="conn-detecting">
82
82
  <span class="spinner"></span>&nbsp;Detecting local providers...
83
83
  </div>
84
84
 
85
- <!-- Add connection type cards -->
86
- <div id="conn-type-chooser" class="hidden">
87
- <button class="conn-card" type="button" id="btn-add-cloud">
88
- <div class="conn-icon conn-icon--cloud" aria-hidden="true">
89
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></svg>
90
- </div>
91
- <div class="conn-body">
92
- <span class="conn-label">Cloud Provider <span class="conn-badge">Hosted</span></span>
93
- <span class="conn-desc">OpenAI, Anthropic, Groq, Together, Mistral, DeepSeek, xAI</span>
94
- </div>
95
- <div class="conn-arrow" aria-hidden="true">
96
- <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="9 18 15 12 9 6"/></svg>
97
- </div>
98
- </button>
99
- <button class="conn-card" type="button" id="btn-add-local">
100
- <div class="conn-icon conn-icon--local" aria-hidden="true">
101
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="currentColor" stroke="none"/><circle cx="6" cy="18" r="1" fill="currentColor" stroke="none"/></svg>
102
- </div>
103
- <div class="conn-body">
104
- <span class="conn-label">Local Provider <span class="conn-badge conn-badge--local">On-Device</span></span>
105
- <span class="conn-desc">Ollama, Docker Model Runner, LM Studio running locally</span>
106
- </div>
107
- <div class="conn-arrow" aria-hidden="true">
108
- <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="9 18 15 12 9 6"/></svg>
109
- </div>
110
- </button>
111
- </div>
112
-
113
- <!-- Connection detail form (shared for cloud and local) -->
114
- <div id="conn-detail-form" class="hidden">
115
- <div class="connection-mode-card" id="conn-mode-card">
116
- <div class="connection-mode-header">
117
- <span class="connection-mode-badge" id="conn-mode-badge"></span>
118
- <h3 id="conn-mode-title"></h3>
119
- </div>
120
- </div>
121
-
122
- <!-- Cloud: provider quick-picks -->
123
- <div class="provider-quick-picks hidden" id="cloud-provider-picks"></div>
124
-
125
- <!-- Local: detected providers -->
126
- <div id="local-provider-list" class="hidden"></div>
127
-
128
- <div class="field-group">
129
- <label for="conn-name">Connection Name</label>
130
- <input id="conn-name" type="text" placeholder='e.g. "My Ollama", "Work OpenAI"'>
131
- </div>
132
-
133
- <div class="field-group">
134
- <label for="conn-base-url">Base URL</label>
135
- <input id="conn-base-url" type="url" placeholder="https://api.openai.com">
136
- <p class="field-hint">The provider endpoint. Do not include a trailing /v1.</p>
137
- </div>
138
-
139
- <div class="field-group hidden" id="conn-apikey-group">
140
- <label for="conn-api-key">API Key</label>
141
- <input id="conn-api-key" type="password" placeholder="sk-..." autocomplete="new-password">
142
- <p class="field-hint">Stored server-side only.</p>
143
- </div>
144
-
145
- <div id="conn-detail-error" class="field-error hidden" role="alert"></div>
146
-
147
- <div class="connection-success hidden" id="conn-test-success" role="status">
148
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
149
- <span id="conn-test-msg">Connected</span>
150
- </div>
151
-
152
- <div class="step-actions">
153
- <button class="btn btn-secondary" id="btn-conn-cancel">Cancel</button>
154
- <button class="btn btn-outline" id="btn-conn-test">Test</button>
155
- <button class="btn btn-primary" id="btn-conn-save">Save Connection</button>
156
- </div>
157
- </div>
85
+ <!-- Provider card grid -->
86
+ <div class="provider-grid" id="provider-grid"></div>
158
87
 
159
- <!-- Main step actions (shown when not adding a connection) -->
88
+ <!-- Provider count info + nav -->
160
89
  <div class="step-actions" id="step1-actions">
161
90
  <button class="btn btn-secondary" id="btn-step1-back">Back</button>
162
- <button class="btn btn-outline" id="btn-step1-add">Add Connection</button>
163
- <button class="btn btn-primary" id="btn-step1-next" disabled>Next</button>
91
+ <span class="nav-info" id="provider-count-info"></span>
92
+ <button class="btn btn-primary" id="btn-step1-next" disabled>Choose Models</button>
164
93
  </div>
165
94
  </section>
166
95
 
167
96
  <!-- ═══════════════════════════════════════════════════════════
168
- Step 2: Model Assignment
97
+ Step 2: Model Assignment (Radio Options)
169
98
  ═══════════════════════════════════════════════════════════ -->
170
99
  <section class="step-content hidden" id="step-2" data-testid="step-models">
171
- <h2>Model Assignment</h2>
172
- <p class="step-description">Choose default models for chat and embeddings.</p>
173
-
174
- <!-- LLM Card -->
175
- <div class="model-card">
176
- <div class="model-card-header">
177
- <span class="model-card-title">Chat Model (LLM)</span>
178
- <span class="model-card-help">Used for conversations, tool calls, and reasoning.</span>
179
- </div>
180
- <div class="field-group">
181
- <label for="llm-connection">Connection</label>
182
- <select id="llm-connection">
183
- <option value="" disabled selected>Select a connection</option>
184
- </select>
185
- </div>
186
- <div class="field-group">
187
- <label for="llm-model">Model</label>
188
- <select id="llm-model"><option value="">Loading...</option></select>
189
- </div>
190
- <div class="field-group">
191
- <label for="llm-small-model">Small Model <span style="color:var(--color-text-tertiary);font-weight:normal">(optional)</span></label>
192
- <select id="llm-small-model"><option value="">(same as chat model)</option></select>
193
- <p class="field-hint">Used for lightweight tasks like memory extraction.</p>
194
- </div>
195
- </div>
100
+ <h2>Choose Your Models</h2>
101
+ <p class="step-description">Pre-selected from your providers. Adjust if needed.</p>
196
102
 
197
- <!-- Embeddings Card -->
198
- <div class="model-card">
199
- <div class="model-card-header">
200
- <span class="model-card-title">Embeddings</span>
201
- <span class="model-card-help">Used for vector search and memory.</span>
202
- </div>
203
- <div class="field-group">
204
- <label for="emb-connection">Connection</label>
205
- <select id="emb-connection">
206
- <option value="" disabled selected>Select a connection</option>
207
- </select>
208
- </div>
209
- <div class="field-group">
210
- <label for="emb-model">Embedding Model</label>
211
- <select id="emb-model"><option value="">Loading...</option></select>
212
- </div>
213
- <div class="field-group">
214
- <label for="emb-dims">Embedding Dimensions</label>
215
- <input id="emb-dims" type="number" value="1536" min="1" step="1">
216
- <p class="field-hint">Set automatically for known models. Override only if needed.</p>
217
- </div>
218
- </div>
103
+ <!-- Model groups rendered dynamically -->
104
+ <div id="model-groups"></div>
219
105
 
220
- <button class="add-connection-link" type="button" id="btn-models-add-conn">Need another connection?</button>
106
+ <!-- Hidden fields for backward compatibility with API payload -->
107
+ <input type="hidden" id="llm-connection" value="">
108
+ <input type="hidden" id="llm-model" value="">
109
+ <input type="hidden" id="llm-small-model" value="">
110
+ <input type="hidden" id="emb-connection" value="">
111
+ <input type="hidden" id="emb-model" value="">
112
+ <input type="hidden" id="emb-dims" value="1536">
221
113
 
222
114
  <div id="step2-error" class="field-error hidden" role="alert"></div>
223
115
 
224
116
  <div class="step-actions">
225
117
  <button class="btn btn-secondary" id="btn-step2-back">Back</button>
226
- <button class="btn btn-primary" id="btn-step2-next">Next</button>
118
+ <button class="btn btn-primary" id="btn-step2-next">Voice Setup</button>
227
119
  </div>
228
120
  </section>
229
121
 
230
122
  <!-- ═══════════════════════════════════════════════════════════
231
- Step 3: Options
123
+ Step 3: Voice (TTS / STT)
232
124
  ═══════════════════════════════════════════════════════════ -->
233
- <section class="step-content hidden" id="step-3" data-testid="step-options">
125
+ <section class="step-content hidden" id="step-3" data-testid="step-voice">
126
+ <h2>Voice Capabilities</h2>
127
+ <p class="step-description">Choose how your assistant speaks and listens.</p>
128
+
129
+ <!-- Voice groups rendered dynamically -->
130
+ <div id="voice-groups"></div>
131
+
132
+ <div class="step-actions">
133
+ <button class="btn btn-secondary" id="btn-step3-back">Back</button>
134
+ <button class="btn btn-primary" id="btn-step3-next">Options</button>
135
+ </div>
136
+ </section>
137
+
138
+ <!-- ═══════════════════════════════════════════════════════════
139
+ Step 4: Options (Channels + Services + Memory)
140
+ ═══════════════════════════════════════════════════════════ -->
141
+ <section class="step-content hidden" id="step-4" data-testid="step-options">
234
142
  <h2>Options</h2>
235
- <p class="step-description">Tweak optional settings before review.</p>
143
+ <p class="step-description">Choose channels, services, and tweak settings before review.</p>
144
+
145
+ <!-- Channels -->
146
+ <div class="options-section">
147
+ <h3 class="options-section-title">Channels</h3>
148
+ <p class="options-section-desc">How you talk to your assistant. Web Chat is always on.</p>
149
+ <div class="toggle-grid" id="channels-grid"></div>
150
+ </div>
151
+
152
+ <!-- Services -->
153
+ <div class="options-section">
154
+ <h3 class="options-section-title">Services</h3>
155
+ <p class="options-section-desc">Extra capabilities for your stack.</p>
156
+ <div class="toggle-grid" id="services-grid"></div>
157
+ </div>
236
158
 
237
159
  <!-- Ollama in-stack toggle (only shown when relevant) -->
238
160
  <div class="addon-row hidden" id="ollama-addon">
@@ -251,25 +173,37 @@
251
173
  <p class="field-hint">Identifies the memory owner. Defaults to your email or "default_user".</p>
252
174
  </div>
253
175
 
176
+ <div id="step4-error" class="field-error hidden" role="alert"></div>
177
+
254
178
  <div class="step-actions">
255
- <button class="btn btn-secondary" id="btn-step3-back">Back</button>
256
- <button class="btn btn-primary" id="btn-step3-next">Next</button>
179
+ <button class="btn btn-secondary" id="btn-step4-back">Back</button>
180
+ <button class="btn btn-primary" id="btn-step4-next">Review</button>
257
181
  </div>
258
182
  </section>
259
183
 
260
184
  <!-- ═══════════════════════════════════════════════════════════
261
- Step 4: Review & Install
185
+ Step 5: Review & Install
262
186
  ═══════════════════════════════════════════════════════════ -->
263
- <section class="step-content hidden" id="step-4" data-testid="step-review">
187
+ <section class="step-content hidden" id="step-5" data-testid="step-review">
264
188
  <h2>Review &amp; Install</h2>
265
189
  <p class="step-description">Confirm your settings, then install.</p>
266
190
 
267
- <div class="review-grid" id="review-grid"></div>
191
+ <div id="review-summary"></div>
192
+
193
+ <div class="review-json-toggle" id="review-json-toggle">
194
+ <button class="btn-json-toggle" id="btn-toggle-json" type="button">Show Setup JSON</button>
195
+ </div>
196
+ <div class="review-json hidden" id="review-json">
197
+ <pre id="review-json-pre"></pre>
198
+ </div>
199
+
200
+ <!-- Keep old review-grid for backward compat (hidden, populated for tests) -->
201
+ <div class="hidden" id="review-grid"></div>
268
202
 
269
203
  <div id="install-error" class="install-error hidden" role="alert"></div>
270
204
 
271
205
  <div class="step-actions" id="review-actions">
272
- <button class="btn btn-secondary" id="btn-step4-back">Back</button>
206
+ <button class="btn btn-secondary" id="btn-step5-back">Back</button>
273
207
  <button class="btn btn-primary" id="btn-install">Install</button>
274
208
  </div>
275
209
  </section>
@@ -330,7 +264,7 @@
330
264
  <h2>Setup Complete</h2>
331
265
  <p class="done-subtitle">Your OpenPalm stack is up and running.</p>
332
266
  <ul class="service-list" id="deploy-service-list"></ul>
333
- <a href="/" class="btn btn-primary">Open Console</a>
267
+ <a href="http://localhost:4096" class="btn btn-primary">Open Console</a>
334
268
  </div>
335
269
 
336
270
  <!-- Deploy error actions -->