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:
+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