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/package.json +11 -2
- package/src/cost-tracker.js +91 -68
- package/src/public/app.js +403 -100
- package/src/public/index.html +2 -2
- package/src/public/styles.css +39 -1
- package/src/server.js +138 -33
- package/src/storage.js +109 -147
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
|
|
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
|
-
//
|
|
91
|
-
const
|
|
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
|
|
103
|
-
<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
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
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">
|
|
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">
|
|
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 (
|
|
786
|
+
// ── Batting Order (Global Lineup)
|
|
644
787
|
// ══════════════════════════════════════
|
|
645
788
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
692
|
-
<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"
|
|
695
|
-
${
|
|
696
|
-
|
|
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(
|
|
853
|
+
<button class="btn" onclick="openAddModelModal()">+ Add Model</button>
|
|
700
854
|
</div>
|
|
701
855
|
</div>
|
|
702
856
|
`;
|
|
703
857
|
|
|
704
|
-
|
|
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
|
|
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(
|
|
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(
|
|
920
|
+
<button class="lineup-remove" onclick="removeRoute(${index})">×</button>
|
|
739
921
|
</div>
|
|
740
922
|
`;
|
|
741
923
|
}
|
|
742
924
|
|
|
743
|
-
function setupDragDrop(list
|
|
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
|
|
786
|
-
|
|
787
|
-
routes
|
|
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(
|
|
800
|
-
const existing =
|
|
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
|
|
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('${
|
|
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(
|
|
843
|
-
|
|
844
|
-
|
|
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
|
|
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(
|
|
856
|
-
const route =
|
|
857
|
-
|
|
858
|
-
await api('/routes', { method: 'PUT', body:
|
|
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
|
|
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
|
|
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
|
-
${
|
|
907
|
-
${
|
|
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
|
-
|
|
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">✓</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>
|
|
1095
|
-
<span class="wizard-status wizard-status-warn">Not
|
|
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">
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
<
|
|
1102
|
-
|
|
1103
|
-
|
|
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-
|
|
1107
|
-
<
|
|
1108
|
-
<
|
|
1109
|
-
|
|
1110
|
-
<
|
|
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
|
-
|
|
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>' :
|