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:
stocky789
2026-06-08 18:19:18 +10:00
committed by GitHub
parent ac94885c84
commit 1e0d9b92af
37 changed files with 3425 additions and 485 deletions
+3
View File
@@ -2108,6 +2108,8 @@
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
<option value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot</option>
<option value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription</option>
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
@@ -2136,6 +2138,7 @@
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
</div>
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
</div>
</div>
</div>
+201 -68
View File
@@ -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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[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
View File
@@ -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,
+128
View File
@@ -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;
}
}
}
+5
View File
@@ -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"],
+49 -3
View File
@@ -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
View File
@@ -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();