ondeckllm 1.1.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/README.md +57 -0
- package/package.json +20 -0
- package/src/cli.js +2 -0
- package/src/public/app.js +593 -0
- package/src/public/index.html +52 -0
- package/src/public/logo.jpg +0 -0
- package/src/public/styles.css +1152 -0
- package/src/server.js +329 -0
- package/src/storage.js +304 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ⚾ OnDeckLLM
|
|
2
|
+
|
|
3
|
+
**Your AI Model Lineup Manager**
|
|
4
|
+
|
|
5
|
+
A localhost dashboard for managing LLM providers, model routing, and batting-order fallback chains. Auto-discovers providers from your [OpenClaw](https://openclaw.ai) config or works standalone.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 🔍 **Auto-Discovery** — Detects Anthropic, OpenAI, Google AI, Ollama, and more from your OpenClaw config
|
|
10
|
+
- ⚾ **Batting Order** — Drag-and-drop model priority per task type (coding, chat, analysis)
|
|
11
|
+
- 🔌 **Provider Hub** — Add, test, and manage API keys for all major LLM providers
|
|
12
|
+
- 🔄 **Config Sync** — Push your model lineup back to OpenClaw with one click
|
|
13
|
+
- 📊 **Health Checks** — Live provider status with latency testing
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g ondeckllm
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Start the dashboard
|
|
25
|
+
ondeckllm
|
|
26
|
+
|
|
27
|
+
# Custom port
|
|
28
|
+
PORT=3901 ondeckllm
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then open [http://localhost:3900](http://localhost:3900)
|
|
32
|
+
|
|
33
|
+
## Providers Supported
|
|
34
|
+
|
|
35
|
+
| Provider | Auto-Discover | API Key | Local |
|
|
36
|
+
|----------|:---:|:---:|:---:|
|
|
37
|
+
| OpenAI | ✅ | ✅ | |
|
|
38
|
+
| Anthropic | ✅ | ✅ | |
|
|
39
|
+
| Google AI | ✅ | ✅ | |
|
|
40
|
+
| Groq | ✅ | ✅ | |
|
|
41
|
+
| Ollama | ✅ | | ✅ |
|
|
42
|
+
| Remote Ollama | ✅ | | ✅ |
|
|
43
|
+
|
|
44
|
+
## Works With
|
|
45
|
+
|
|
46
|
+
- **[OpenClaw](https://openclaw.ai)** — Auto-discovers providers from `~/.openclaw/openclaw.json`
|
|
47
|
+
- **Standalone** — Works without OpenClaw; manage providers manually
|
|
48
|
+
|
|
49
|
+
## Links
|
|
50
|
+
|
|
51
|
+
- 🌐 [ondeckllm.com](https://ondeckllm.com)
|
|
52
|
+
- ☕ [Ko-fi](https://ko-fi.com/ondeckllm)
|
|
53
|
+
- 🐛 [Issues](https://github.com/canonflip/ondeckllm/issues)
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT © [Canonflip](https://canonflip.com)
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ondeckllm",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Localhost dashboard for managing LLM providers, model routing, and batting-order fallback chains",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ondeckllm": "./src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/server.js",
|
|
12
|
+
"dev": "node src/server.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["llm", "ai", "dashboard", "model-routing", "openai", "anthropic", "ollama", "ondeckllm"],
|
|
15
|
+
"author": "Canonflip",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"express": "^4.21.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// ── OnDeckLLM Frontend ──
|
|
2
|
+
|
|
3
|
+
const $ = (sel, ctx = document) => ctx.querySelector(sel);
|
|
4
|
+
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
|
|
5
|
+
|
|
6
|
+
let providerMeta = {};
|
|
7
|
+
let providerData = {};
|
|
8
|
+
let taskRoutes = {};
|
|
9
|
+
let profiles = {};
|
|
10
|
+
let activeProfile = null;
|
|
11
|
+
let discoveredProviders = [];
|
|
12
|
+
|
|
13
|
+
// ── Init ──
|
|
14
|
+
|
|
15
|
+
async function init() {
|
|
16
|
+
setupNavigation();
|
|
17
|
+
await Promise.all([
|
|
18
|
+
loadProviders(),
|
|
19
|
+
loadRoutes(),
|
|
20
|
+
loadProfiles(),
|
|
21
|
+
loadDiscovery()
|
|
22
|
+
]);
|
|
23
|
+
renderWelcomeBanner();
|
|
24
|
+
renderProviders();
|
|
25
|
+
renderRouter();
|
|
26
|
+
renderProfiles();
|
|
27
|
+
renderSupport();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Navigation ──
|
|
31
|
+
|
|
32
|
+
function setupNavigation() {
|
|
33
|
+
$$('.nav-item').forEach(item => {
|
|
34
|
+
item.addEventListener('click', (e) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
const page = item.dataset.page;
|
|
37
|
+
$$('.nav-item').forEach(n => n.classList.remove('active'));
|
|
38
|
+
item.classList.add('active');
|
|
39
|
+
$$('.page').forEach(p => p.classList.remove('active'));
|
|
40
|
+
$(`#page-${page}`).classList.add('active');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── API Helpers ──
|
|
46
|
+
|
|
47
|
+
async function api(path, opts = {}) {
|
|
48
|
+
const res = await fetch(`/api${path}`, {
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
...opts,
|
|
51
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined
|
|
52
|
+
});
|
|
53
|
+
return res.json();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Toast ──
|
|
57
|
+
|
|
58
|
+
function toast(message, type = 'info') {
|
|
59
|
+
const container = $('#toast-container');
|
|
60
|
+
const el = document.createElement('div');
|
|
61
|
+
el.className = `toast ${type}`;
|
|
62
|
+
el.textContent = message;
|
|
63
|
+
container.appendChild(el);
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
el.style.animation = 'toastOut 300ms ease forwards';
|
|
66
|
+
setTimeout(() => el.remove(), 300);
|
|
67
|
+
}, 3000);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Discovery ──
|
|
71
|
+
|
|
72
|
+
async function loadDiscovery() {
|
|
73
|
+
const data = await api('/discovery');
|
|
74
|
+
discoveredProviders = data.discovered || [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderWelcomeBanner() {
|
|
78
|
+
const banner = $('#welcome-banner');
|
|
79
|
+
// Count configured providers
|
|
80
|
+
const configured = Object.values(providerData).filter(p => p.status === 'active' || p.status === 'configured');
|
|
81
|
+
|
|
82
|
+
if (configured.length === 0) {
|
|
83
|
+
banner.innerHTML = '';
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
banner.innerHTML = `
|
|
88
|
+
<div class="welcome-banner">
|
|
89
|
+
<div class="banner-icon">\u2714\uFE0F</div>
|
|
90
|
+
<div class="banner-text">
|
|
91
|
+
<h3>Found ${configured.length} provider${configured.length !== 1 ? 's' : ''} configured</h3>
|
|
92
|
+
<p>${discoveredProviders.length > 0 ? `Auto-discovered: ${discoveredProviders.join(', ')}` : 'Ready to route requests'}</p>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="banner-providers">
|
|
95
|
+
${configured.map(p => `
|
|
96
|
+
<span class="provider-chip">
|
|
97
|
+
<span class="chip-dot" style="background:${p.color}"></span>
|
|
98
|
+
${p.name}
|
|
99
|
+
</span>
|
|
100
|
+
`).join('')}
|
|
101
|
+
</div>
|
|
102
|
+
<button class="banner-dismiss" onclick="this.closest('.welcome-banner').remove()">×</button>
|
|
103
|
+
</div>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ══════════════════════════════════════
|
|
108
|
+
// ── Provider Hub
|
|
109
|
+
// ══════════════════════════════════════
|
|
110
|
+
|
|
111
|
+
async function loadProviders() {
|
|
112
|
+
[providerMeta, providerData] = await Promise.all([
|
|
113
|
+
api('/providers/meta'),
|
|
114
|
+
api('/providers')
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderProviders() {
|
|
119
|
+
const page = $('#page-providers');
|
|
120
|
+
page.innerHTML = `
|
|
121
|
+
<div class="page-header">
|
|
122
|
+
<h1>Provider Hub</h1>
|
|
123
|
+
<p>Manage API keys and connections for your LLM providers</p>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="card-grid" id="provider-grid"></div>
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const grid = $('#provider-grid');
|
|
129
|
+
for (const [id, info] of Object.entries(providerData)) {
|
|
130
|
+
grid.appendChild(createProviderCard(id, info));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createProviderCard(id, info) {
|
|
135
|
+
const card = document.createElement('div');
|
|
136
|
+
card.className = 'card';
|
|
137
|
+
card.id = `provider-${id}`;
|
|
138
|
+
|
|
139
|
+
const statusClass = info.status || 'unconfigured';
|
|
140
|
+
const statusLabel = {
|
|
141
|
+
active: 'Active', configured: 'Configured', error: 'Error', unconfigured: 'Not Set Up'
|
|
142
|
+
}[statusClass] || statusClass;
|
|
143
|
+
|
|
144
|
+
const discoveredBadge = info.autoDiscovered ? '<span class="auto-discovered-badge">AUTO</span>' : '';
|
|
145
|
+
|
|
146
|
+
card.innerHTML = `
|
|
147
|
+
<div class="card-header">
|
|
148
|
+
<div class="card-title">
|
|
149
|
+
<div class="provider-dot" style="background:${info.color};box-shadow:0 0 6px ${info.color}44"></div>
|
|
150
|
+
<h3>${info.name}</h3>
|
|
151
|
+
${info.local ? '<span class="model-tag">LOCAL</span>' : ''}
|
|
152
|
+
${discoveredBadge}
|
|
153
|
+
</div>
|
|
154
|
+
<span class="status-badge ${statusClass}">${statusClass === 'active' ? '\u2714 ' : ''}${statusLabel}</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="provider-config">
|
|
157
|
+
${info.local ? renderLocalProviderForm(id, info) : renderCloudProviderForm(id, info)}
|
|
158
|
+
</div>
|
|
159
|
+
<div class="model-tags">
|
|
160
|
+
${info.models.map(m => `<span class="model-tag">${m}</span>`).join('')}
|
|
161
|
+
</div>
|
|
162
|
+
`;
|
|
163
|
+
return card;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderCloudProviderForm(id, info) {
|
|
167
|
+
return `
|
|
168
|
+
<div class="input-group">
|
|
169
|
+
<label>API Key</label>
|
|
170
|
+
<div class="input-wrapper">
|
|
171
|
+
<input type="password" id="key-${id}" placeholder="sk-..." value="${info.apiKey || ''}" />
|
|
172
|
+
<button class="toggle-vis" onclick="toggleKeyVis('${id}')">◉</button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="btn-group">
|
|
176
|
+
<button class="btn btn-primary btn-sm" onclick="saveProvider('${id}')">Save Key</button>
|
|
177
|
+
<button class="btn btn-success btn-sm" onclick="testProvider('${id}')">Test</button>
|
|
178
|
+
${info.configured ? `<button class="btn btn-danger btn-sm" onclick="removeProvider('${id}')">Remove</button>` : ''}
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderLocalProviderForm(id, info) {
|
|
184
|
+
return `
|
|
185
|
+
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">
|
|
186
|
+
Local provider \u2014 no API key needed. Make sure the service is running.
|
|
187
|
+
</p>
|
|
188
|
+
<div class="btn-group">
|
|
189
|
+
<button class="btn btn-success btn-sm" onclick="testProvider('${id}')">Test Connection</button>
|
|
190
|
+
</div>
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
window.toggleKeyVis = function(id) {
|
|
195
|
+
const input = $(`#key-${id}`);
|
|
196
|
+
input.type = input.type === 'password' ? 'text' : 'password';
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
window.saveProvider = async function(id) {
|
|
200
|
+
const input = $(`#key-${id}`);
|
|
201
|
+
const key = input.value.trim();
|
|
202
|
+
if (!key) return toast('Enter an API key', 'error');
|
|
203
|
+
await api(`/providers/${id}`, { method: 'POST', body: { apiKey: key } });
|
|
204
|
+
toast(`${providerData[id].name} key saved`, 'success');
|
|
205
|
+
await loadProviders();
|
|
206
|
+
renderProviders();
|
|
207
|
+
renderWelcomeBanner();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
window.testProvider = async function(id) {
|
|
211
|
+
const card = $(`#provider-${id}`);
|
|
212
|
+
const btn = card.querySelector('.btn-success');
|
|
213
|
+
const origText = btn.textContent;
|
|
214
|
+
btn.innerHTML = '<span class="spinner"></span> Testing...';
|
|
215
|
+
btn.disabled = true;
|
|
216
|
+
|
|
217
|
+
const result = await api(`/providers/${id}/test`, { method: 'POST' });
|
|
218
|
+
|
|
219
|
+
btn.textContent = origText;
|
|
220
|
+
btn.disabled = false;
|
|
221
|
+
|
|
222
|
+
if (result.ok) {
|
|
223
|
+
toast(`${providerData[id].name}: Connection successful!`, 'success');
|
|
224
|
+
} else {
|
|
225
|
+
toast(`${providerData[id].name}: ${result.message}`, 'error');
|
|
226
|
+
}
|
|
227
|
+
await loadProviders();
|
|
228
|
+
renderProviders();
|
|
229
|
+
renderWelcomeBanner();
|
|
230
|
+
// Refresh router to pick up any new models
|
|
231
|
+
renderRouter();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
window.removeProvider = async function(id) {
|
|
235
|
+
if (!confirm(`Remove ${providerData[id].name} API key?`)) return;
|
|
236
|
+
await api(`/providers/${id}`, { method: 'DELETE' });
|
|
237
|
+
toast(`${providerData[id].name} removed`, 'info');
|
|
238
|
+
await loadProviders();
|
|
239
|
+
renderProviders();
|
|
240
|
+
renderWelcomeBanner();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ══════════════════════════════════════
|
|
244
|
+
// ── Batting Order (Task Router)
|
|
245
|
+
// ══════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
const TASK_TYPES = {
|
|
248
|
+
chat: { icon: '\u{1F4AC}', label: 'Chat / General', desc: 'Conversational AI, Q&A, summarization' },
|
|
249
|
+
coding: { icon: '\u{1F4BB}', label: 'Coding', desc: 'Code generation, review, debugging' },
|
|
250
|
+
images: { icon: '\u{1F5BC}\uFE0F', label: 'Images', desc: 'Image generation and editing' },
|
|
251
|
+
video: { icon: '\u{1F3A5}', label: 'Video', desc: 'Video generation and processing' },
|
|
252
|
+
research: { icon: '\u{1F50D}', label: 'Research / RAG', desc: 'Deep research, retrieval, analysis' },
|
|
253
|
+
data: { icon: '\u{1F4CA}', label: 'Data / Analysis', desc: 'Data processing, charts, analytics' }
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
async function loadRoutes() {
|
|
257
|
+
taskRoutes = await api('/routes');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderRouter() {
|
|
261
|
+
const page = $('#page-router');
|
|
262
|
+
page.innerHTML = `
|
|
263
|
+
<div class="page-header">
|
|
264
|
+
<h1>Batting Order</h1>
|
|
265
|
+
<p>Set your model lineup per task type. #1 is your primary \u2014 the rest are fallbacks in order.
|
|
266
|
+
<span class="sync-badge">\u21C4 Syncs to OpenClaw</span>
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
<div id="task-sections"></div>
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
const container = $('#task-sections');
|
|
273
|
+
for (const [taskId, meta] of Object.entries(TASK_TYPES)) {
|
|
274
|
+
container.appendChild(createTaskSection(taskId, meta));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function createTaskSection(taskId, meta) {
|
|
279
|
+
const section = document.createElement('div');
|
|
280
|
+
section.className = 'task-section';
|
|
281
|
+
|
|
282
|
+
const routes = taskRoutes[taskId] || [];
|
|
283
|
+
|
|
284
|
+
section.innerHTML = `
|
|
285
|
+
<div class="task-header">
|
|
286
|
+
<span class="task-icon">${meta.icon}</span>
|
|
287
|
+
<h3>${meta.label}</h3>
|
|
288
|
+
<span class="task-desc">${meta.desc}</span>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="lineup-card">
|
|
291
|
+
<div class="lineup-card-header">
|
|
292
|
+
<span class="lineup-title">LINEUP</span>
|
|
293
|
+
<span>${routes.length} model${routes.length !== 1 ? 's' : ''}</span>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="lineup-list" data-task="${taskId}">
|
|
296
|
+
${routes.length === 0 ? '<div class="empty-state">No models in the lineup \u2014 add one to get started</div>' : ''}
|
|
297
|
+
${routes.map((r, i) => renderLineupEntry(taskId, r, i, routes.length)).join('')}
|
|
298
|
+
</div>
|
|
299
|
+
<div class="lineup-add-btn">
|
|
300
|
+
<button class="btn" onclick="openAddModelModal('${taskId}')">+ Add Model to Lineup</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
`;
|
|
304
|
+
|
|
305
|
+
setupDragDrop(section.querySelector('.lineup-list'), taskId);
|
|
306
|
+
|
|
307
|
+
return section;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function getRankLabel(index) {
|
|
311
|
+
if (index === 0) return 'Primary';
|
|
312
|
+
return `Fallback #${index}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getRankClass(index) {
|
|
316
|
+
if (index === 0) return 'rank-1';
|
|
317
|
+
if (index === 1) return 'rank-2';
|
|
318
|
+
if (index === 2) return 'rank-3';
|
|
319
|
+
return 'rank-n';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderLineupEntry(taskId, route, index, total) {
|
|
323
|
+
const provColor = providerData[route.provider]?.color || '#888';
|
|
324
|
+
const provName = providerData[route.provider]?.name || route.provider;
|
|
325
|
+
const rankClass = getRankClass(index);
|
|
326
|
+
const roleClass = index === 0 ? 'role-primary' : 'role-fallback';
|
|
327
|
+
const roleLabel = index === 0 ? 'PRIMARY' : `FB #${index}`;
|
|
328
|
+
|
|
329
|
+
return `
|
|
330
|
+
<div class="lineup-entry" draggable="true" data-index="${index}">
|
|
331
|
+
<span class="drag-handle">\u2982</span>
|
|
332
|
+
<span class="rank-badge ${rankClass}">${index + 1}</span>
|
|
333
|
+
<span class="lineup-provider">
|
|
334
|
+
<span class="prov-dot" style="background:${provColor};box-shadow:0 0 4px ${provColor}66"></span>
|
|
335
|
+
<span class="prov-name">${provName}</span>
|
|
336
|
+
</span>
|
|
337
|
+
<span class="lineup-model">${route.model}</span>
|
|
338
|
+
<span class="lineup-role ${roleClass}">${roleLabel}</span>
|
|
339
|
+
<button class="lineup-remove" onclick="removeRoute('${taskId}', ${index})">×</button>
|
|
340
|
+
</div>
|
|
341
|
+
`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function setupDragDrop(list, taskId) {
|
|
345
|
+
let dragIndex = null;
|
|
346
|
+
|
|
347
|
+
list.addEventListener('dragstart', (e) => {
|
|
348
|
+
const item = e.target.closest('.lineup-entry');
|
|
349
|
+
if (!item) return;
|
|
350
|
+
dragIndex = parseInt(item.dataset.index);
|
|
351
|
+
item.classList.add('dragging');
|
|
352
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
list.addEventListener('dragend', (e) => {
|
|
356
|
+
const item = e.target.closest('.lineup-entry');
|
|
357
|
+
if (item) item.classList.remove('dragging');
|
|
358
|
+
list.classList.remove('drag-over');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
list.addEventListener('dragover', (e) => {
|
|
362
|
+
e.preventDefault();
|
|
363
|
+
list.classList.add('drag-over');
|
|
364
|
+
e.dataTransfer.dropEffect = 'move';
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
list.addEventListener('dragleave', () => {
|
|
368
|
+
list.classList.remove('drag-over');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
list.addEventListener('drop', async (e) => {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
list.classList.remove('drag-over');
|
|
374
|
+
|
|
375
|
+
const items = $$('.lineup-entry', list);
|
|
376
|
+
let dropIndex = items.length - 1;
|
|
377
|
+
for (let i = 0; i < items.length; i++) {
|
|
378
|
+
const rect = items[i].getBoundingClientRect();
|
|
379
|
+
if (e.clientY < rect.top + rect.height / 2) {
|
|
380
|
+
dropIndex = i;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (dragIndex !== null && dragIndex !== dropIndex) {
|
|
386
|
+
const routes = taskRoutes[taskId] || [];
|
|
387
|
+
const [moved] = routes.splice(dragIndex, 1);
|
|
388
|
+
routes.splice(dropIndex, 0, moved);
|
|
389
|
+
taskRoutes[taskId] = routes;
|
|
390
|
+
await api('/routes', { method: 'PUT', body: taskRoutes });
|
|
391
|
+
renderRouter();
|
|
392
|
+
toast('Lineup reordered', 'success');
|
|
393
|
+
}
|
|
394
|
+
dragIndex = null;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Add Model Modal ──
|
|
399
|
+
|
|
400
|
+
window.openAddModelModal = function(taskId) {
|
|
401
|
+
const existing = (taskRoutes[taskId] || []).map(r => `${r.provider}:${r.model}`);
|
|
402
|
+
const configured = Object.entries(providerData).filter(([, p]) => p.configured);
|
|
403
|
+
|
|
404
|
+
const modalRoot = $('#modal-root');
|
|
405
|
+
modalRoot.innerHTML = `
|
|
406
|
+
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
|
407
|
+
<div class="modal">
|
|
408
|
+
<div class="modal-header">
|
|
409
|
+
<h2>Add Model to ${TASK_TYPES[taskId].label} Lineup</h2>
|
|
410
|
+
<p>Select from your configured providers</p>
|
|
411
|
+
</div>
|
|
412
|
+
<div class="modal-body">
|
|
413
|
+
${configured.length === 0 ? '<div class="empty-state">No providers configured. Go to Provider Hub first.</div>' : ''}
|
|
414
|
+
${configured.map(([pid, prov]) => `
|
|
415
|
+
<div class="modal-provider-section">
|
|
416
|
+
<div class="modal-provider-header">
|
|
417
|
+
<span class="prov-dot" style="background:${prov.color}"></span>
|
|
418
|
+
${prov.name}
|
|
419
|
+
</div>
|
|
420
|
+
<div class="modal-model-list">
|
|
421
|
+
${prov.models.map(m => {
|
|
422
|
+
const key = `${pid}:${m}`;
|
|
423
|
+
const added = existing.includes(key);
|
|
424
|
+
return `
|
|
425
|
+
<div class="modal-model-item${added ? ' already-added' : ''}" onclick="${added ? '' : `selectModelForLineup('${taskId}','${pid}','${m}')`}">
|
|
426
|
+
${added ? '<span class="model-check">\u2714</span>' : '<span style="width:14px"></span>'}
|
|
427
|
+
${m}
|
|
428
|
+
</div>
|
|
429
|
+
`;
|
|
430
|
+
}).join('')}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
`).join('')}
|
|
434
|
+
</div>
|
|
435
|
+
<div class="modal-footer">
|
|
436
|
+
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
`;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
window.selectModelForLineup = async function(taskId, provider, model) {
|
|
444
|
+
if (!taskRoutes[taskId]) taskRoutes[taskId] = [];
|
|
445
|
+
taskRoutes[taskId].push({ provider, model });
|
|
446
|
+
await api('/routes', { method: 'PUT', body: taskRoutes });
|
|
447
|
+
closeModal();
|
|
448
|
+
renderRouter();
|
|
449
|
+
toast(`Added ${model} to ${TASK_TYPES[taskId].label} lineup`, 'success');
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
window.closeModal = function() {
|
|
453
|
+
$('#modal-root').innerHTML = '';
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
window.removeRoute = async function(taskId, index) {
|
|
457
|
+
const route = taskRoutes[taskId][index];
|
|
458
|
+
taskRoutes[taskId].splice(index, 1);
|
|
459
|
+
await api('/routes', { method: 'PUT', body: taskRoutes });
|
|
460
|
+
renderRouter();
|
|
461
|
+
toast(`Removed ${route.model} from lineup`, 'info');
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// ══════════════════════════════════════
|
|
465
|
+
// ── Profiles
|
|
466
|
+
// ══════════════════════════════════════
|
|
467
|
+
|
|
468
|
+
async function loadProfiles() {
|
|
469
|
+
const data = await api('/profiles');
|
|
470
|
+
profiles = data.profiles;
|
|
471
|
+
activeProfile = data.active;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderProfiles() {
|
|
475
|
+
const page = $('#page-profiles');
|
|
476
|
+
page.innerHTML = `
|
|
477
|
+
<div class="page-header">
|
|
478
|
+
<h1>Profiles</h1>
|
|
479
|
+
<p>One-click routing presets. Activating a profile reconfigures all batting orders.</p>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="profile-grid" id="profile-grid"></div>
|
|
482
|
+
`;
|
|
483
|
+
|
|
484
|
+
const grid = $('#profile-grid');
|
|
485
|
+
for (const [id, profile] of Object.entries(profiles)) {
|
|
486
|
+
grid.appendChild(createProfileCard(id, profile));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function createProfileCard(id, profile) {
|
|
491
|
+
const card = document.createElement('div');
|
|
492
|
+
card.className = `profile-card${activeProfile === id ? ' active' : ''}`;
|
|
493
|
+
card.onclick = () => activateProfile(id);
|
|
494
|
+
|
|
495
|
+
const models = new Set();
|
|
496
|
+
for (const routes of Object.values(profile.taskRoutes || {})) {
|
|
497
|
+
for (const r of routes) {
|
|
498
|
+
models.add(r.model);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
card.innerHTML = `
|
|
503
|
+
<div class="profile-icon">${profile.icon}</div>
|
|
504
|
+
<h3>${profile.name}</h3>
|
|
505
|
+
<p>${profile.description}</p>
|
|
506
|
+
<div class="profile-models">
|
|
507
|
+
${[...models].slice(0, 6).map(m => `<span class="model-tag">${m}</span>`).join('')}
|
|
508
|
+
${models.size > 6 ? `<span class="model-tag">+${models.size - 6} more</span>` : ''}
|
|
509
|
+
</div>
|
|
510
|
+
`;
|
|
511
|
+
return card;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function activateProfile(id) {
|
|
515
|
+
const result = await api('/profiles/activate', { method: 'POST', body: { profileId: id } });
|
|
516
|
+
if (result.ok) {
|
|
517
|
+
activeProfile = id;
|
|
518
|
+
taskRoutes = result.routes;
|
|
519
|
+
renderProfiles();
|
|
520
|
+
renderRouter();
|
|
521
|
+
toast(`${profiles[id].name} profile activated \u2014 synced to OpenClaw`, 'success');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ══════════════════════════════════════
|
|
526
|
+
// ── Support Page
|
|
527
|
+
// ══════════════════════════════════════
|
|
528
|
+
|
|
529
|
+
function renderSupport() {
|
|
530
|
+
const page = $('#page-support');
|
|
531
|
+
page.innerHTML = `
|
|
532
|
+
<div class="page-header">
|
|
533
|
+
<h1>Support</h1>
|
|
534
|
+
<p>Get help, contribute, and connect with providers</p>
|
|
535
|
+
</div>
|
|
536
|
+
<div class="support-links">
|
|
537
|
+
<a href="https://github.com/canonflip/ondeckllm" target="_blank" class="support-link-card">
|
|
538
|
+
<span class="support-link-icon">★</span>
|
|
539
|
+
<div>
|
|
540
|
+
<h3>Star on GitHub</h3>
|
|
541
|
+
<p>Show your support — star the repo</p>
|
|
542
|
+
</div>
|
|
543
|
+
</a>
|
|
544
|
+
<a href="https://github.com/canonflip/ondeckllm/issues" target="_blank" class="support-link-card">
|
|
545
|
+
<span class="support-link-icon">🐛</span>
|
|
546
|
+
<div>
|
|
547
|
+
<h3>Report Bugs</h3>
|
|
548
|
+
<p>Found an issue? Let us know</p>
|
|
549
|
+
</div>
|
|
550
|
+
</a>
|
|
551
|
+
<a href="https://buymeacoffee.com/canonflip" target="_blank" class="support-link-card">
|
|
552
|
+
<span class="support-link-icon">☕</span>
|
|
553
|
+
<div>
|
|
554
|
+
<h3>Buy Us a Coffee</h3>
|
|
555
|
+
<p>Help fund development</p>
|
|
556
|
+
</div>
|
|
557
|
+
</a>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="provider-getstarted-section">
|
|
560
|
+
<h2>Get Started with a Provider</h2>
|
|
561
|
+
<p>Don't have an API key yet? Sign up with one of these providers.</p>
|
|
562
|
+
<div class="provider-getstarted-grid">
|
|
563
|
+
${renderProviderSignupCard('OpenAI', '#10a37f', 'https://platform.openai.com', 'GPT-4o, o3, DALL-E')}
|
|
564
|
+
${renderProviderSignupCard('Anthropic', '#d4a574', 'https://console.anthropic.com', 'Claude Opus, Sonnet, Haiku')}
|
|
565
|
+
${renderProviderSignupCard('Google AI', '#4285f4', 'https://aistudio.google.com', 'Gemini 2.0 Pro & Flash')}
|
|
566
|
+
${renderProviderSignupCard('Groq', '#f55036', 'https://groq.com', 'Ultra-fast inference')}
|
|
567
|
+
${renderProviderSignupCard('Mistral', '#ff7000', 'https://mistral.ai', 'Mistral Large, Codestral')}
|
|
568
|
+
${renderProviderSignupCard('DeepSeek', '#5b6ee1', 'https://deepseek.com', 'DeepSeek Chat & Coder')}
|
|
569
|
+
${renderProviderSignupCard('Together', '#6e56cf', 'https://together.ai', 'Open-source model hosting')}
|
|
570
|
+
${renderProviderSignupCard('Ollama', '#ffffff', 'https://ollama.com', 'Run models locally — free')}
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
<div class="support-footer">
|
|
574
|
+
Built by <a href="https://canonflip.com" target="_blank">Canonflip</a>
|
|
575
|
+
</div>
|
|
576
|
+
`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function renderProviderSignupCard(name, color, url, desc) {
|
|
580
|
+
return `
|
|
581
|
+
<a href="${url}" target="_blank" class="provider-signup-card">
|
|
582
|
+
<span class="prov-dot" style="background:${color};box-shadow:0 0 6px ${color}66"></span>
|
|
583
|
+
<div>
|
|
584
|
+
<h4>${name}</h4>
|
|
585
|
+
<p>${desc}</p>
|
|
586
|
+
</div>
|
|
587
|
+
<span class="signup-arrow">→</span>
|
|
588
|
+
</a>
|
|
589
|
+
`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Boot ──
|
|
593
|
+
init();
|