ondeckllm 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/public/app.js CHANGED
@@ -5,7 +5,7 @@ const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
5
5
 
6
6
  let providerMeta = {};
7
7
  let providerData = {};
8
- let taskRoutes = {};
8
+ let globalLineup = [];
9
9
  let profiles = {};
10
10
  let activeProfile = null;
11
11
  let discoveredProviders = [];
@@ -16,6 +16,7 @@ async function init() {
16
16
  setupNavigation();
17
17
  await Promise.all([
18
18
  loadProviders(),
19
+ loadProviderOrder(),
19
20
  loadRoutes(),
20
21
  loadProfiles(),
21
22
  loadDiscovery()
@@ -87,8 +88,11 @@ async function loadDiscovery() {
87
88
 
88
89
  function renderWelcomeBanner() {
89
90
  const banner = $('#welcome-banner');
90
- // Count configured providers
91
- const configured = Object.values(providerData).filter(p => p.status === 'active' || p.status === 'configured');
91
+ // Get configured providers in priority order
92
+ const orderedIds = getOrderedProviderIds();
93
+ const configured = orderedIds
94
+ .map(id => providerData[id])
95
+ .filter(p => p && (p.status === 'active' || p.status === 'configured'));
92
96
 
93
97
  if (configured.length === 0) {
94
98
  banner.innerHTML = '';
@@ -99,14 +103,14 @@ function renderWelcomeBanner() {
99
103
  <div class="welcome-banner">
100
104
  <div class="banner-icon">\u2714\uFE0F</div>
101
105
  <div class="banner-text">
102
- <h3>Found ${configured.length} provider${configured.length !== 1 ? 's' : ''} configured</h3>
103
- <p>${discoveredProviders.length > 0 ? `Auto-discovered: ${discoveredProviders.join(', ')}` : 'Ready to route requests'}</p>
106
+ <h3>${configured.length} provider${configured.length !== 1 ? 's' : ''} configured</h3>
107
+ <p>Priority order (drag cards below to reorder)</p>
104
108
  </div>
105
109
  <div class="banner-providers">
106
- ${configured.map(p => `
110
+ ${configured.map((p, i) => `
107
111
  <span class="provider-chip">
108
112
  <span class="chip-dot" style="background:${p.color}"></span>
109
- ${p.name}
113
+ <span class="chip-rank">#${i + 1}</span> ${p.name}
110
114
  </span>
111
115
  `).join('')}
112
116
  </div>
@@ -126,20 +130,98 @@ async function loadProviders() {
126
130
  ]);
127
131
  }
128
132
 
133
+ let providerOrder = null;
134
+
135
+ async function loadProviderOrder() {
136
+ const data = await api('/providers/order');
137
+ providerOrder = data.order;
138
+ }
139
+
140
+ function getOrderedProviderIds() {
141
+ const allIds = Object.keys(providerData);
142
+ if (!providerOrder) return allIds;
143
+ // Return ordered ids first, then any new ones not in the saved order
144
+ const ordered = providerOrder.filter(id => allIds.includes(id));
145
+ const remaining = allIds.filter(id => !providerOrder.includes(id));
146
+ return [...ordered, ...remaining];
147
+ }
148
+
129
149
  function renderProviders() {
130
150
  const page = $('#page-providers');
131
151
  page.innerHTML = `
132
152
  <div class="page-header">
133
153
  <h1>Provider Hub</h1>
134
- <p>Manage API keys and connections for your LLM providers</p>
154
+ <p>Manage API keys and connections. Drag to set priority order.</p>
135
155
  </div>
136
156
  <div class="card-grid" id="provider-grid"></div>
137
157
  `;
138
158
 
139
159
  const grid = $('#provider-grid');
140
- for (const [id, info] of Object.entries(providerData)) {
141
- grid.appendChild(createProviderCard(id, info));
160
+ const orderedIds = getOrderedProviderIds();
161
+ for (const id of orderedIds) {
162
+ const info = providerData[id];
163
+ if (!info) continue;
164
+ const card = createProviderCard(id, info);
165
+ card.setAttribute('draggable', 'true');
166
+ card.dataset.providerId = id;
167
+ grid.appendChild(card);
142
168
  }
169
+ setupProviderDragDrop(grid);
170
+ }
171
+
172
+ function setupProviderDragDrop(grid) {
173
+ let dragId = null;
174
+
175
+ grid.addEventListener('dragstart', (e) => {
176
+ const card = e.target.closest('.card[data-provider-id]');
177
+ if (!card) return;
178
+ dragId = card.dataset.providerId;
179
+ card.classList.add('dragging');
180
+ e.dataTransfer.effectAllowed = 'move';
181
+ });
182
+
183
+ grid.addEventListener('dragend', (e) => {
184
+ const card = e.target.closest('.card[data-provider-id]');
185
+ if (card) card.classList.remove('dragging');
186
+ grid.querySelectorAll('.card').forEach(c => c.classList.remove('drag-over-card'));
187
+ });
188
+
189
+ grid.addEventListener('dragover', (e) => {
190
+ e.preventDefault();
191
+ const card = e.target.closest('.card[data-provider-id]');
192
+ if (card && card.dataset.providerId !== dragId) {
193
+ grid.querySelectorAll('.card').forEach(c => c.classList.remove('drag-over-card'));
194
+ card.classList.add('drag-over-card');
195
+ }
196
+ });
197
+
198
+ grid.addEventListener('dragleave', (e) => {
199
+ const card = e.target.closest('.card[data-provider-id]');
200
+ if (card) card.classList.remove('drag-over-card');
201
+ });
202
+
203
+ grid.addEventListener('drop', async (e) => {
204
+ e.preventDefault();
205
+ grid.querySelectorAll('.card').forEach(c => c.classList.remove('drag-over-card'));
206
+ const targetCard = e.target.closest('.card[data-provider-id]');
207
+ if (!targetCard || !dragId) return;
208
+ const targetId = targetCard.dataset.providerId;
209
+ if (dragId === targetId) return;
210
+
211
+ // Reorder
212
+ const ids = getOrderedProviderIds();
213
+ const fromIdx = ids.indexOf(dragId);
214
+ const toIdx = ids.indexOf(targetId);
215
+ if (fromIdx === -1 || toIdx === -1) return;
216
+ ids.splice(fromIdx, 1);
217
+ ids.splice(toIdx, 0, dragId);
218
+
219
+ providerOrder = ids;
220
+ await api('/providers/order', { method: 'PUT', body: { order: ids } });
221
+ renderProviders();
222
+ toast('Provider order saved', 'success');
223
+ dragId = null;
224
+ });
143
225
  }
144
226
 
145
227
  function getHealthIndicator(info) {
@@ -210,6 +292,25 @@ function renderCloudProviderForm(id, info) {
210
292
  }
211
293
 
212
294
  function renderLocalProviderForm(id, info) {
295
+ if (id === 'remote-ollama') {
296
+ const currentUrl = info.baseUrl || '';
297
+ return `
298
+ <div class="input-group">
299
+ <label>Ollama Server URL</label>
300
+ <div class="input-wrapper">
301
+ <input type="text" id="remote-ollama-url" placeholder="http://192.168.1.100:11434" value="${currentUrl}" />
302
+ </div>
303
+ <p style="font-size:12px;color:var(--text-muted);margin-top:4px;">
304
+ Point to any machine running Ollama — manage its models remotely from this UI.
305
+ </p>
306
+ </div>
307
+ <div class="btn-group">
308
+ <button class="btn btn-primary btn-sm" onclick="saveRemoteOllamaUrl()">Save URL</button>
309
+ <button class="btn btn-success btn-sm" onclick="testProvider('${id}')">Test Connection</button>
310
+ ${info.configured ? `<button class="btn btn-danger btn-sm" onclick="removeProvider('${id}')">Remove</button>` : ''}
311
+ </div>
312
+ `;
313
+ }
213
314
  return `
214
315
  <p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">
215
316
  Local provider \u2014 no API key needed. Make sure the service is running.
@@ -220,6 +321,20 @@ function renderLocalProviderForm(id, info) {
220
321
  `;
221
322
  }
222
323
 
324
+ window.saveRemoteOllamaUrl = async function() {
325
+ const input = document.getElementById('remote-ollama-url');
326
+ const url = input?.value?.trim();
327
+ if (!url) return toast('Enter a URL (e.g. http://192.168.1.100:11434)', 'error');
328
+ const result = await api('/providers/remote-ollama/url', { method: 'POST', body: { baseUrl: url } });
329
+ if (result.ok) {
330
+ toast('Remote Ollama URL saved', 'success');
331
+ await loadProviders();
332
+ renderProviders();
333
+ } else {
334
+ toast(`Error: ${result.error}`, 'error');
335
+ }
336
+ };
337
+
223
338
  window.toggleKeyVis = function(id) {
224
339
  const input = $(`#key-${id}`);
225
340
  input.type = input.type === 'password' ? 'text' : 'password';
@@ -304,13 +419,19 @@ function renderCostTracker() {
304
419
  page.innerHTML = `
305
420
  <div class="page-header">
306
421
  <h1>Cost Tracker</h1>
307
- <p>Track LLM spending across all providers</p>
422
+ <p>Track LLM spending across all providers
423
+ <button class="btn btn-sm btn-secondary" onclick="syncOpenClawUsage()" id="sync-btn" style="margin-left:12px;">⟳ Sync OpenClaw</button>
424
+ <span id="sync-status" style="font-size:12px;color:var(--text-muted);margin-left:8px;"></span>
425
+ </p>
308
426
  </div>
309
427
  <div class="cost-summary-cards" id="cost-summary-cards">
310
- <div class="cost-card"><div class="cost-card-label">Today</div><div class="cost-card-value" id="cost-today">--</div></div>
428
+ <div class="cost-card"><div class="cost-card-label">Last 24h</div><div class="cost-card-value" id="cost-today">--</div></div>
311
429
  <div class="cost-card"><div class="cost-card-label">This Week</div><div class="cost-card-value" id="cost-week">--</div></div>
312
430
  <div class="cost-card"><div class="cost-card-label">This Month</div><div class="cost-card-value" id="cost-month">--</div></div>
313
- <div class="cost-card"><div class="cost-card-label">All Time</div><div class="cost-card-value" id="cost-all">--</div></div>
431
+ <div class="cost-card"><div class="cost-card-label">Tracked Total</div><div class="cost-card-value" id="cost-all">--</div></div>
432
+ </div>
433
+ <div class="cost-note" style="font-size:12px;color:var(--text-muted);margin-top:8px;padding:8px 12px;background:rgba(255,255,255,0.03);border-radius:6px;border-left:3px solid var(--accent);">
434
+ 📊 Costs are tracked from local OpenClaw sessions on this machine and auto-sync every 5 minutes. Historical usage before tracking started is not included. Connect an Anthropic Admin API key in Provider Hub for complete billing history.
314
435
  </div>
315
436
  <div class="cost-chart-container">
316
437
  <h3>Daily Spend (Last 30 Days)</h3>
@@ -333,6 +454,28 @@ function renderCostTracker() {
333
454
  refreshCostData();
334
455
  }
335
456
 
457
+ window.syncOpenClawUsage = async function() {
458
+ const btn = document.getElementById('sync-btn');
459
+ const status = document.getElementById('sync-status');
460
+ if (btn) { btn.disabled = true; btn.textContent = '⟳ Syncing...'; }
461
+ try {
462
+ const result = await api('/usage/sync', { method: 'POST' });
463
+ if (result.ok) {
464
+ const msg = result.imported > 0
465
+ ? `Imported ${result.imported} new entries from ${result.filesScanned} files`
466
+ : `Up to date (${result.totalTracked} files tracked)`;
467
+ if (status) status.textContent = msg;
468
+ toast(msg, 'success');
469
+ await refreshCostData();
470
+ } else {
471
+ toast(`Sync error: ${result.error}`, 'error');
472
+ }
473
+ } catch (err) {
474
+ toast(`Sync failed: ${err.message}`, 'error');
475
+ }
476
+ if (btn) { btn.disabled = false; btn.textContent = '⟳ Sync OpenClaw'; }
477
+ };
478
+
336
479
  async function refreshCostData() {
337
480
  await loadCostSummary();
338
481
  if (!costSummary) return;
@@ -640,72 +783,111 @@ function escapeHtml(str) {
640
783
  }
641
784
 
642
785
  // ══════════════════════════════════════
643
- // ── Batting Order (Task Router)
786
+ // ── Batting Order (Global Lineup)
644
787
  // ══════════════════════════════════════
645
788
 
646
- const TASK_TYPES = {
647
- chat: { icon: '\u{1F4AC}', label: 'Chat / General', desc: 'Conversational AI, Q&A, summarization' },
648
- coding: { icon: '\u{1F4BB}', label: 'Coding', desc: 'Code generation, review, debugging' },
649
- images: { icon: '\u{1F5BC}\uFE0F', label: 'Images', desc: 'Image generation and editing' },
650
- video: { icon: '\u{1F3A5}', label: 'Video', desc: 'Video generation and processing' },
651
- research: { icon: '\u{1F50D}', label: 'Research / RAG', desc: 'Deep research, retrieval, analysis' },
652
- data: { icon: '\u{1F4CA}', label: 'Data / Analysis', desc: 'Data processing, charts, analytics' }
789
+ // Default model per provider — used when building implied lineup from provider order
790
+ const DEFAULT_MODELS = {
791
+ anthropic: 'claude-sonnet-4-5-20250929',
792
+ openai: 'gpt-4o',
793
+ google: 'gemini-2.0-flash',
794
+ groq: 'llama-3.3-70b-versatile',
795
+ mistral: 'mistral-large-latest',
796
+ deepseek: 'deepseek-chat',
797
+ together: 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo',
798
+ openrouter: 'anthropic/claude-opus-4-6',
799
+ ollama: 'llama3.2',
800
+ 'remote-ollama': null
653
801
  };
654
802
 
803
+ // Build implied lineup from global provider order (fallback when no custom lineup set)
804
+ function getImpliedLineup() {
805
+ const orderedIds = getOrderedProviderIds();
806
+ const lineup = [];
807
+ for (const pid of orderedIds) {
808
+ const prov = providerData[pid];
809
+ if (!prov || !prov.configured) continue;
810
+ let model = DEFAULT_MODELS[pid];
811
+ if (pid === 'remote-ollama') {
812
+ const remoteModels = providerMeta?.['remote-ollama']?.models || prov.models || [];
813
+ model = remoteModels[0] || null;
814
+ }
815
+ if (model) lineup.push({ provider: pid, model, implied: true });
816
+ }
817
+ return lineup;
818
+ }
819
+
655
820
  async function loadRoutes() {
656
- taskRoutes = await api('/routes');
821
+ const data = await api('/routes');
822
+ globalLineup = data.lineup || [];
657
823
  }
658
824
 
659
825
  function renderRouter() {
660
826
  const page = $('#page-router');
827
+ const hasCustom = globalLineup.length > 0;
828
+ const displayLineup = hasCustom ? globalLineup : getImpliedLineup();
829
+
661
830
  page.innerHTML = `
662
831
  <div class="page-header">
663
832
  <h1>Batting Order</h1>
664
- <p>Set your model lineup per task type. #1 is your primary \u2014 the rest are fallbacks in order.
833
+ <p>#1 is your primary model \u2014 the rest are fallbacks in order.
665
834
  <span class="sync-badge">\u21C4 Syncs to OpenClaw</span>
666
835
  </p>
667
836
  </div>
668
- <div id="task-sections"></div>
669
- `;
670
-
671
- const container = $('#task-sections');
672
- for (const [taskId, meta] of Object.entries(TASK_TYPES)) {
673
- container.appendChild(createTaskSection(taskId, meta));
674
- }
675
- }
676
-
677
- function createTaskSection(taskId, meta) {
678
- const section = document.createElement('div');
679
- section.className = 'task-section';
680
-
681
- const routes = taskRoutes[taskId] || [];
682
-
683
- section.innerHTML = `
684
- <div class="task-header">
685
- <span class="task-icon">${meta.icon}</span>
686
- <h3>${meta.label}</h3>
687
- <span class="task-desc">${meta.desc}</span>
688
- </div>
689
837
  <div class="lineup-card">
690
838
  <div class="lineup-card-header">
691
- <span class="lineup-title">LINEUP</span>
692
- <span>${routes.length} model${routes.length !== 1 ? 's' : ''}</span>
839
+ <span class="lineup-title">${hasCustom ? 'CUSTOM LINEUP' : 'IMPLIED FROM PROVIDER ORDER'}</span>
840
+ <span>${displayLineup.length} model${displayLineup.length !== 1 ? 's' : ''}</span>
841
+ ${hasCustom ? `<button class="btn btn-sm btn-secondary" onclick="clearGlobalLineup()" style="margin-left:auto;font-size:11px;">Reset to Provider Order</button>` : ''}
693
842
  </div>
694
- <div class="lineup-list" data-task="${taskId}">
695
- ${routes.length === 0 ? '<div class="empty-state">No models in the lineup \u2014 add one to get started</div>' : ''}
696
- ${routes.map((r, i) => renderLineupEntry(taskId, r, i, routes.length)).join('')}
843
+ <div class="lineup-list" id="global-lineup-list">
844
+ ${displayLineup.length > 0
845
+ ? displayLineup.map((r, i) => hasCustom
846
+ ? renderLineupEntry('global', r, i, displayLineup.length)
847
+ : renderImpliedEntry(r, i)
848
+ ).join('')
849
+ : '<div class="empty-state">No providers configured. Add providers in the Provider Hub first.</div>'
850
+ }
697
851
  </div>
698
852
  <div class="lineup-add-btn">
699
- <button class="btn" onclick="openAddModelModal('${taskId}')">+ Add Model to Lineup</button>
853
+ <button class="btn" onclick="openAddModelModal()">+ Add Model</button>
700
854
  </div>
701
855
  </div>
702
856
  `;
703
857
 
704
- setupDragDrop(section.querySelector('.lineup-list'), taskId);
858
+ if (hasCustom) {
859
+ setupDragDrop($('#global-lineup-list'));
860
+ }
861
+ }
862
+
863
+ function renderImpliedEntry(route, index) {
864
+ const provColor = providerData[route.provider]?.color || '#888';
865
+ const provName = providerData[route.provider]?.name || route.provider;
866
+ const roleLabel = index === 0 ? 'PRIMARY' : `FB #${index}`;
867
+ const roleClass = index === 0 ? 'role-primary' : 'role-fallback';
868
+ const rankClass = getRankClass(index);
705
869
 
706
- return section;
870
+ return `
871
+ <div class="lineup-entry implied-entry">
872
+ <span class="drag-handle" style="visibility:hidden">\u2982</span>
873
+ <span class="rank-badge ${rankClass}">${index + 1}</span>
874
+ <span class="lineup-provider">
875
+ <span class="prov-dot" style="background:${provColor};box-shadow:0 0 4px ${provColor}66"></span>
876
+ <span class="prov-name">${provName}</span>
877
+ </span>
878
+ <span class="lineup-model">${route.model}</span>
879
+ <span class="lineup-role ${roleClass}">${roleLabel}</span>
880
+ </div>
881
+ `;
707
882
  }
708
883
 
884
+ window.clearGlobalLineup = async function() {
885
+ globalLineup = [];
886
+ await api('/routes', { method: 'PUT', body: { lineup: [] } });
887
+ renderRouter();
888
+ toast('Lineup reset to provider order', 'success');
889
+ };
890
+
709
891
  function getRankLabel(index) {
710
892
  if (index === 0) return 'Primary';
711
893
  return `Fallback #${index}`;
@@ -718,7 +900,7 @@ function getRankClass(index) {
718
900
  return 'rank-n';
719
901
  }
720
902
 
721
- function renderLineupEntry(taskId, route, index, total) {
903
+ function renderLineupEntry(scope, route, index, total) {
722
904
  const provColor = providerData[route.provider]?.color || '#888';
723
905
  const provName = providerData[route.provider]?.name || route.provider;
724
906
  const rankClass = getRankClass(index);
@@ -735,12 +917,12 @@ function renderLineupEntry(taskId, route, index, total) {
735
917
  </span>
736
918
  <span class="lineup-model">${route.model}</span>
737
919
  <span class="lineup-role ${roleClass}">${roleLabel}</span>
738
- <button class="lineup-remove" onclick="removeRoute('${taskId}', ${index})">&times;</button>
920
+ <button class="lineup-remove" onclick="removeRoute(${index})">&times;</button>
739
921
  </div>
740
922
  `;
741
923
  }
742
924
 
743
- function setupDragDrop(list, taskId) {
925
+ function setupDragDrop(list) {
744
926
  let dragIndex = null;
745
927
 
746
928
  list.addEventListener('dragstart', (e) => {
@@ -782,11 +964,9 @@ function setupDragDrop(list, taskId) {
782
964
  }
783
965
 
784
966
  if (dragIndex !== null && dragIndex !== dropIndex) {
785
- const routes = taskRoutes[taskId] || [];
786
- const [moved] = routes.splice(dragIndex, 1);
787
- routes.splice(dropIndex, 0, moved);
788
- taskRoutes[taskId] = routes;
789
- await api('/routes', { method: 'PUT', body: taskRoutes });
967
+ const [moved] = globalLineup.splice(dragIndex, 1);
968
+ globalLineup.splice(dropIndex, 0, moved);
969
+ await api('/routes', { method: 'PUT', body: { lineup: globalLineup } });
790
970
  renderRouter();
791
971
  toast('Lineup reordered', 'success');
792
972
  }
@@ -796,8 +976,8 @@ function setupDragDrop(list, taskId) {
796
976
 
797
977
  // ── Add Model Modal ──
798
978
 
799
- window.openAddModelModal = function(taskId) {
800
- const existing = (taskRoutes[taskId] || []).map(r => `${r.provider}:${r.model}`);
979
+ window.openAddModelModal = function() {
980
+ const existing = globalLineup.map(r => `${r.provider}:${r.model}`);
801
981
  const configured = Object.entries(providerData).filter(([, p]) => p.configured);
802
982
 
803
983
  const modalRoot = $('#modal-root');
@@ -805,7 +985,7 @@ window.openAddModelModal = function(taskId) {
805
985
  <div class="modal-overlay" onclick="if(event.target===this)closeModal()">
806
986
  <div class="modal">
807
987
  <div class="modal-header">
808
- <h2>Add Model to ${TASK_TYPES[taskId].label} Lineup</h2>
988
+ <h2>Add Model to Batting Order</h2>
809
989
  <p>Select from your configured providers</p>
810
990
  </div>
811
991
  <div class="modal-body">
@@ -821,7 +1001,7 @@ window.openAddModelModal = function(taskId) {
821
1001
  const key = `${pid}:${m}`;
822
1002
  const added = existing.includes(key);
823
1003
  return `
824
- <div class="modal-model-item${added ? ' already-added' : ''}" onclick="${added ? '' : `selectModelForLineup('${taskId}','${pid}','${m}')`}">
1004
+ <div class="modal-model-item${added ? ' already-added' : ''}" onclick="${added ? '' : `selectModelForLineup('${pid}','${m}')`}">
825
1005
  ${added ? '<span class="model-check">\u2714</span>' : '<span style="width:14px"></span>'}
826
1006
  ${m}
827
1007
  </div>
@@ -839,23 +1019,22 @@ window.openAddModelModal = function(taskId) {
839
1019
  `;
840
1020
  };
841
1021
 
842
- window.selectModelForLineup = async function(taskId, provider, model) {
843
- if (!taskRoutes[taskId]) taskRoutes[taskId] = [];
844
- taskRoutes[taskId].push({ provider, model });
845
- await api('/routes', { method: 'PUT', body: taskRoutes });
1022
+ window.selectModelForLineup = async function(provider, model) {
1023
+ globalLineup.push({ provider, model });
1024
+ await api('/routes', { method: 'PUT', body: { lineup: globalLineup } });
846
1025
  closeModal();
847
1026
  renderRouter();
848
- toast(`Added ${model} to ${TASK_TYPES[taskId].label} lineup`, 'success');
1027
+ toast(`Added ${model} to batting order`, 'success');
849
1028
  };
850
1029
 
851
1030
  window.closeModal = function() {
852
1031
  $('#modal-root').innerHTML = '';
853
1032
  };
854
1033
 
855
- window.removeRoute = async function(taskId, index) {
856
- const route = taskRoutes[taskId][index];
857
- taskRoutes[taskId].splice(index, 1);
858
- await api('/routes', { method: 'PUT', body: taskRoutes });
1034
+ window.removeRoute = async function(index) {
1035
+ const route = globalLineup[index];
1036
+ globalLineup.splice(index, 1);
1037
+ await api('/routes', { method: 'PUT', body: { lineup: globalLineup } });
859
1038
  renderRouter();
860
1039
  toast(`Removed ${route.model} from lineup`, 'info');
861
1040
  };
@@ -875,7 +1054,7 @@ function renderProfiles() {
875
1054
  page.innerHTML = `
876
1055
  <div class="page-header">
877
1056
  <h1>Profiles</h1>
878
- <p>One-click routing presets. Activating a profile reconfigures all batting orders.</p>
1057
+ <p>One-click routing presets. Activating a profile sets the batting order and syncs to OpenClaw.</p>
879
1058
  </div>
880
1059
  <div class="profile-grid" id="profile-grid"></div>
881
1060
  `;
@@ -891,20 +1070,15 @@ function createProfileCard(id, profile) {
891
1070
  card.className = `profile-card${activeProfile === id ? ' active' : ''}`;
892
1071
  card.onclick = () => activateProfile(id);
893
1072
 
894
- const models = new Set();
895
- for (const routes of Object.values(profile.taskRoutes || {})) {
896
- for (const r of routes) {
897
- models.add(r.model);
898
- }
899
- }
1073
+ const routes = profile.routes || [];
900
1074
 
901
1075
  card.innerHTML = `
902
1076
  <div class="profile-icon">${profile.icon}</div>
903
1077
  <h3>${profile.name}</h3>
904
1078
  <p>${profile.description}</p>
905
1079
  <div class="profile-models">
906
- ${[...models].slice(0, 6).map(m => `<span class="model-tag">${m}</span>`).join('')}
907
- ${models.size > 6 ? `<span class="model-tag">+${models.size - 6} more</span>` : ''}
1080
+ ${routes.slice(0, 6).map(r => `<span class="model-tag">${r.model}</span>`).join('')}
1081
+ ${routes.length > 6 ? `<span class="model-tag">+${routes.length - 6} more</span>` : ''}
908
1082
  </div>
909
1083
  `;
910
1084
  return card;
@@ -914,7 +1088,7 @@ async function activateProfile(id) {
914
1088
  const result = await api('/profiles/activate', { method: 'POST', body: { profileId: id } });
915
1089
  if (result.ok) {
916
1090
  activeProfile = id;
917
- taskRoutes = result.routes;
1091
+ globalLineup = result.lineup || [];
918
1092
  renderProfiles();
919
1093
  renderRouter();
920
1094
  toast(`${profiles[id].name} profile activated \u2014 synced to OpenClaw`, 'success');
@@ -1085,33 +1259,82 @@ function renderOllamaWizard() {
1085
1259
  return;
1086
1260
  }
1087
1261
 
1262
+ // Check if remote Ollama is already configured and reachable
1263
+ const remoteConfigured = providerData['remote-ollama']?.configured;
1264
+ const remoteUrl = providerData['remote-ollama']?.baseUrl || '';
1265
+ const remoteActive = providerData['remote-ollama']?.status === 'active';
1266
+
1088
1267
  // Step 1: Status check
1089
1268
  if (!ollamaStatus.installed) {
1269
+ // If remote Ollama is configured and active, skip the install prompt entirely
1270
+ if (remoteActive && remoteUrl) {
1271
+ container.innerHTML = `
1272
+ <div class="wizard-section wizard-section-ok">
1273
+ <div class="wizard-header">
1274
+ <span class="wizard-step wizard-step-ok">&#10003;</span>
1275
+ <h3>Remote Ollama Connected</h3>
1276
+ <span class="wizard-status wizard-status-ok">via ${remoteUrl}</span>
1277
+ </div>
1278
+ <p class="wizard-desc">Managing models on your remote Ollama server. Switch to the <strong>Remote Ollama</strong> tab below to browse and pull models.</p>
1279
+ <div class="btn-group" style="margin-top:8px">
1280
+ <button class="btn btn-success btn-sm" onclick="testProvider('remote-ollama')">Test Connection</button>
1281
+ <button class="btn btn-secondary btn-sm" onclick="recheckOllamaStatus()">Re-check</button>
1282
+ </div>
1283
+ </div>
1284
+ `;
1285
+ return;
1286
+ }
1287
+
1090
1288
  container.innerHTML = `
1091
1289
  <div class="wizard-section">
1092
1290
  <div class="wizard-header">
1093
1291
  <span class="wizard-step">1</span>
1094
- <h3>Install Ollama</h3>
1095
- <span class="wizard-status wizard-status-warn">Not Installed</span>
1292
+ <h3>Set Up Ollama</h3>
1293
+ <span class="wizard-status wizard-status-warn">Not Detected</span>
1096
1294
  </div>
1097
- <p class="wizard-desc">Ollama lets you run LLMs locally. Install it first:</p>
1098
- <div class="wizard-commands">
1099
- <div class="wizard-cmd">
1100
- <span class="wizard-cmd-label">macOS (Homebrew)</span>
1101
- <div class="wizard-cmd-row">
1102
- <code>brew install ollama</code>
1103
- <button class="btn btn-sm btn-secondary" onclick="copyCmd('brew install ollama')">Copy</button>
1104
- </div>
1295
+ <p class="wizard-desc">Run LLMs locally or connect to a remote Ollama server on your network.</p>
1296
+
1297
+ <div class="wizard-options" style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
1298
+ <div class="wizard-option-card" style="flex:1;min-width:220px;border:1px solid var(--border);border-radius:8px;padding:16px;">
1299
+ <h4 style="margin:0 0 8px 0;">🖥 Install Locally</h4>
1300
+ <p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">Install Ollama on this machine to run models here.</p>
1301
+ <button class="btn btn-primary btn-sm" id="ollama-install-btn" onclick="installOllama()">⬇ Install Now</button>
1105
1302
  </div>
1106
- <div class="wizard-cmd">
1107
- <span class="wizard-cmd-label">Linux / macOS (curl)</span>
1108
- <div class="wizard-cmd-row">
1109
- <code>curl -fsSL https://ollama.com/install.sh | sh</code>
1110
- <button class="btn btn-sm btn-secondary" onclick="copyCmd('curl -fsSL https://ollama.com/install.sh | sh')">Copy</button>
1303
+ <div class="wizard-option-card" style="flex:1;min-width:220px;border:1px solid var(--border);border-radius:8px;padding:16px;">
1304
+ <h4 style="margin:0 0 8px 0;">🌐 Connect to Remote</h4>
1305
+ <p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">Have Ollama running on another machine? Enter its URL to manage models remotely.</p>
1306
+ <div class="input-group" style="margin-bottom:8px;">
1307
+ <div class="input-wrapper">
1308
+ <input type="text" id="wizard-remote-url" placeholder="http://192.168.1.100:11434" value="${remoteUrl}" style="font-size:13px;" />
1309
+ </div>
1111
1310
  </div>
1311
+ <button class="btn btn-primary btn-sm" onclick="connectRemoteFromWizard()">Connect</button>
1112
1312
  </div>
1113
1313
  </div>
1114
- <button class="btn btn-primary" onclick="recheckOllamaStatus()" style="margin-top:12px">Re-check Status</button>
1314
+
1315
+ <div id="ollama-install-output" class="wizard-install-output" style="display:none"></div>
1316
+ <div style="display:flex;align-items:center;gap:8px;">
1317
+ <button class="btn btn-secondary btn-sm" onclick="recheckOllamaStatus()">Re-check Status</button>
1318
+ </div>
1319
+ <details class="wizard-manual-install">
1320
+ <summary style="cursor:pointer;color:var(--text-muted);font-size:13px;margin-top:8px">Manual install commands</summary>
1321
+ <div class="wizard-commands" style="margin-top:8px">
1322
+ <div class="wizard-cmd">
1323
+ <span class="wizard-cmd-label">macOS (Homebrew)</span>
1324
+ <div class="wizard-cmd-row">
1325
+ <code>brew install ollama</code>
1326
+ <button class="btn btn-sm btn-secondary" onclick="copyCmd('brew install ollama')">Copy</button>
1327
+ </div>
1328
+ </div>
1329
+ <div class="wizard-cmd">
1330
+ <span class="wizard-cmd-label">Linux / macOS (curl)</span>
1331
+ <div class="wizard-cmd-row">
1332
+ <code>curl -fsSL https://ollama.com/install.sh | sh</code>
1333
+ <button class="btn btn-sm btn-secondary" onclick="copyCmd('curl -fsSL https://ollama.com/install.sh | sh')">Copy</button>
1334
+ </div>
1335
+ </div>
1336
+ </div>
1337
+ </details>
1115
1338
  </div>
1116
1339
  `;
1117
1340
  return;
@@ -1189,6 +1412,86 @@ window.recheckOllamaStatus = async function() {
1189
1412
  toast('Status refreshed', 'info');
1190
1413
  };
1191
1414
 
1415
+ window.connectRemoteFromWizard = async function() {
1416
+ const input = document.getElementById('wizard-remote-url');
1417
+ const url = input?.value?.trim();
1418
+ if (!url) return toast('Enter a URL (e.g. http://192.168.1.100:11434)', 'error');
1419
+
1420
+ // Save the URL
1421
+ const result = await api('/providers/remote-ollama/url', { method: 'POST', body: { baseUrl: url } });
1422
+ if (!result.ok) return toast(`Error: ${result.error}`, 'error');
1423
+
1424
+ // Test the connection
1425
+ const test = await api('/providers/remote-ollama/test', { method: 'POST' });
1426
+ if (test.ok) {
1427
+ toast(`Connected to remote Ollama at ${url}`, 'success');
1428
+ // Reload everything
1429
+ await loadProviders();
1430
+ renderProviders();
1431
+ ollamaSource = 'remote-ollama';
1432
+ await Promise.all([loadOllamaStatus(), loadOllamaModels()]);
1433
+ renderOllamaModels();
1434
+ } else {
1435
+ toast(`Saved URL but connection failed: ${test.message || 'Could not reach server'}`, 'error');
1436
+ await loadProviders();
1437
+ renderProviders();
1438
+ renderOllamaWizard();
1439
+ }
1440
+ };
1441
+
1442
+ window.installOllama = async function() {
1443
+ const btn = document.getElementById('ollama-install-btn');
1444
+ const output = document.getElementById('ollama-install-output');
1445
+ if (!btn || !output) return;
1446
+
1447
+ btn.innerHTML = '<span class="spinner"></span> Installing...';
1448
+ btn.disabled = true;
1449
+ output.style.display = 'block';
1450
+ output.textContent = 'Starting install...\n';
1451
+
1452
+ try {
1453
+ const resp = await fetch('/api/ollama/install', { method: 'POST' });
1454
+ const reader = resp.body.getReader();
1455
+ const decoder = new TextDecoder();
1456
+ let buffer = '';
1457
+
1458
+ while (true) {
1459
+ const { done, value } = await reader.read();
1460
+ if (done) break;
1461
+ buffer += decoder.decode(value, { stream: true });
1462
+ const lines = buffer.split('\n');
1463
+ buffer = lines.pop();
1464
+ for (const line of lines) {
1465
+ if (line.startsWith('data: ')) {
1466
+ try {
1467
+ const data = JSON.parse(line.slice(6));
1468
+ if (data.output) {
1469
+ output.textContent += data.output;
1470
+ output.scrollTop = output.scrollHeight;
1471
+ }
1472
+ if (data.status === 'success') {
1473
+ toast('Ollama installed! Refreshing...', 'success');
1474
+ setTimeout(async () => {
1475
+ await loadOllamaStatus();
1476
+ renderOllamaWizard();
1477
+ }, 2000);
1478
+ } else if (data.status === 'error') {
1479
+ toast(`Install failed: ${data.message}`, 'error');
1480
+ btn.innerHTML = '⬇ Install Now';
1481
+ btn.disabled = false;
1482
+ }
1483
+ } catch {}
1484
+ }
1485
+ }
1486
+ }
1487
+ } catch (err) {
1488
+ toast(`Install failed: ${err.message}`, 'error');
1489
+ output.textContent += `\nError: ${err.message}`;
1490
+ btn.innerHTML = '⬇ Install Now';
1491
+ btn.disabled = false;
1492
+ }
1493
+ };
1494
+
1192
1495
  window.installPack = async function(packId) {
1193
1496
  const btn = document.getElementById(`pack-btn-${packId}`);
1194
1497
  if (!btn) return;
@@ -1294,7 +1597,7 @@ function renderOllamaLibrary() {
1294
1597
  <div class="ollama-model-info">
1295
1598
  <span class="ollama-model-name">${m.name}</span>
1296
1599
  <span class="ollama-model-desc">${m.description}</span>
1297
- <span class="ollama-model-meta">${m.size}</span>
1600
+ <span class="ollama-model-meta">${m.size}${m.disk ? ` · <span class="ollama-disk-size">${m.disk}</span>` : ''}</span>
1298
1601
  </div>
1299
1602
  <div class="ollama-model-actions" id="ollama-action-${m.name.replace(/[^a-z0-9]/gi, '-')}">
1300
1603
  ${installed ? '<span class="ollama-installed-badge">Installed</span>' :