mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features - Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI. - Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status. - Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance. - Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints. - Added new slash commands for managing skills, including listing, searching, and invoking skills. - Implemented caching for skill catalog to optimize performance. - Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing. - Refactored existing code to accommodate new features and improve maintainability. * refactor: share provider device-flow setup - reuse one device-flow backend for Copilot and ChatGPT Subscription - add one frontend device-flow helper for Settings and /setup - put GitHub Copilot back into Add Models, now as a dropdown option - make provider selection just select; clicking Add starts sign-in - stop ChatGPT Subscription setup from opening auth tabs automatically - make /setup copilot and /setup chatgpt-subscription work from chat - show ChatGPT Subscription in the /setup suggestions - show the real error message when setup fails - add focused tests for the shared flow and setup UI * feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX Backend: - Resolve runtime bearer for provider-auth endpoints at probe time via a shared _resolve_probe_key() that delegates to resolve_endpoint_runtime, applied across all probe/refresh call sites. - Skip live completion probes and health pings for discovery-only providers (centralized behind _is_discovery_only_provider) — the Codex/Responses API has no such endpoints, so status is derived from cached models. - Never persist the short lived ChatGPT bearer to the plaintext sessions table; proactively clear any stale bearer left by an earlier code path. - Revoke orphaned ProviderAuthSession credentials when the last endpoint backing them is deleted (_delete_orphaned_provider_auth), surfaced via cleared_provider_auth in the delete response. Frontend (admin.js): - Auto-start the device-auth flow on provider selection so the authorization panel (code + Authorize) shows immediately instead of behind a "Sign in" click. - Remove the redundant top button for device auth providers, move retry into the panel via an inline "Try again". - Drop the self-evident hint text and add an execCommand clipboard fallback so Copy works in non-secure (HTTP/LAN) contexts. * fix: harden chatgpt subscription provider * chore: remove PR media from branch * Fix chatgpt subscription recovery and token handling --------- Co-authored-by: 5p00kyy <admin@5p00ky.dev>
This commit is contained in:
+201
-68
@@ -5,6 +5,7 @@ import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -707,6 +708,80 @@ function initEndpointForm() {
|
||||
const pickerBtn = el('adm-provider-btn');
|
||||
const pickerMenu = el('adm-provider-menu');
|
||||
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
|
||||
const DEVICE_AUTH_PROVIDER_VALUES = new Set(Object.keys(PROVIDER_DEVICE_FLOWS));
|
||||
let deviceAuthPolling = false;
|
||||
function _selectedProviderOption() {
|
||||
return provider && provider.selectedOptions ? provider.selectedOptions[0] : null;
|
||||
}
|
||||
function _selectedDeviceAuthProvider() {
|
||||
const opt = _selectedProviderOption();
|
||||
const flow = opt && opt.dataset ? opt.dataset.authFlow : '';
|
||||
if (flow && DEVICE_AUTH_PROVIDER_VALUES.has(flow)) return flow;
|
||||
return DEVICE_AUTH_PROVIDER_VALUES.has(provider.value) ? provider.value : '';
|
||||
}
|
||||
function _isDeviceAuthSelected() {
|
||||
return !!_selectedDeviceAuthProvider();
|
||||
}
|
||||
function _setApiFormForProvider() {
|
||||
const deviceAuthProvider = _selectedDeviceAuthProvider();
|
||||
const deviceAuthConfig = PROVIDER_DEVICE_FLOWS[deviceAuthProvider] || null;
|
||||
const apiKey = el('adm-epApiKey');
|
||||
const testBtn = el('adm-epApiTestBtn');
|
||||
const addBtn = el('adm-epAddBtn');
|
||||
const status = el('adm-deviceAuthStatus');
|
||||
const msg = _endpointMsg('api');
|
||||
if (deviceAuthConfig) {
|
||||
urlInput.value = '';
|
||||
urlInput.placeholder = deviceAuthProvider === 'copilot'
|
||||
? 'GitHub Copilot uses GitHub account sign-in'
|
||||
: 'ChatGPT Subscription uses OpenAI account sign-in';
|
||||
urlInput.readOnly = true;
|
||||
if (apiKey) {
|
||||
apiKey.value = '';
|
||||
apiKey.placeholder = 'No API key needed';
|
||||
apiKey.disabled = true;
|
||||
}
|
||||
if (testBtn) {
|
||||
testBtn.disabled = true;
|
||||
testBtn.style.opacity = '0.45';
|
||||
testBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.disabled = false;
|
||||
addBtn.textContent = 'Add';
|
||||
addBtn.style.width = '55px';
|
||||
addBtn.style.display = '';
|
||||
}
|
||||
if (kindSel) kindSel.value = 'api';
|
||||
if (msg) {
|
||||
msg.textContent = '';
|
||||
msg.className = '';
|
||||
}
|
||||
} else {
|
||||
urlInput.placeholder = 'Base URL or pick provider';
|
||||
urlInput.readOnly = false;
|
||||
if (apiKey) {
|
||||
apiKey.placeholder = 'API key';
|
||||
apiKey.disabled = false;
|
||||
}
|
||||
if (testBtn) {
|
||||
testBtn.disabled = false;
|
||||
testBtn.style.opacity = '';
|
||||
testBtn.style.cursor = '';
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.disabled = false;
|
||||
addBtn.textContent = 'Add';
|
||||
addBtn.style.width = '55px';
|
||||
addBtn.style.display = '';
|
||||
}
|
||||
if (msg) {
|
||||
msg.textContent = '';
|
||||
msg.className = '';
|
||||
}
|
||||
if (!deviceAuthPolling && status) status.textContent = '';
|
||||
}
|
||||
}
|
||||
function _renderPickerMenu() {
|
||||
if (!pickerMenu) return;
|
||||
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
|
||||
@@ -748,9 +823,16 @@ function initEndpointForm() {
|
||||
}
|
||||
|
||||
provider.addEventListener('change', () => {
|
||||
if (_isDeviceAuthSelected()) {
|
||||
_setApiFormForProvider();
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
return;
|
||||
}
|
||||
if (provider.value) urlInput.value = provider.value;
|
||||
else urlInput.value = '';
|
||||
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
||||
_setApiFormForProvider();
|
||||
});
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
@@ -838,6 +920,12 @@ function initEndpointForm() {
|
||||
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
||||
if (apiTestBtn) {
|
||||
apiTestBtn.addEventListener('click', async () => {
|
||||
if (_isDeviceAuthSelected()) {
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = '';
|
||||
msg.className = '';
|
||||
return;
|
||||
}
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
@@ -885,6 +973,11 @@ function initEndpointForm() {
|
||||
}
|
||||
|
||||
el('adm-epAddBtn').addEventListener('click', async () => {
|
||||
const deviceAuthProvider = _selectedDeviceAuthProvider();
|
||||
if (deviceAuthProvider) {
|
||||
await _startProviderDeviceAuth(deviceAuthProvider, el('adm-epAddBtn'));
|
||||
return;
|
||||
}
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
@@ -936,76 +1029,116 @@ function initEndpointForm() {
|
||||
btn.disabled = false; btn.textContent = 'Add';
|
||||
});
|
||||
|
||||
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
|
||||
// code + verification link, and polls until they authorise (or it expires).
|
||||
const copilotBtn = el('adm-copilotConnectBtn');
|
||||
if (copilotBtn) {
|
||||
let copilotPolling = false;
|
||||
copilotBtn.addEventListener('click', async () => {
|
||||
if (copilotPolling) return;
|
||||
const status = el('adm-copilotStatus');
|
||||
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
|
||||
status.textContent = ''; status.className = 'adm-ep-inline-msg';
|
||||
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
|
||||
copilotPolling = true;
|
||||
let start;
|
||||
try {
|
||||
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await res.json();
|
||||
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
|
||||
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
|
||||
async function _startProviderDeviceAuth(providerKey, triggerEl = null) {
|
||||
if (deviceAuthPolling) return;
|
||||
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||
if (!config) return;
|
||||
const status = el('adm-deviceAuthStatus') || _endpointMsg('api');
|
||||
if (!status) return;
|
||||
const triggerText = triggerEl ? triggerEl.textContent : '';
|
||||
// Render an error with an inline "Try again" (the top button is hidden for
|
||||
// device-auth providers, so retry lives here). Built with DOM methods, not
|
||||
// innerHTML. Call reset() first so the deviceAuthPolling guard is cleared.
|
||||
const showAuthError = (text) => {
|
||||
status.className = 'admin-error';
|
||||
status.textContent = text + ' ';
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'admin-btn-sm';
|
||||
retry.textContent = 'Try again';
|
||||
retry.addEventListener('click', () => { _startProviderDeviceAuth(providerKey, triggerEl); });
|
||||
status.appendChild(retry);
|
||||
};
|
||||
const reset = () => {
|
||||
if (triggerEl) {
|
||||
triggerEl.disabled = false;
|
||||
triggerEl.textContent = triggerText || 'Add';
|
||||
}
|
||||
deviceAuthPolling = false;
|
||||
_setApiFormForProvider();
|
||||
};
|
||||
status.textContent = '';
|
||||
status.className = 'adm-ep-inline-msg';
|
||||
if (triggerEl) {
|
||||
triggerEl.disabled = true;
|
||||
triggerEl.textContent = 'Starting...';
|
||||
}
|
||||
deviceAuthPolling = true;
|
||||
_setApiFormForProvider();
|
||||
status.textContent = `Starting ${config.label} sign-in...`;
|
||||
|
||||
const { poll_id, user_code, verification_uri, verification_uri_complete, interval, expires_in } = start;
|
||||
// Prefer the "complete" URL — it embeds the code so the user only has to
|
||||
// click "Authorize" (no manual code entry).
|
||||
const authUrl = verification_uri_complete || verification_uri || '';
|
||||
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
copilotBtn.textContent = 'Waiting…';
|
||||
|
||||
// Cohesive waiting panel: spinner + status line, the device code as a
|
||||
// copyable chip, and a primary "Authorize on GitHub" action.
|
||||
status.className = '';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>Waiting for GitHub authorization…</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
|
||||
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-copilot-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
|
||||
try {
|
||||
const result = await runProviderDeviceFlow(providerKey, {
|
||||
openWindow: () => {},
|
||||
onStart: ({ start, authUrl }) => {
|
||||
if (triggerEl) triggerEl.textContent = 'Waiting...';
|
||||
status.className = '';
|
||||
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
|
||||
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>' + esc(waitLabel) + '</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-device-auth-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
const code = start.user_code || '';
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
ok = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
if (!ok) {
|
||||
// navigator.clipboard is unavailable in non-secure contexts (HTTP
|
||||
// self-host over a LAN IP), so fall back to execCommand('copy').
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { ta.setSelectionRange(0, code.length); } catch (e) {}
|
||||
try { ok = document.execCommand('copy'); } catch (e) {}
|
||||
ta.remove();
|
||||
}
|
||||
copyBtn.textContent = ok ? 'Copied' : 'Failed';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
},
|
||||
});
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
|
||||
const deadline = Date.now() + (expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((interval || 5), 2) * 1000;
|
||||
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', poll_id);
|
||||
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
|
||||
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d.endpoint || {});
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
const endpoint = result.endpoint || {};
|
||||
const n = ((endpoint && endpoint.models) || []).length;
|
||||
status.className = 'admin-success';
|
||||
status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
|
||||
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(endpoint || {});
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
if (result.status === 'failed') {
|
||||
reset();
|
||||
showAuthError('Authorization failed (' + (result.error || 'denied') + ').');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
reset();
|
||||
showAuthError('Authorization expired.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
reset();
|
||||
showAuthError(formatDeviceFlowError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||
|
||||
+39
-15
@@ -680,9 +680,11 @@ export function applyModelColor(roleEl, modelName) {
|
||||
html += '<div><span class="ctx-label">Max tokens</span> ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>';
|
||||
}
|
||||
}
|
||||
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
|
||||
if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
|
||||
if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
|
||||
if (isCostTrackedEndpoint(_epUrl)) {
|
||||
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
|
||||
if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
|
||||
if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
|
||||
}
|
||||
popup.innerHTML = html;
|
||||
const rect = roleEl.getBoundingClientRect();
|
||||
popup.style.top = (rect.bottom + 4) + 'px';
|
||||
@@ -735,11 +737,31 @@ export function isLocalEndpoint(url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Cost for the current turn, returning null (free) for local endpoints. */
|
||||
function _billableCost(model, inputTokens, outputTokens) {
|
||||
const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
export function isSubscriptionEndpoint(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const path = parsed.pathname.replace(/\/+$/, '');
|
||||
return parsed.hostname === 'chatgpt.com'
|
||||
&& (path === '/backend-api/codex' || path.startsWith('/backend-api/codex/'));
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _currentEndpointUrl() {
|
||||
return (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
? window.sessionModule.getCurrentEndpointUrl() : null;
|
||||
if (isLocalEndpoint(url)) return null;
|
||||
}
|
||||
|
||||
export function isCostTrackedEndpoint(url) {
|
||||
return !isLocalEndpoint(url) && !isSubscriptionEndpoint(url);
|
||||
}
|
||||
|
||||
/** Cost for the current turn, returning null for non-billable endpoints. */
|
||||
function _billableCost(model, inputTokens, outputTokens) {
|
||||
const url = _currentEndpointUrl();
|
||||
if (!isCostTrackedEndpoint(url)) return null;
|
||||
return getModelCost(model, inputTokens, outputTokens);
|
||||
}
|
||||
|
||||
@@ -784,11 +806,10 @@ export function resetSessionCost(sessionId) {
|
||||
export function updateSessionCostUI() {
|
||||
const el = document.getElementById('session-cost-display');
|
||||
if (!el) return;
|
||||
// Local model? It's free — hide the badge and clear any stale cost that a
|
||||
// previous (buggy) cloud-rate billing left in localStorage for this session.
|
||||
const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
? window.sessionModule.getCurrentEndpointUrl() : null;
|
||||
if (isLocalEndpoint(_url)) {
|
||||
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
|
||||
// cloud-rate calculation may have left in localStorage for this session.
|
||||
const _url = _currentEndpointUrl();
|
||||
if (!isCostTrackedEndpoint(_url)) {
|
||||
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (sid && getSessionCost(sid) > 0) {
|
||||
try {
|
||||
@@ -1708,7 +1729,8 @@ export function displayMetrics(messageElement, metrics) {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
||||
|
||||
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a';
|
||||
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : '';
|
||||
const costRows = costStr ? `<div><span class="ctx-label">Cost</span> ${costStr}</div>` : '';
|
||||
const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
|
||||
const totalTok = inputTokens + outputTokens;
|
||||
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)';
|
||||
@@ -1722,7 +1744,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
// Session total cost
|
||||
let sessionCostStr = '';
|
||||
const sc = getSessionCost();
|
||||
if (sc > 0) {
|
||||
if (costStr && sc > 0) {
|
||||
sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
|
||||
}
|
||||
|
||||
@@ -1738,7 +1760,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
<div><span class="ctx-label">Time</span> ${responseTime}s</div>
|
||||
${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
|
||||
${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
|
||||
<div><span class="ctx-label">Cost</span> ${costStr}</div>
|
||||
${costRows}
|
||||
${sessionCostStr}
|
||||
${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
|
||||
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
|
||||
@@ -2392,6 +2414,8 @@ const chatRenderer = {
|
||||
modelColor,
|
||||
applyModelColor,
|
||||
getModelCost,
|
||||
isCostTrackedEndpoint,
|
||||
isSubscriptionEndpoint,
|
||||
getImageCost,
|
||||
getSessionCost,
|
||||
resetSessionCost,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Shared DOM-free provider device-flow runner.
|
||||
|
||||
export const PROVIDER_DEVICE_FLOWS = {
|
||||
copilot: {
|
||||
label: 'GitHub Copilot',
|
||||
startUrl: '/api/copilot/device/start',
|
||||
pollUrl: '/api/copilot/device/poll',
|
||||
authUrl(start) {
|
||||
return start?.verification_uri_complete || start?.verification_uri || '';
|
||||
},
|
||||
},
|
||||
'chatgpt-subscription': {
|
||||
label: 'ChatGPT Subscription',
|
||||
startUrl: '/api/chatgpt-subscription/device/start',
|
||||
pollUrl: '/api/chatgpt-subscription/device/poll',
|
||||
authUrl(start) {
|
||||
return start?.verification_uri || '';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function _formData() {
|
||||
if (typeof FormData !== 'undefined') return new FormData();
|
||||
return new URLSearchParams();
|
||||
}
|
||||
|
||||
async function _jsonOrEmpty(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _messageFromPayload(payload, fallback) {
|
||||
if (payload && typeof payload.detail === 'string' && payload.detail.trim()) {
|
||||
return payload.detail.trim();
|
||||
}
|
||||
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
|
||||
return payload.error.trim();
|
||||
}
|
||||
if (payload && typeof payload.message === 'string' && payload.message.trim()) {
|
||||
return payload.message.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function formatDeviceFlowError(error, fallback = 'Request failed') {
|
||||
if (!error) return fallback;
|
||||
if (typeof error === 'string') return error;
|
||||
if (error.detail) return String(error.detail);
|
||||
if (error.message) return String(error.message);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function _fetchJson(fetchImpl, url, options, fallback) {
|
||||
const response = await fetchImpl(url, options);
|
||||
const payload = await _jsonOrEmpty(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(_messageFromPayload(payload, fallback || `Request failed (HTTP ${response.status})`));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function _defaultSleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function _callCallback(fn, payload) {
|
||||
if (typeof fn === 'function') await fn(payload);
|
||||
}
|
||||
|
||||
export async function runProviderDeviceFlow(provider, options = {}) {
|
||||
const cfg = PROVIDER_DEVICE_FLOWS[provider];
|
||||
if (!cfg) throw new Error(`Unknown device-flow provider: ${provider}`);
|
||||
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch?.bind(globalThis);
|
||||
if (!fetchImpl) throw new Error('Fetch API is unavailable');
|
||||
|
||||
const openWindow = options.openWindow || ((url) => {
|
||||
if (globalThis.window && typeof globalThis.window.open === 'function') {
|
||||
globalThis.window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
});
|
||||
const sleep = options.sleep || _defaultSleep;
|
||||
const now = options.now || (() => Date.now());
|
||||
const formData = options.formData || _formData();
|
||||
|
||||
const start = await _fetchJson(fetchImpl, cfg.startUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
}, `Failed to start ${cfg.label} sign-in`);
|
||||
|
||||
if (!start.poll_id) throw new Error(`${cfg.label} sign-in did not return a poll id`);
|
||||
const authUrl = cfg.authUrl(start);
|
||||
await _callCallback(options.onStart, { provider, config: cfg, start, authUrl });
|
||||
if (authUrl) openWindow(authUrl);
|
||||
|
||||
const deadline = now() + Number(start.expires_in || 900) * 1000;
|
||||
let stepMs = Math.max(Number(start.interval || 5), 2) * 1000;
|
||||
|
||||
while (true) {
|
||||
if (now() > deadline) return { status: 'expired' };
|
||||
await _callCallback(options.onWaiting, { provider, config: cfg, start, authUrl });
|
||||
await sleep(stepMs);
|
||||
if (now() > deadline) return { status: 'expired' };
|
||||
|
||||
const fd = _formData();
|
||||
fd.append('poll_id', start.poll_id);
|
||||
const poll = await _fetchJson(fetchImpl, cfg.pollUrl, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
}, `${cfg.label} sign-in poll failed`);
|
||||
await _callCallback(options.onPoll, { provider, config: cfg, start, poll });
|
||||
|
||||
if (poll.status === 'authorized') {
|
||||
return { status: 'authorized', endpoint: poll.endpoint || {} };
|
||||
}
|
||||
if (poll.status === 'failed') {
|
||||
return { status: 'failed', error: poll.error || 'denied' };
|
||||
}
|
||||
if (poll.interval) {
|
||||
stepMs = Math.max(Number(poll.interval || 5), 2) * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ const _PROVIDERS = [
|
||||
[/opencode/i,
|
||||
'<svg viewBox="0 0 24 30" fill="currentColor"><path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z"/></svg>'],
|
||||
|
||||
// GitHub / Copilot
|
||||
[/github|copilot/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A12 12 0 0 0 8.2 23.9c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.8.1-.8.1-.8 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.3 3.6 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.3-3.2-.1-.3-.5-1.6.1-3.2 0 0 1-.3 3.3 1.2a11.4 11.4 0 0 1 6 0C15.3 4.7 16 5 16 5c.6 1.6.2 2.9.1 3.2.8.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .5Z"/></svg>'],
|
||||
|
||||
// OpenRouter
|
||||
[/openrouter|open router/i,
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4.5c2 0 2.5-6 4.5-6"/><path d="M12 12c2 0 2.5 6 4.5 6"/></svg>'],
|
||||
@@ -102,6 +106,7 @@ export function providerLogo(modelId) {
|
||||
// doesn't match `x.ai`.
|
||||
const _ENDPOINT_LABELS = [
|
||||
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
||||
[/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"],
|
||||
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
||||
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
||||
[/(^|\.)openai\.com$/i, "OpenAI"],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js';
|
||||
|
||||
const POPUP_ID = 'slash-autocomplete';
|
||||
const MAX_VISIBLE = 12;
|
||||
const MAX_VISIBLE = 14;
|
||||
|
||||
// Flatten the registry into a searchable list of leaf entries. Each entry is
|
||||
// either a top-level command or a "cmd sub" pair (so subcommands get their
|
||||
@@ -81,6 +81,23 @@ function _flatten() {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function _loadSkillEntries() {
|
||||
try {
|
||||
const res = await fetch('/api/skills/slash-catalog', { credentials: 'same-origin' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (Array.isArray(data.skills) ? data.skills : []).map(s => ({
|
||||
token: s.token || `/${s.name}`,
|
||||
aliases: [],
|
||||
category: s.category || 'Skills',
|
||||
help: s.help || 'Run skill',
|
||||
usage: s.usage || `${s.token || `/${s.name}`} <request>`,
|
||||
})).filter(e => e.token && e.token.startsWith('/'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function _scoreMatch(entry, query) {
|
||||
// query already starts with "/". Match against token + aliases. Prefix wins
|
||||
// over substring; alias match scores slightly lower than token match.
|
||||
@@ -98,6 +115,17 @@ function _scoreMatch(entry, query) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function _exactCommandGroupItems(all, query) {
|
||||
const q = query.toLowerCase();
|
||||
if (!/^\/[a-z0-9_-]+$/i.test(q)) return [];
|
||||
const parent = all.find(entry => entry.token.toLowerCase() === q);
|
||||
if (!parent) return [];
|
||||
const prefix = q + ' ';
|
||||
const children = all.filter(entry => entry.token.toLowerCase().startsWith(prefix));
|
||||
if (!children.length) return [];
|
||||
return children.concat(parent);
|
||||
}
|
||||
|
||||
function _ensurePopup(textarea) {
|
||||
let el = document.getElementById(POPUP_ID);
|
||||
if (el) return el;
|
||||
@@ -164,7 +192,7 @@ export function initSlashAutocomplete(textarea) {
|
||||
if (!textarea || textarea._slashAcWired) return;
|
||||
textarea._slashAcWired = true;
|
||||
|
||||
const all = _flatten();
|
||||
let all = _flatten();
|
||||
let popup = null;
|
||||
let visible = false;
|
||||
let items = [];
|
||||
@@ -191,12 +219,17 @@ export function initSlashAutocomplete(textarea) {
|
||||
// the menu hides — we don't autocomplete mid-sentence.
|
||||
if (!v.startsWith('/') || v.includes('\n')) { hide(); return; }
|
||||
const query = v.trim();
|
||||
items = all
|
||||
const groupItems = _exactCommandGroupItems(all, query);
|
||||
if (groupItems.length) {
|
||||
items = groupItems.slice(0, MAX_VISIBLE);
|
||||
} else {
|
||||
items = all
|
||||
.map(e => ({ e, s: _scoreMatch(e, query) }))
|
||||
.filter(x => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.slice(0, MAX_VISIBLE)
|
||||
.map(x => x.e);
|
||||
}
|
||||
if (!items.length && query.length > 1) { hide(); return; }
|
||||
if (!items.length) {
|
||||
// Just "/" with no matches — fall back to showing everything up to MAX_VISIBLE
|
||||
@@ -207,6 +240,19 @@ export function initSlashAutocomplete(textarea) {
|
||||
_render(popup, items, selectedIdx, query);
|
||||
};
|
||||
|
||||
_loadSkillEntries().then(skillEntries => {
|
||||
if (!skillEntries.length) return;
|
||||
const seen = new Set(all.map(e => e.token));
|
||||
const merged = all.slice();
|
||||
for (const entry of skillEntries) {
|
||||
if (seen.has(entry.token)) continue;
|
||||
seen.add(entry.token);
|
||||
merged.push(entry);
|
||||
}
|
||||
all = merged;
|
||||
if (visible) refresh();
|
||||
});
|
||||
|
||||
const insert = (token) => {
|
||||
textarea.value = token + ' ';
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
+351
-71
@@ -21,6 +21,7 @@ import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||
|
||||
// ── Module state ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -58,11 +59,28 @@ const SETUP_PROVIDER_URLS = {
|
||||
'opencode-go': { name: 'OpenCode Go', url: 'https://opencode.ai/zen/go/v1' },
|
||||
};
|
||||
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go'];
|
||||
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_NAMES[SETUP_PROVIDER_NAMES.length - 1];
|
||||
const SETUP_DEVICE_AUTH_PROVIDERS = [
|
||||
{ key: 'copilot', name: 'GitHub Copilot', aliases: ['github'], command: '/setup copilot' },
|
||||
{ key: 'chatgpt-subscription', name: 'ChatGPT Subscription', aliases: ['chatgptsubscription', 'chatgpt-sub', 'codex'], command: '/setup chatgpt-subscription' },
|
||||
];
|
||||
const SETUP_PROVIDER_HINT_NAMES = SETUP_PROVIDER_NAMES.concat(SETUP_DEVICE_AUTH_PROVIDERS.map(provider => provider.key));
|
||||
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_HINT_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_HINT_NAMES[SETUP_PROVIDER_HINT_NAMES.length - 1];
|
||||
const SETUP_LOCAL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
|
||||
const SETUP_API_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||||
const SETUP_SETTINGS_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||
|
||||
function _setupApiProviderChips() {
|
||||
return SETUP_PROVIDER_NAMES.map(name =>
|
||||
'<span class="setup-clickable-provider" data-setup-kind="api-key" data-setup-provider="' + name + '" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
function _setupDeviceAuthProviderChips() {
|
||||
return SETUP_DEVICE_AUTH_PROVIDERS.map(provider =>
|
||||
'<span class="setup-clickable-provider" data-setup-kind="device-auth" data-setup-provider="' + provider.key + '" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Run ' + provider.command + '">' + provider.name + '</span>'
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
function _setupProviderFromInput(input) {
|
||||
const raw = (input || '').trim().toLowerCase().replace(/\s+/g, '');
|
||||
const aliases = {
|
||||
@@ -84,6 +102,17 @@ function _setupProviderFromInput(input) {
|
||||
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
|
||||
}
|
||||
|
||||
function _setupDeviceAuthProviderFromInput(input) {
|
||||
const raw = (input || '').trim().toLowerCase().replace(/\s+/g, '').replace(/_/g, '-');
|
||||
if (!raw) return '';
|
||||
for (const provider of SETUP_DEVICE_AUTH_PROVIDERS) {
|
||||
const candidates = [provider.key, provider.name, ...(provider.aliases || [])]
|
||||
.map(value => String(value || '').toLowerCase().replace(/\s+/g, '').replace(/_/g, '-'));
|
||||
if (candidates.includes(raw)) return provider.key;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function _extractSetupProviderCredential(input) {
|
||||
const raw = (input || '').trim();
|
||||
if (!raw) return null;
|
||||
@@ -158,9 +187,8 @@ function _setupReply(text, remember = true) {
|
||||
}
|
||||
|
||||
function _showSetupEndpointChoices() {
|
||||
const providers = SETUP_PROVIDER_NAMES.map(name =>
|
||||
'<span class="setup-clickable-provider" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
||||
).join(' ');
|
||||
const providers = _setupApiProviderChips();
|
||||
const deviceAuthProviders = _setupDeviceAuthProviderChips();
|
||||
return slashReply(
|
||||
'<div class="setup-guide-no-censor" style="display:grid;gap:10px;">' +
|
||||
'<div>' +
|
||||
@@ -178,6 +206,7 @@ function _showSetupEndpointChoices() {
|
||||
'<div>Paste provider name then API key (example):</div>' +
|
||||
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">deepseek sk-...</code></pre>' +
|
||||
'<div style="margin-top:8px;font-size:1em;"><span>Supported providers:</span><br>' + providers + '</div>' +
|
||||
'<div style="margin-top:8px;font-size:1em;"><span>Account sign-in:</span><br>' + deviceAuthProviders + '</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
@@ -208,9 +237,8 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
|
||||
text: 'deepseek sk-...',
|
||||
copyText: 'deepseek sk-...',
|
||||
},
|
||||
{ kind: 'p', html: '<strong>Supported providers:</strong><br>' + SETUP_PROVIDER_NAMES.map(name =>
|
||||
'<span class="setup-clickable-provider" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
||||
).join(' ') },
|
||||
{ kind: 'p', html: '<strong>Supported providers:</strong><br>' + _setupApiProviderChips() },
|
||||
{ kind: 'p', html: '<strong>Account sign-in:</strong><br>' + _setupDeviceAuthProviderChips() },
|
||||
];
|
||||
return typewriterBlocksReply(blocks, { gap: '4px', bodyClass: 'setup-guide-no-censor', interval: 3 });
|
||||
}
|
||||
@@ -231,7 +259,7 @@ async function _hasConfiguredModels() {
|
||||
}
|
||||
|
||||
function _setupProviderPrompt() {
|
||||
const chips = SETUP_PROVIDER_NAMES.map(name =>
|
||||
const chips = SETUP_PROVIDER_HINT_NAMES.map(name =>
|
||||
'<span style="font-weight:650;">' + name + '</span>'
|
||||
).join(' ');
|
||||
slashReply('<b>Supported providers:</b><br>' + chips);
|
||||
@@ -286,6 +314,53 @@ function slashReply(text) {
|
||||
return { el: div, body };
|
||||
}
|
||||
|
||||
let _skillCatalogCache = { at: 0, items: [] };
|
||||
|
||||
async function _loadSkillSlashCatalog(force = false) {
|
||||
const now = Date.now();
|
||||
if (!force && (now - _skillCatalogCache.at) < 15000) return _skillCatalogCache.items;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/skills/slash-catalog`, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error('catalog unavailable');
|
||||
const data = await res.json();
|
||||
const items = Array.isArray(data.skills) ? data.skills : [];
|
||||
_skillCatalogCache = { at: now, items };
|
||||
return items;
|
||||
} catch {
|
||||
return _skillCatalogCache.items || [];
|
||||
}
|
||||
}
|
||||
|
||||
function _submitComposedMessage(text) {
|
||||
const msgInput = document.getElementById('message');
|
||||
const form = document.getElementById('chat-form');
|
||||
if (!msgInput || !form) return false;
|
||||
msgInput.value = text;
|
||||
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
||||
else form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _invokeSkillByName(name, requestText, ctx) {
|
||||
const res = await fetch(`${API_BASE}/api/skills/${encodeURIComponent(name)}/invoke`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request: requestText || '' })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
slashReply(ctx?.esc ? ctx.esc(err?.detail || 'Skill is not available') : 'Skill is not available');
|
||||
return true;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.message || !_submitComposedMessage(data.message)) {
|
||||
slashReply('Could not start skill invocation.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Minimal footer for slash replies: copy + dismiss */
|
||||
function _slashFooter(msgEl) {
|
||||
const footer = document.createElement('div');
|
||||
@@ -681,6 +756,13 @@ async function handleSetupWizard(mode, input) {
|
||||
await _setupProviderPrompt();
|
||||
return;
|
||||
}
|
||||
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(input);
|
||||
if (deviceAuthProvider) {
|
||||
_addMessage('user', input);
|
||||
setupMode = false;
|
||||
await _setupProviderDeviceFlow(deviceAuthProvider);
|
||||
return;
|
||||
}
|
||||
const paired = _extractSetupProviderCredential(input);
|
||||
const provider = paired?.provider || _setupProviderFromInput(input);
|
||||
if (!provider) {
|
||||
@@ -1429,6 +1511,42 @@ async function _cmdModels(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdModel(args, ctx) {
|
||||
const sub = (args[0] || '').toLowerCase();
|
||||
if (sub === 'list' || sub === 'ls') return _cmdModels(args.slice(1), ctx);
|
||||
|
||||
const model = sessionModule.getCurrentModel ? sessionModule.getCurrentModel() : '';
|
||||
const endpoint = sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : '';
|
||||
slashReply(`<pre>${[
|
||||
`Current model: ${ctx.esc(model || 'None selected')}`,
|
||||
endpoint ? `Endpoint: ${ctx.esc(endpoint)}` : 'Endpoint: not available',
|
||||
'',
|
||||
'Usage: /model list to show all available models'
|
||||
].join('\n')}</pre>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdMcp(args, ctx) {
|
||||
const res = await fetch(`${API_BASE}/api/mcp/servers`, { credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
slashReply('MCP status is unavailable for this user.');
|
||||
return true;
|
||||
}
|
||||
const servers = await res.json();
|
||||
if (!Array.isArray(servers) || !servers.length) {
|
||||
slashReply('No MCP servers configured.');
|
||||
return true;
|
||||
}
|
||||
const lines = servers.map(s => {
|
||||
const status = s.status || (s.is_enabled ? 'enabled' : 'disabled');
|
||||
const enabled = Number(s.enabled_tool_count ?? s.tool_count ?? 0);
|
||||
const total = Number(s.tool_count ?? enabled);
|
||||
return `${s.name || s.id || 'MCP server'} - ${status} (${enabled}/${total} tools)`;
|
||||
});
|
||||
slashReply(`<pre>${lines.map(line => ctx.esc(line)).join('\n')}</pre>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Memory ──
|
||||
|
||||
async function _cmdMemoryList(args, ctx) {
|
||||
@@ -1507,6 +1625,73 @@ async function _cmdMemorySearch(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Skills ──
|
||||
|
||||
async function _cmdSkills(args, ctx) {
|
||||
const sub = (args[0] || 'list').toLowerCase();
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (sub === 'list' || sub === 'ls') {
|
||||
const skills = await _loadSkillSlashCatalog(true);
|
||||
if (!skills.length) {
|
||||
slashReply('No published skills available for slash commands.');
|
||||
return true;
|
||||
}
|
||||
const lines = skills.map(s => {
|
||||
const uses = Number(s.uses || 0);
|
||||
const useText = uses > 0 ? ` uses:${uses}` : '';
|
||||
return `${ctx.esc(String(s.token || '').padEnd(24))}${ctx.esc(s.help || '')}${useText}`;
|
||||
});
|
||||
slashReply(`<pre>${lines.join('\n')}</pre>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'search' || sub === 'find') {
|
||||
const query = rest.join(' ').trim();
|
||||
if (!query) { slashReply('Usage: /skills search query'); return true; }
|
||||
const res = await fetch(`${API_BASE}/api/skills/search`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query })
|
||||
});
|
||||
if (!res.ok) { slashReply('Skill search failed.'); return true; }
|
||||
const data = await res.json();
|
||||
const skills = Array.isArray(data.skills) ? data.skills : [];
|
||||
if (!skills.length) { slashReply(`No skills found for "${ctx.esc(query)}".`); return true; }
|
||||
const lines = skills.map(s =>
|
||||
ctx.esc(`/${s.name || s.id || ''}`.padEnd(24)) + ctx.esc(s.description || '')
|
||||
);
|
||||
slashReply(`<pre>${lines.join('\n')}</pre>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'view' || sub === 'cat' || sub === 'show') {
|
||||
const name = (rest[0] || '').trim();
|
||||
if (!name) { slashReply('Usage: /skills view name'); return true; }
|
||||
const res = await fetch(`${API_BASE}/api/skills/${encodeURIComponent(name)}/markdown`, { credentials: 'same-origin' });
|
||||
if (!res.ok) { slashReply(`Skill "${ctx.esc(name)}" was not found.`); return true; }
|
||||
const data = await res.json();
|
||||
slashReply(`<pre>${ctx.esc(data.markdown || '')}</pre>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'use' || sub === 'run') {
|
||||
const name = (rest[0] || '').trim();
|
||||
if (!name) { slashReply('Usage: /skills use name request'); return true; }
|
||||
return _invokeSkillByName(name, rest.slice(1).join(' ').trim(), ctx);
|
||||
}
|
||||
|
||||
slashReply('Usage: /skills list | search query | view name | use name request');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdReloadSkills(args, ctx) {
|
||||
const skills = await _loadSkillSlashCatalog(true);
|
||||
slashReply(`Reloaded skills. ${skills.length} skill command${skills.length === 1 ? '' : 's'} available.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Note (quick Notes shortcut) ──
|
||||
|
||||
async function _cmdNote(args, ctx) {
|
||||
@@ -1799,6 +1984,53 @@ Uploads: ${d.uploads || '?'}</pre>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdUsage(args, ctx) {
|
||||
const sid = ctx.sid;
|
||||
if (!sid) {
|
||||
slashReply('No active session.');
|
||||
return true;
|
||||
}
|
||||
|
||||
let session = null;
|
||||
try {
|
||||
const sessions = sessionModule.getSessions ? sessionModule.getSessions() : [];
|
||||
session = (sessions || []).find(s => s.id === sid) || null;
|
||||
if (!session) {
|
||||
const res = await fetch(`${API_BASE}/api/sessions`, { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const items = Array.isArray(data) ? data : (data.sessions || data.items || []);
|
||||
session = items.find(s => s.id === sid) || null;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const model = session?.model || 'Unknown';
|
||||
const endpointUrl = session?.endpoint_url || (
|
||||
sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : ''
|
||||
);
|
||||
const messageCount = Number(session?.message_count || 0);
|
||||
const totalTokens = Number(session?.total_tokens || 0);
|
||||
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
||||
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||
const costLine = costTracked
|
||||
? (cost > 0
|
||||
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||
: 'Estimated local cost: unavailable or zero')
|
||||
: 'Estimated local cost: not tracked for this endpoint';
|
||||
|
||||
slashReply(`<pre>${[
|
||||
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
||||
`Model: ${ctx.esc(model)}`,
|
||||
`Messages: ${messageCount.toLocaleString()}`,
|
||||
`Recorded tokens: ${totalTokens.toLocaleString()}`,
|
||||
costLine,
|
||||
'',
|
||||
'Provider account usage is not available from here; check the provider dashboard for account quota/billing.'
|
||||
].join('\n')}</pre>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Context compaction ──
|
||||
|
||||
async function _cmdCompact(args, ctx) {
|
||||
@@ -4783,39 +5015,53 @@ function _clearSetupCommandInput() {
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub Copilot device-flow sign-in, driven from chat (mirrors the Settings
|
||||
// "Connect GitHub Copilot" button). Replies via the setup guide messages.
|
||||
async function _setupCopilot() {
|
||||
async function _setupProviderDeviceFlow(providerKey) {
|
||||
_clearSetupGuideMessages();
|
||||
await _setupReply('Starting GitHub Copilot sign-in…');
|
||||
let start;
|
||||
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||
if (!config) {
|
||||
await _setupReply('Provider not recognised.');
|
||||
return;
|
||||
}
|
||||
await _setupReply(`Starting ${config.label} sign-in...`);
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/start`, { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await r.json();
|
||||
if (!r.ok) { await _setupReply(start.detail || 'Failed to start Copilot sign-in.'); return; }
|
||||
} catch (e) { await _setupReply('Request failed.'); return; }
|
||||
const authUrl = start.verification_uri_complete || start.verification_uri || '';
|
||||
await _setupReply(`Opening GitHub — approve the request (code ${start.user_code}). Waiting…`);
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
const deadline = Date.now() + (start.expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((start.interval || 5), 2) * 1000;
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { await _setupReply('Copilot sign-in expired — run /setup copilot again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', start.poll_id);
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/poll`, { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
await _setupReply(`Connected — ${n} Copilot model${n !== 1 ? 's' : ''} available.`);
|
||||
if (modelsModule) modelsModule.refreshModels(true);
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { await _setupReply('Copilot sign-in failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
const result = await runProviderDeviceFlow(providerKey, {
|
||||
onStart: async ({ start, authUrl }) => {
|
||||
const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI';
|
||||
const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code';
|
||||
if (providerKey === 'chatgpt-subscription') {
|
||||
slashReply(
|
||||
'<div class="setup-guide-no-censor" style="display:grid;gap:6px;">' +
|
||||
'<div>Open this URL in your browser, enter the code, then come back here. Waiting...</div>' +
|
||||
'<div>Code: <code>' + uiModule.esc(start.user_code || '') + '</code></div>' +
|
||||
'<div><a href="' + uiModule.esc(authUrl || '') + '" target="_blank" rel="noopener noreferrer">' + uiModule.esc(authUrl || '') + '</a></div>' +
|
||||
'</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _setupReply(`Opening ${place} - ${action} (code ${start.user_code}). Waiting...`);
|
||||
},
|
||||
openWindow: (url) => {
|
||||
if (providerKey === 'chatgpt-subscription') return;
|
||||
try { if (url) window.open(url, '_blank', 'noopener'); } catch (e) {}
|
||||
},
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
const n = ((result.endpoint && result.endpoint.models) || []).length;
|
||||
await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`);
|
||||
if (modelsModule) modelsModule.refreshModels(true);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'failed') {
|
||||
await _setupReply(`${config.label} sign-in failed (${result.error || 'denied'}).`);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
await _setupReply(`${config.label} sign-in expired - run /setup ${providerKey} again.`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
await _setupReply(formatDeviceFlowError(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function _cmdSetup(args, ctx) {
|
||||
@@ -4823,7 +5069,11 @@ async function _cmdSetup(args, ctx) {
|
||||
_clearSetupCommandInput();
|
||||
const topic = (args[0] || '').trim().toLowerCase();
|
||||
const topicArgs = args.slice(1);
|
||||
if (topic === 'copilot' || topic === 'github') { await _setupCopilot(); return true; }
|
||||
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(topic);
|
||||
if (deviceAuthProvider) {
|
||||
await _setupProviderDeviceFlow(deviceAuthProvider);
|
||||
return true;
|
||||
}
|
||||
const provider = _setupProviderFromInput(topic);
|
||||
if (provider) {
|
||||
_clearSetupGuideMessages();
|
||||
@@ -5463,8 +5713,20 @@ async function _cmdHelp(args, ctx) {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
const skillCommands = await _loadSkillSlashCatalog(false);
|
||||
if (skillCommands.length) {
|
||||
lines.push('Skills:');
|
||||
for (const skill of skillCommands.slice(0, 20)) {
|
||||
const token = String(skill.token || '').padEnd(21);
|
||||
lines.push(` ${ctx.esc(token)}${ctx.esc(skill.help || '')}`);
|
||||
}
|
||||
if (skillCommands.length > 20) {
|
||||
lines.push(` ... ${skillCommands.length - 20} more. Use /skills list`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('Tip: /<command> --help for details');
|
||||
lines.push('Shortcuts: /new /rename /fork /web /bash /memories /forget');
|
||||
lines.push('Shortcuts: /new /rename /fork /web /bash /memories /skills');
|
||||
slashReply(`<pre style="line-height:1.7">${lines.join('\n')}</pre>`);
|
||||
return true;
|
||||
}
|
||||
@@ -5539,6 +5801,20 @@ const COMMANDS = {
|
||||
'search': { handler: _cmdMemorySearch, alias: ['grep'], help: 'Search memories', usage: '/memory search q' }
|
||||
}
|
||||
},
|
||||
skills: {
|
||||
alias: ['skill'],
|
||||
category: 'Memory',
|
||||
help: 'List, search, inspect, or run skills',
|
||||
handler: _cmdSkills,
|
||||
usage: '/skills list | search query | view name | use name request',
|
||||
},
|
||||
'reload-skills': {
|
||||
alias: ['reload_skills'],
|
||||
category: 'Memory',
|
||||
help: 'Refresh the slash skill catalog',
|
||||
handler: _cmdReloadSkills,
|
||||
usage: '/reload-skills',
|
||||
},
|
||||
rag: {
|
||||
alias: [],
|
||||
category: 'RAG',
|
||||
@@ -5572,7 +5848,7 @@ const COMMANDS = {
|
||||
category: 'Getting started',
|
||||
help: 'Add local or API model endpoints',
|
||||
handler: _cmdSetup,
|
||||
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint',
|
||||
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup chatgpt-subscription',
|
||||
// Provider subs so the autocomplete popup surfaces "/setup deepseek",
|
||||
// "/setup openai", etc. when the user types "/setup de". Each sub's
|
||||
// handler is a thin wrapper that re-prepends the sub name and
|
||||
@@ -5590,6 +5866,7 @@ const COMMANDS = {
|
||||
xai: { help: 'xAI (Grok)', alias: ['grok'], usage: '/setup xai xai-...', handler: (a, c) => _cmdSetup(['xai', ...a], c) },
|
||||
ollama: { help: 'Ollama Cloud', usage: '/setup ollama KEY', handler: (a, c) => _cmdSetup(['ollama', ...a], c) },
|
||||
copilot: { help: 'GitHub Copilot', usage: '/setup copilot', handler: (a, c) => _cmdSetup(['copilot', ...a], c) },
|
||||
'chatgpt-subscription': { help: 'ChatGPT Subscription', alias: ['codex'], usage: '/setup chatgpt-subscription', handler: (a, c) => _cmdSetup(['chatgpt-subscription', ...a], c) },
|
||||
local: { help: 'Local model server (vLLM / LM Studio / llama.cpp / Ollama)',
|
||||
usage: '/setup local http://localhost:8000/v1',
|
||||
handler: (a, c) => _cmdSetup(['local', ...a], c) },
|
||||
@@ -5767,8 +6044,22 @@ const COMMANDS = {
|
||||
handler: (args, ctx) => _cmdToolPanel('compare', args, ctx),
|
||||
usage: '/compare'
|
||||
},
|
||||
mcp: {
|
||||
alias: [],
|
||||
category: 'Tools',
|
||||
help: 'Show MCP server status',
|
||||
handler: _cmdMcp,
|
||||
usage: '/mcp'
|
||||
},
|
||||
model: {
|
||||
alias: [],
|
||||
category: 'Settings',
|
||||
help: 'Show current chat model',
|
||||
handler: _cmdModel,
|
||||
usage: '/model · /model list'
|
||||
},
|
||||
models: {
|
||||
alias: ['model'],
|
||||
alias: [],
|
||||
category: 'Settings',
|
||||
help: 'List available models',
|
||||
handler: _cmdModels,
|
||||
@@ -5799,10 +6090,16 @@ const COMMANDS = {
|
||||
handler: _cmdStats,
|
||||
usage: '/stats'
|
||||
},
|
||||
usage: {
|
||||
alias: ['cost', 'tokens'],
|
||||
category: 'Utility',
|
||||
help: 'Show local usage for the current chat',
|
||||
handler: _cmdUsage,
|
||||
usage: '/usage'
|
||||
},
|
||||
compact: {
|
||||
alias: [],
|
||||
category: 'Utility',
|
||||
hidden: true,
|
||||
help: 'Compact older chat messages',
|
||||
handler: _cmdCompact,
|
||||
usage: '/compact'
|
||||
@@ -6075,33 +6372,13 @@ async function handleSlashCommand(input) {
|
||||
}
|
||||
|
||||
// --- 4. Skill invocation: /<skill-name> [request] ---
|
||||
// If `rawCmd` matches a published skill, pin its SKILL.md to the user's
|
||||
// message and re-submit. Lets you fire a stored procedure on demand
|
||||
// without the model having to discover the skill itself.
|
||||
// If `rawCmd` matches a published skill, the backend records usage and
|
||||
// returns a skill-pinned message to submit as the next agent turn.
|
||||
try {
|
||||
const skillRes = await fetch(`${API_BASE}/api/skills/${encodeURIComponent(rawCmd)}/markdown`, { credentials: 'same-origin' });
|
||||
if (skillRes.ok) {
|
||||
const skillData = await skillRes.json();
|
||||
const md = skillData.markdown || '';
|
||||
if (md) {
|
||||
_showUser();
|
||||
const request = args.join(' ').trim();
|
||||
const msgInput = document.getElementById('message');
|
||||
const composed =
|
||||
`Apply the skill below to my request, following its Procedure / Pitfalls / Verification.\n\n` +
|
||||
`--- BEGIN SKILL ---\n${md}\n--- END SKILL ---\n\n` +
|
||||
(request ? `Request: ${request}` : `Request: (use the skill as appropriate)`);
|
||||
if (msgInput) {
|
||||
msgInput.value = composed;
|
||||
const form = document.getElementById('chat-form');
|
||||
if (form && typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else if (form) {
|
||||
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const catalog = await _loadSkillSlashCatalog(false);
|
||||
if (catalog.some(s => s.name === rawCmd)) {
|
||||
_showUser();
|
||||
return await _invokeSkillByName(rawCmd, args.join(' ').trim(), ctx);
|
||||
}
|
||||
} catch (_) { /* fall through to fuzzy match */ }
|
||||
|
||||
@@ -6158,10 +6435,13 @@ export function initSlashCommands(deps) {
|
||||
const providerEl = e.target.closest('.setup-clickable-provider');
|
||||
if (providerEl) {
|
||||
e.preventDefault();
|
||||
const providerKey = providerEl.dataset.setupProvider || providerEl.textContent.trim();
|
||||
const providerName = providerEl.textContent.trim();
|
||||
const messageInput = document.getElementById('message');
|
||||
if (messageInput) {
|
||||
const text = providerName + ' sk-';
|
||||
const text = providerEl.dataset.setupKind === 'device-auth'
|
||||
? '/setup ' + providerKey
|
||||
: providerName + ' sk-';
|
||||
messageInput.value = text;
|
||||
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
messageInput.focus();
|
||||
|
||||
Reference in New Issue
Block a user