@jacobbd/relay-ai 0.4.8 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/{chunk-I3SSHXSP.js → chunk-XTBJCRT3.js} +253 -16
- package/dist/chunk-XTBJCRT3.js.map +1 -0
- package/dist/cli.js +5 -5
- package/dist/cli.js.map +1 -1
- package/dist/ui/public/app.js +182 -200
- package/dist/ui/public/oauth-device.js +30 -0
- package/dist/ui/public/provider-logo.js +59 -0
- package/dist/ui/public/style.css +123 -0
- package/dist/{ui-command-P27KHEJG.js → ui-command-GATWK5JC.js} +18 -7
- package/dist/ui-command-GATWK5JC.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-I3SSHXSP.js.map +0 -1
- package/dist/ui-command-P27KHEJG.js.map +0 -1
package/dist/ui/public/app.js
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
getProviderModelPage,
|
|
4
4
|
PROVIDER_MODEL_PAGE_SIZE,
|
|
5
5
|
} from './provider-model-browser.js';
|
|
6
|
+
import { copyDeviceCode, oauthConnectionLabel } from './oauth-device.js';
|
|
7
|
+
import { providerInitial, providerLogoHtml } from './provider-logo.js';
|
|
6
8
|
|
|
7
9
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
8
10
|
|
|
@@ -56,53 +58,6 @@ const state = {
|
|
|
56
58
|
},
|
|
57
59
|
};
|
|
58
60
|
|
|
59
|
-
// ─── Provider logos via Simple Icons CDN ─────────────────────────────────────
|
|
60
|
-
// https://simpleicons.org — monochrome SVG brand marks, white on dark bg.
|
|
61
|
-
// Format: https://cdn.simpleicons.org/{slug}/ffffff
|
|
62
|
-
|
|
63
|
-
// Inline SVGs for providers whose official domains block hotlinking.
|
|
64
|
-
// White fill, renders on gradient background.
|
|
65
|
-
const PROVIDER_INLINE_SVGS = {
|
|
66
|
-
openai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.896zm16.597 3.855l-5.843-3.368L15.115 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.403-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>`,
|
|
67
|
-
kilo: `<svg viewBox="0 0 24 24" fill="white" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" style="width:22px;height:22px"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"/></svg>`,
|
|
68
|
-
xai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
|
|
69
|
-
'xai-oauth': `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// Verified working logo URLs. Slugs tested against cdn.simpleicons.org — 404s removed.
|
|
73
|
-
// Missing: openai, groq, cohere, togetherai, fireworks, cerebras → inline SVG or gradient letter fallback.
|
|
74
|
-
const PROVIDER_LOGO_URLS = {
|
|
75
|
-
// OpenCode — official favicon (proper square icon)
|
|
76
|
-
zen: 'https://opencode.ai/favicon.ico',
|
|
77
|
-
go: 'https://opencode.ai/favicon.ico',
|
|
78
|
-
// Verified Simple Icons slugs (200 responses confirmed)
|
|
79
|
-
anthropic: 'https://cdn.simpleicons.org/anthropic/ffffff',
|
|
80
|
-
google: 'https://cdn.simpleicons.org/google/ffffff',
|
|
81
|
-
nvidia: 'https://cdn.simpleicons.org/nvidia/ffffff',
|
|
82
|
-
deepseek: 'https://cdn.simpleicons.org/deepseek/ffffff',
|
|
83
|
-
mistral: 'https://cdn.simpleicons.org/mistralai/ffffff',
|
|
84
|
-
ollama: 'https://cdn.simpleicons.org/ollama/ffffff',
|
|
85
|
-
openrouter: 'https://cdn.simpleicons.org/openrouter/ffffff',
|
|
86
|
-
perplexity: 'https://cdn.simpleicons.org/perplexity/ffffff',
|
|
87
|
-
huggingface: 'https://cdn.simpleicons.org/huggingface/ffffff',
|
|
88
|
-
cloudflare: 'https://cdn.simpleicons.org/cloudflare/ffffff',
|
|
89
|
-
azure: 'https://cdn.simpleicons.org/microsoftazure/ffffff',
|
|
90
|
-
vertex: 'https://cdn.simpleicons.org/googlecloud/ffffff',
|
|
91
|
-
bedrock: 'https://cdn.simpleicons.org/amazonaws/ffffff',
|
|
92
|
-
// xai / xai-oauth → inline SVG in PROVIDER_INLINE_SVGS
|
|
93
|
-
// openai, groq, cohere, togetherai, fireworks, cerebras → no verified slug, use gradient letter
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
function getProviderLogoContent(providerId) {
|
|
97
|
-
const base = providerId.replace(/-oauth$/, '').replace(/-api$/, '');
|
|
98
|
-
// Inline SVG takes priority (for providers that block hotlinking)
|
|
99
|
-
const svg = PROVIDER_INLINE_SVGS[providerId] ?? PROVIDER_INLINE_SVGS[base];
|
|
100
|
-
if (svg) return { type: 'svg', content: svg };
|
|
101
|
-
const url = PROVIDER_LOGO_URLS[providerId] ?? PROVIDER_LOGO_URLS[base];
|
|
102
|
-
if (url) return { type: 'img', content: url };
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
61
|
// ─── Neon model colors (unique per model ID) ──────────────────────────────────
|
|
107
62
|
|
|
108
63
|
const NEONS = [
|
|
@@ -213,10 +168,6 @@ function providerPalette(id) {
|
|
|
213
168
|
return PALETTES[Math.abs(hash) % PALETTES.length];
|
|
214
169
|
}
|
|
215
170
|
|
|
216
|
-
function providerInitial(name) {
|
|
217
|
-
return (name || '?').replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || '?';
|
|
218
|
-
}
|
|
219
|
-
|
|
220
171
|
// ─── API ─────────────────────────────────────────────────────────────────────
|
|
221
172
|
|
|
222
173
|
async function api(method, path, body) {
|
|
@@ -437,8 +388,7 @@ function renderProviders() {
|
|
|
437
388
|
function buildProviderCard(provider) {
|
|
438
389
|
const [c1, c2] = providerPalette(provider.id);
|
|
439
390
|
const displayName = getProviderName(provider.id);
|
|
440
|
-
const
|
|
441
|
-
const logo = getProviderLogoContent(provider.id);
|
|
391
|
+
const connectionLabel = oauthConnectionLabel(provider);
|
|
442
392
|
const card = document.createElement('div');
|
|
443
393
|
card.className = 'provider-card';
|
|
444
394
|
card.dataset.id = provider.id;
|
|
@@ -449,21 +399,15 @@ function buildProviderCard(provider) {
|
|
|
449
399
|
header.setAttribute('tabindex', '0');
|
|
450
400
|
header.setAttribute('aria-label', `View models from ${displayName}`);
|
|
451
401
|
|
|
452
|
-
const logoHtml = logo?.type === 'svg'
|
|
453
|
-
? logo.content
|
|
454
|
-
: logo?.type === 'img'
|
|
455
|
-
? `<img src="${logo.content}" class="provider-logo-img" alt="${escapeHtml(displayName)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`
|
|
456
|
-
: initial;
|
|
457
|
-
|
|
458
402
|
header.innerHTML = `
|
|
459
|
-
<div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${
|
|
403
|
+
<div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerLogoHtml(provider.id, displayName)}</div>
|
|
460
404
|
<div class="provider-info">
|
|
461
405
|
<div class="provider-name">${escapeHtml(displayName)}</div>
|
|
462
406
|
<div class="provider-models-count">${provider.modelCount ?? provider.models?.length ?? 0} models</div>
|
|
463
407
|
</div>
|
|
464
408
|
<div class="provider-status">
|
|
465
409
|
<span class="status-chip ${provider.hasKey ? 'has-key' : provider.freeAccess ? 'free-access' : 'no-key'}">
|
|
466
|
-
${
|
|
410
|
+
${escapeHtml(connectionLabel)}
|
|
467
411
|
</span>
|
|
468
412
|
<button class="provider-config-toggle" type="button" aria-label="Manage ${escapeHtml(displayName)} provider" aria-expanded="false" title="Manage provider">
|
|
469
413
|
<svg class="provider-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
|
@@ -554,7 +498,7 @@ function renderProviderModelBrowser() {
|
|
|
554
498
|
container.innerHTML = `
|
|
555
499
|
<a class="provider-browser-back" href="#providers">← Providers & Keys</a>
|
|
556
500
|
<div class="provider-browser-hero">
|
|
557
|
-
<div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${
|
|
501
|
+
<div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerLogoHtml(provider.id, displayName)}</div>
|
|
558
502
|
<div>
|
|
559
503
|
<div class="section-eyebrow">Provider catalog</div>
|
|
560
504
|
<h1 class="section-heading">${escapeHtml(displayName)} <span class="heading-accent">models</span></h1>
|
|
@@ -629,13 +573,6 @@ function renderProviderModelBrowser() {
|
|
|
629
573
|
|
|
630
574
|
function buildTemplateCard(template) {
|
|
631
575
|
const [c1, c2] = providerPalette(template.id);
|
|
632
|
-
const initial = providerInitial(template.name);
|
|
633
|
-
const logo = getProviderLogoContent(template.id);
|
|
634
|
-
const logoHtml = logo?.type === 'svg'
|
|
635
|
-
? logo.content
|
|
636
|
-
: logo?.type === 'img'
|
|
637
|
-
? `<img src="${logo.content}" class="provider-logo-img" alt="${template.name}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`
|
|
638
|
-
: initial;
|
|
639
576
|
const card = document.createElement('div');
|
|
640
577
|
card.className = 'provider-card';
|
|
641
578
|
card.dataset.id = template.id;
|
|
@@ -651,7 +588,7 @@ function buildTemplateCard(template) {
|
|
|
651
588
|
: '';
|
|
652
589
|
|
|
653
590
|
header.innerHTML = `
|
|
654
|
-
<div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${
|
|
591
|
+
<div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerLogoHtml(template.id, template.name)}</div>
|
|
655
592
|
<div class="provider-info">
|
|
656
593
|
<div class="provider-name">${template.name}</div>
|
|
657
594
|
<div class="provider-models-count">Not configured ${signupLink}</div>
|
|
@@ -797,88 +734,180 @@ function buildCustomEndpointBodyContent(template, card) {
|
|
|
797
734
|
return content;
|
|
798
735
|
}
|
|
799
736
|
|
|
737
|
+
function setOAuthFeedback(feedback, message, tone = '') {
|
|
738
|
+
feedback.replaceChildren();
|
|
739
|
+
feedback.className = `key-feedback${tone ? ` ${tone}` : ''}`;
|
|
740
|
+
feedback.textContent = message;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function createDeviceAuthorizationPanel(url, userCode) {
|
|
744
|
+
const panel = document.createElement('section');
|
|
745
|
+
panel.className = 'oauth-device-panel';
|
|
746
|
+
panel.setAttribute('aria-label', 'Device authorization');
|
|
747
|
+
|
|
748
|
+
const eyebrow = document.createElement('div');
|
|
749
|
+
eyebrow.className = 'oauth-device-eyebrow';
|
|
750
|
+
eyebrow.textContent = 'One-time device code';
|
|
751
|
+
|
|
752
|
+
const codeRow = document.createElement('div');
|
|
753
|
+
codeRow.className = 'oauth-device-code-row';
|
|
754
|
+
|
|
755
|
+
const code = document.createElement('code');
|
|
756
|
+
code.className = 'oauth-device-code';
|
|
757
|
+
code.textContent = userCode;
|
|
758
|
+
code.setAttribute('aria-label', `Device code ${userCode}`);
|
|
759
|
+
|
|
760
|
+
const copyButton = document.createElement('button');
|
|
761
|
+
copyButton.type = 'button';
|
|
762
|
+
copyButton.className = 'btn btn-ghost oauth-device-copy';
|
|
763
|
+
copyButton.textContent = 'Copy code';
|
|
764
|
+
copyButton.addEventListener('click', async () => {
|
|
765
|
+
try {
|
|
766
|
+
await copyDeviceCode(userCode);
|
|
767
|
+
copyButton.textContent = 'Copied ✓';
|
|
768
|
+
setTimeout(() => { copyButton.textContent = 'Copy code'; }, 1800);
|
|
769
|
+
} catch {
|
|
770
|
+
copyButton.textContent = 'Copy failed';
|
|
771
|
+
setTimeout(() => { copyButton.textContent = 'Copy code'; }, 1800);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
codeRow.append(code, copyButton);
|
|
776
|
+
|
|
777
|
+
const instructions = document.createElement('ol');
|
|
778
|
+
instructions.className = 'oauth-device-steps';
|
|
779
|
+
for (const step of [
|
|
780
|
+
'Copy the code above.',
|
|
781
|
+
'Open the secure sign-in page.',
|
|
782
|
+
'Paste the code there and approve access.',
|
|
783
|
+
]) {
|
|
784
|
+
const item = document.createElement('li');
|
|
785
|
+
item.textContent = step;
|
|
786
|
+
instructions.appendChild(item);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const actions = document.createElement('div');
|
|
790
|
+
actions.className = 'oauth-device-actions';
|
|
791
|
+
|
|
792
|
+
const openButton = document.createElement('button');
|
|
793
|
+
openButton.type = 'button';
|
|
794
|
+
openButton.className = 'btn btn-primary';
|
|
795
|
+
openButton.textContent = 'Open sign-in page ↗';
|
|
796
|
+
|
|
797
|
+
const waiting = document.createElement('div');
|
|
798
|
+
waiting.className = 'oauth-device-waiting';
|
|
799
|
+
waiting.setAttribute('role', 'status');
|
|
800
|
+
waiting.textContent = 'Waiting for you to finish sign-in…';
|
|
801
|
+
|
|
802
|
+
openButton.addEventListener('click', () => {
|
|
803
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
804
|
+
waiting.textContent = 'Sign-in page opened. Paste the code, then return here…';
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
actions.append(openButton, waiting);
|
|
808
|
+
panel.append(eyebrow, codeRow, instructions, actions);
|
|
809
|
+
return panel;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function beginDeviceOAuthFlow({ providerId, signInButton, refreshButton, feedback, onDone }) {
|
|
813
|
+
signInButton.disabled = true;
|
|
814
|
+
if (refreshButton) refreshButton.disabled = true;
|
|
815
|
+
setOAuthFeedback(feedback, 'Preparing a secure one-time code…', 'muted');
|
|
816
|
+
|
|
817
|
+
let startResult;
|
|
818
|
+
try {
|
|
819
|
+
startResult = await api('POST', '/api/providers/oauth/start', { providerId });
|
|
820
|
+
} catch {
|
|
821
|
+
setOAuthFeedback(feedback, 'Could not start sign-in. Check the connection and try again.', 'error');
|
|
822
|
+
signInButton.disabled = false;
|
|
823
|
+
if (refreshButton) refreshButton.disabled = false;
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (startResult.error) {
|
|
827
|
+
setOAuthFeedback(feedback, startResult.error, 'error');
|
|
828
|
+
signInButton.disabled = false;
|
|
829
|
+
if (refreshButton) refreshButton.disabled = false;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const { sessionId, url, userCode } = startResult;
|
|
834
|
+
if (!sessionId || !url || !userCode) {
|
|
835
|
+
setOAuthFeedback(feedback, 'The provider did not return a usable device code. Please try again.', 'error');
|
|
836
|
+
signInButton.disabled = false;
|
|
837
|
+
if (refreshButton) refreshButton.disabled = false;
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
feedback.replaceChildren(createDeviceAuthorizationPanel(url, userCode));
|
|
842
|
+
feedback.className = 'key-feedback oauth-device-host';
|
|
843
|
+
|
|
844
|
+
let pollInFlight = false;
|
|
845
|
+
const pollInterval = setInterval(async () => {
|
|
846
|
+
if (pollInFlight) return;
|
|
847
|
+
pollInFlight = true;
|
|
848
|
+
try {
|
|
849
|
+
const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
|
|
850
|
+
if (statusResult.status === 'done') {
|
|
851
|
+
clearInterval(pollInterval);
|
|
852
|
+
setOAuthFeedback(feedback, '✓ Signed in successfully', 'success');
|
|
853
|
+
try {
|
|
854
|
+
await onDone();
|
|
855
|
+
} catch {
|
|
856
|
+
setOAuthFeedback(feedback, 'Signed in, but the provider list could not reload. Refresh the page to continue.', 'error');
|
|
857
|
+
}
|
|
858
|
+
} else if (statusResult.status === 'error' || statusResult.error) {
|
|
859
|
+
clearInterval(pollInterval);
|
|
860
|
+
const message = statusResult.status === 'error'
|
|
861
|
+
? (statusResult.error ?? 'Authorization failed')
|
|
862
|
+
: 'Session expired — please try again';
|
|
863
|
+
setOAuthFeedback(feedback, message, 'error');
|
|
864
|
+
signInButton.disabled = false;
|
|
865
|
+
if (refreshButton) refreshButton.disabled = false;
|
|
866
|
+
}
|
|
867
|
+
} catch {
|
|
868
|
+
clearInterval(pollInterval);
|
|
869
|
+
setOAuthFeedback(feedback, 'Lost contact with the sign-in session. Please try again.', 'error');
|
|
870
|
+
signInButton.disabled = false;
|
|
871
|
+
if (refreshButton) refreshButton.disabled = false;
|
|
872
|
+
} finally {
|
|
873
|
+
pollInFlight = false;
|
|
874
|
+
}
|
|
875
|
+
}, 2000);
|
|
876
|
+
}
|
|
877
|
+
|
|
800
878
|
function buildOAuthTemplateBodyContent(template) {
|
|
801
879
|
const content = document.createElement('div');
|
|
802
880
|
content.className = 'provider-body-content';
|
|
803
881
|
|
|
804
882
|
const note = document.createElement('div');
|
|
805
|
-
note.
|
|
806
|
-
note.textContent = 'Sign in
|
|
883
|
+
note.className = 'oauth-device-note';
|
|
884
|
+
note.textContent = 'Sign in with a one-time device code — no API key required.';
|
|
807
885
|
|
|
808
886
|
const actionRow = document.createElement('div');
|
|
809
887
|
actionRow.className = 'key-row';
|
|
810
888
|
|
|
811
889
|
const signInBtn = document.createElement('button');
|
|
812
890
|
signInBtn.className = 'btn btn-primary';
|
|
813
|
-
signInBtn.textContent = '
|
|
891
|
+
signInBtn.textContent = 'Get sign-in code';
|
|
814
892
|
|
|
815
893
|
const feedback = document.createElement('div');
|
|
816
894
|
feedback.className = 'key-feedback';
|
|
817
895
|
|
|
818
896
|
actionRow.appendChild(signInBtn);
|
|
819
|
-
content.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
if (startResult.error) {
|
|
834
|
-
feedback.textContent = startResult.error;
|
|
835
|
-
feedback.className = 'key-feedback error';
|
|
836
|
-
signInBtn.disabled = false;
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
const { sessionId } = startResult;
|
|
841
|
-
if (startResult.pkce) {
|
|
842
|
-
// PKCE / browser-redirect flow — open auth URL, poll for completion.
|
|
843
|
-
const { authUrl } = startResult;
|
|
844
|
-
window.open(authUrl, '_blank');
|
|
845
|
-
feedback.innerHTML = `Browser opened for authorization.<br>
|
|
846
|
-
<span style="opacity:0.6;font-size:12px">Complete sign-in in the browser window, then return here…</span>`;
|
|
847
|
-
feedback.className = 'key-feedback muted';
|
|
848
|
-
} else {
|
|
849
|
-
// Device code flow — show URL and code.
|
|
850
|
-
const { url, userCode } = startResult;
|
|
851
|
-
window.open(url, '_blank');
|
|
852
|
-
feedback.innerHTML = `Go to <a href="${url}" target="_blank" style="color:inherit">${url}</a><br>
|
|
853
|
-
Enter code: <strong style="letter-spacing:0.1em;font-size:15px">${userCode}</strong><br>
|
|
854
|
-
<span style="opacity:0.6;font-size:12px">Waiting for authorization…</span>`;
|
|
855
|
-
feedback.className = 'key-feedback muted';
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
pollInterval = setInterval(async () => {
|
|
859
|
-
const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
|
|
860
|
-
if (statusResult.status === 'done') {
|
|
861
|
-
stopPolling();
|
|
862
|
-
feedback.textContent = '✓ Signed in — provider added';
|
|
863
|
-
feedback.className = 'key-feedback success';
|
|
864
|
-
showToast(`${template.name} added`);
|
|
865
|
-
state.modelsLoaded = false;
|
|
866
|
-
await loadTemplates();
|
|
867
|
-
await initModels();
|
|
868
|
-
renderProviders();
|
|
869
|
-
} else if (statusResult.status === 'error') {
|
|
870
|
-
stopPolling();
|
|
871
|
-
feedback.textContent = statusResult.error ?? 'Authorization failed';
|
|
872
|
-
feedback.className = 'key-feedback error';
|
|
873
|
-
signInBtn.disabled = false;
|
|
874
|
-
} else if (statusResult.error) {
|
|
875
|
-
stopPolling();
|
|
876
|
-
feedback.textContent = 'Session expired — please try again';
|
|
877
|
-
feedback.className = 'key-feedback error';
|
|
878
|
-
signInBtn.disabled = false;
|
|
879
|
-
}
|
|
880
|
-
}, 3000);
|
|
881
|
-
});
|
|
897
|
+
content.append(note, actionRow, feedback);
|
|
898
|
+
|
|
899
|
+
signInBtn.addEventListener('click', () => beginDeviceOAuthFlow({
|
|
900
|
+
providerId: template.id,
|
|
901
|
+
signInButton: signInBtn,
|
|
902
|
+
feedback,
|
|
903
|
+
onDone: async () => {
|
|
904
|
+
showToast(`${template.name} added`);
|
|
905
|
+
state.modelsLoaded = false;
|
|
906
|
+
await loadTemplates();
|
|
907
|
+
await initModels();
|
|
908
|
+
renderProviders();
|
|
909
|
+
},
|
|
910
|
+
}));
|
|
882
911
|
|
|
883
912
|
return content;
|
|
884
913
|
}
|
|
@@ -1110,10 +1139,10 @@ function buildOAuthProviderBodyContent(provider) {
|
|
|
1110
1139
|
content.className = 'provider-body-content';
|
|
1111
1140
|
|
|
1112
1141
|
const status = document.createElement('div');
|
|
1113
|
-
status.
|
|
1142
|
+
status.className = 'oauth-device-note';
|
|
1114
1143
|
status.textContent = provider.hasKey
|
|
1115
|
-
?
|
|
1116
|
-
: 'Not signed in.
|
|
1144
|
+
? `Signed in via OAuth${provider.subscription?.label ? ` · ${provider.subscription.label}` : ''}.`
|
|
1145
|
+
: 'Not signed in. Generate a one-time code to connect this provider.';
|
|
1117
1146
|
|
|
1118
1147
|
const actionRow = document.createElement('div');
|
|
1119
1148
|
actionRow.className = 'key-row';
|
|
@@ -1122,7 +1151,7 @@ function buildOAuthProviderBodyContent(provider) {
|
|
|
1122
1151
|
|
|
1123
1152
|
const signInBtn = document.createElement('button');
|
|
1124
1153
|
signInBtn.className = 'btn btn-primary';
|
|
1125
|
-
signInBtn.textContent = provider.hasKey ? 'Re-authenticate' : '
|
|
1154
|
+
signInBtn.textContent = provider.hasKey ? 'Re-authenticate' : 'Get sign-in code';
|
|
1126
1155
|
|
|
1127
1156
|
const refreshBtn = document.createElement('button');
|
|
1128
1157
|
refreshBtn.className = 'btn btn-ghost';
|
|
@@ -1139,11 +1168,6 @@ function buildOAuthProviderBodyContent(provider) {
|
|
|
1139
1168
|
content.appendChild(feedback);
|
|
1140
1169
|
content.appendChild(buildDeleteProviderRow(provider));
|
|
1141
1170
|
|
|
1142
|
-
let pollInterval = null;
|
|
1143
|
-
function stopPolling() {
|
|
1144
|
-
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
1171
|
refreshBtn.addEventListener('click', async () => {
|
|
1148
1172
|
refreshBtn.disabled = true;
|
|
1149
1173
|
feedback.textContent = 'Fetching latest models…';
|
|
@@ -1162,61 +1186,19 @@ function buildOAuthProviderBodyContent(provider) {
|
|
|
1162
1186
|
setTimeout(() => { feedback.textContent = ''; feedback.className = 'key-feedback'; }, 4000);
|
|
1163
1187
|
});
|
|
1164
1188
|
|
|
1165
|
-
signInBtn.addEventListener('click',
|
|
1166
|
-
|
|
1167
|
-
signInBtn
|
|
1168
|
-
refreshBtn
|
|
1169
|
-
feedback
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
const { sessionId, url, userCode } = startResult;
|
|
1182
|
-
window.open(url, '_blank');
|
|
1183
|
-
feedback.innerHTML = `Go to <a href="${url}" target="_blank" style="color:inherit">${url}</a><br>
|
|
1184
|
-
Enter code: <strong style="letter-spacing:0.1em;font-size:15px">${userCode}</strong><br>
|
|
1185
|
-
<span style="opacity:0.6;font-size:12px">Waiting for authorization…</span>`;
|
|
1186
|
-
feedback.className = 'key-feedback muted';
|
|
1187
|
-
|
|
1188
|
-
pollInterval = setInterval(async () => {
|
|
1189
|
-
const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
|
|
1190
|
-
if (statusResult.status === 'done') {
|
|
1191
|
-
stopPolling();
|
|
1192
|
-
feedback.textContent = '✓ Signed in successfully';
|
|
1193
|
-
feedback.className = 'key-feedback success';
|
|
1194
|
-
provider.hasKey = true;
|
|
1195
|
-
status.textContent = 'Signed in via OAuth.';
|
|
1196
|
-
signInBtn.textContent = 'Re-authenticate';
|
|
1197
|
-
signInBtn.disabled = false;
|
|
1198
|
-
refreshBtn.style.display = '';
|
|
1199
|
-
refreshBtn.disabled = false;
|
|
1200
|
-
const chip = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .status-chip`);
|
|
1201
|
-
if (chip) { chip.className = 'status-chip has-key'; chip.textContent = 'Key stored'; }
|
|
1202
|
-
showToast(`Signed in to ${provider.name ?? provider.id}`);
|
|
1203
|
-
state.modelsLoaded = false;
|
|
1204
|
-
initModels().then(() => { renderProviders(); renderFavList(); });
|
|
1205
|
-
} else if (statusResult.status === 'error') {
|
|
1206
|
-
stopPolling();
|
|
1207
|
-
feedback.textContent = statusResult.error ?? 'Authorization failed';
|
|
1208
|
-
feedback.className = 'key-feedback error';
|
|
1209
|
-
signInBtn.disabled = false;
|
|
1210
|
-
refreshBtn.disabled = false;
|
|
1211
|
-
} else if (statusResult.error) {
|
|
1212
|
-
stopPolling();
|
|
1213
|
-
feedback.textContent = 'Session expired — please try again';
|
|
1214
|
-
feedback.className = 'key-feedback error';
|
|
1215
|
-
signInBtn.disabled = false;
|
|
1216
|
-
refreshBtn.disabled = false;
|
|
1217
|
-
}
|
|
1218
|
-
}, 3000);
|
|
1219
|
-
});
|
|
1189
|
+
signInBtn.addEventListener('click', () => beginDeviceOAuthFlow({
|
|
1190
|
+
providerId: provider.id,
|
|
1191
|
+
signInButton: signInBtn,
|
|
1192
|
+
refreshButton: refreshBtn,
|
|
1193
|
+
feedback,
|
|
1194
|
+
onDone: async () => {
|
|
1195
|
+
showToast(`Signed in to ${provider.name ?? provider.id}`);
|
|
1196
|
+
state.modelsLoaded = false;
|
|
1197
|
+
await initModels();
|
|
1198
|
+
renderProviders();
|
|
1199
|
+
renderFavList();
|
|
1200
|
+
},
|
|
1201
|
+
}));
|
|
1220
1202
|
|
|
1221
1203
|
return content;
|
|
1222
1204
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Return the short credential label used on a provider card. */
|
|
2
|
+
export function oauthConnectionLabel(provider) {
|
|
3
|
+
if (!provider?.hasKey) return provider?.freeAccess ? 'Free models' : 'Not configured';
|
|
4
|
+
if (provider.authType !== 'oauth') return 'Key stored';
|
|
5
|
+
const planLabel = provider.subscription?.label;
|
|
6
|
+
return typeof planLabel === 'string' && planLabel.trim()
|
|
7
|
+
? `Connected · ${planLabel.trim()}`
|
|
8
|
+
: 'Connected';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Copy a device code, with a small legacy fallback for older browser contexts. */
|
|
12
|
+
export async function copyDeviceCode(code, clipboard = globalThis.navigator?.clipboard) {
|
|
13
|
+
if (clipboard?.writeText) {
|
|
14
|
+
await clipboard.writeText(code);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!globalThis.document?.body || typeof globalThis.document.execCommand !== 'function') {
|
|
18
|
+
throw new Error('Clipboard access is unavailable');
|
|
19
|
+
}
|
|
20
|
+
const input = globalThis.document.createElement('textarea');
|
|
21
|
+
input.value = code;
|
|
22
|
+
input.setAttribute('readonly', '');
|
|
23
|
+
input.style.position = 'fixed';
|
|
24
|
+
input.style.opacity = '0';
|
|
25
|
+
globalThis.document.body.appendChild(input);
|
|
26
|
+
input.select();
|
|
27
|
+
const copied = globalThis.document.execCommand('copy');
|
|
28
|
+
input.remove();
|
|
29
|
+
if (!copied) throw new Error('Copy failed');
|
|
30
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Provider brand marks shared by every UI surface that represents a provider.
|
|
2
|
+
// Simple Icons URLs were verified when added; inline SVGs cover brands whose
|
|
3
|
+
// official assets cannot be hotlinked reliably.
|
|
4
|
+
|
|
5
|
+
const PROVIDER_INLINE_SVGS = {
|
|
6
|
+
openai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.896zm16.597 3.855l-5.843-3.368L15.115 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.403-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>`,
|
|
7
|
+
kilo: `<svg viewBox="0 0 24 24" fill="white" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" style="width:22px;height:22px"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"/></svg>`,
|
|
8
|
+
xai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const PROVIDER_LOGO_URLS = {
|
|
12
|
+
zen: 'https://opencode.ai/favicon.ico',
|
|
13
|
+
go: 'https://opencode.ai/favicon.ico',
|
|
14
|
+
anthropic: 'https://cdn.simpleicons.org/anthropic/ffffff',
|
|
15
|
+
google: 'https://cdn.simpleicons.org/google/ffffff',
|
|
16
|
+
nvidia: 'https://cdn.simpleicons.org/nvidia/ffffff',
|
|
17
|
+
deepseek: 'https://cdn.simpleicons.org/deepseek/ffffff',
|
|
18
|
+
mistral: 'https://cdn.simpleicons.org/mistralai/ffffff',
|
|
19
|
+
ollama: 'https://cdn.simpleicons.org/ollama/ffffff',
|
|
20
|
+
openrouter: 'https://cdn.simpleicons.org/openrouter/ffffff',
|
|
21
|
+
perplexity: 'https://cdn.simpleicons.org/perplexity/ffffff',
|
|
22
|
+
huggingface: 'https://cdn.simpleicons.org/huggingface/ffffff',
|
|
23
|
+
cloudflare: 'https://cdn.simpleicons.org/cloudflare/ffffff',
|
|
24
|
+
azure: 'https://cdn.simpleicons.org/microsoftazure/ffffff',
|
|
25
|
+
vertex: 'https://cdn.simpleicons.org/googlecloud/ffffff',
|
|
26
|
+
bedrock: 'https://cdn.simpleicons.org/amazonaws/ffffff',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function escapeHtml(value) {
|
|
30
|
+
return String(value ?? '')
|
|
31
|
+
.replace(/&/g, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'/g, ''');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getProviderLogoContent(providerId) {
|
|
39
|
+
const base = providerId.replace(/-oauth$/, '').replace(/-api$/, '');
|
|
40
|
+
const svg = PROVIDER_INLINE_SVGS[providerId] ?? PROVIDER_INLINE_SVGS[base];
|
|
41
|
+
if (svg) return { type: 'svg', content: svg };
|
|
42
|
+
const url = PROVIDER_LOGO_URLS[providerId] ?? PROVIDER_LOGO_URLS[base];
|
|
43
|
+
if (url) return { type: 'img', content: url };
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function providerInitial(name) {
|
|
48
|
+
return (name || '?').replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || '?';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function providerLogoHtml(providerId, displayName) {
|
|
52
|
+
const initial = providerInitial(displayName);
|
|
53
|
+
const logo = getProviderLogoContent(providerId);
|
|
54
|
+
if (logo?.type === 'svg') return logo.content;
|
|
55
|
+
if (logo?.type === 'img') {
|
|
56
|
+
return `<img src="${logo.content}" class="provider-logo-img" alt="${escapeHtml(displayName)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`;
|
|
57
|
+
}
|
|
58
|
+
return initial;
|
|
59
|
+
}
|