promethios-bridge 2.1.8 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bridge.js +534 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promethios-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Run Promethios agent frameworks locally on your computer with full file, terminal, browser access, ambient context capture, and the always-on-top floating chat overlay. Native Framework Mode supports OpenClaw and other frameworks via the bridge.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/bridge.js
CHANGED
|
@@ -485,9 +485,188 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
485
485
|
}
|
|
486
486
|
});
|
|
487
487
|
|
|
488
|
+
|
|
489
|
+
// ── MCP OAuth routes (for the browser-based overlay Providers tab) ──────────
|
|
490
|
+
// These routes implement the same OAuth flow as the Electron mcp-oauth.js,
|
|
491
|
+
// but running entirely inside the Express server so the npx bridge can use it.
|
|
492
|
+
//
|
|
493
|
+
// Token storage: in-memory map (resets on bridge restart).
|
|
494
|
+
// For persistence across restarts, tokens are also saved to ~/.promethios/mcp-tokens.json
|
|
495
|
+
const path = require('path');
|
|
496
|
+
const fs = require('fs');
|
|
497
|
+
const http = require('http');
|
|
498
|
+
const { URL: NodeURL } = require('url');
|
|
499
|
+
const crypto = require('crypto');
|
|
500
|
+
|
|
501
|
+
const MCP_TOKENS_PATH = path.join(
|
|
502
|
+
process.env.HOME || process.env.USERPROFILE || require('os').homedir(),
|
|
503
|
+
'.promethios', 'mcp-tokens.json'
|
|
504
|
+
);
|
|
505
|
+
function loadMcpTokens() {
|
|
506
|
+
try {
|
|
507
|
+
fs.mkdirSync(path.dirname(MCP_TOKENS_PATH), { recursive: true });
|
|
508
|
+
return JSON.parse(fs.readFileSync(MCP_TOKENS_PATH, 'utf8'));
|
|
509
|
+
} catch { return {}; }
|
|
510
|
+
}
|
|
511
|
+
function saveMcpTokens(tokens) {
|
|
512
|
+
try {
|
|
513
|
+
fs.mkdirSync(path.dirname(MCP_TOKENS_PATH), { recursive: true });
|
|
514
|
+
fs.writeFileSync(MCP_TOKENS_PATH, JSON.stringify(tokens, null, 2), 'utf8');
|
|
515
|
+
} catch (err) { log('[mcp-tokens] save failed:', err.message); }
|
|
516
|
+
}
|
|
517
|
+
const mcpTokens = loadMcpTokens(); // { manus: 'tok_...', claude: 'tok_...', ... }
|
|
518
|
+
|
|
519
|
+
const PROVIDER_NAMES = {
|
|
520
|
+
manus: 'Manus', claude: 'Claude', chatgpt: 'ChatGPT',
|
|
521
|
+
gemini: 'Gemini', perplexity: 'Perplexity',
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// Pending OAuth servers: clientId → { server, resolve, reject }
|
|
525
|
+
const _pendingOAuth = new Map();
|
|
526
|
+
let _oauthCallbackPort = 7826;
|
|
527
|
+
|
|
528
|
+
// GET /mcp-oauth-status — return connected providers
|
|
529
|
+
app.get('/mcp-oauth-status', (req, res) => {
|
|
530
|
+
const tokens = {};
|
|
531
|
+
for (const [id, tok] of Object.entries(mcpTokens)) {
|
|
532
|
+
tokens[id] = { connected: true, tokenPrefix: tok.slice(0, 16) + '...' };
|
|
533
|
+
}
|
|
534
|
+
res.json({ ok: true, tokens });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// POST /mcp-oauth-start — start OAuth flow for a provider
|
|
538
|
+
// Body: { clientId: 'manus' }
|
|
539
|
+
// Opens the consent page in the system browser and waits for the callback.
|
|
540
|
+
app.post('/mcp-oauth-start', async (req, res) => {
|
|
541
|
+
const { clientId } = req.body || {};
|
|
542
|
+
if (!clientId) return res.status(400).json({ ok: false, error: 'Missing clientId' });
|
|
543
|
+
// Close any existing pending server for this client
|
|
544
|
+
if (_pendingOAuth.has(clientId)) {
|
|
545
|
+
try { _pendingOAuth.get(clientId).server.close(); } catch {}
|
|
546
|
+
_pendingOAuth.delete(clientId);
|
|
547
|
+
}
|
|
548
|
+
const cbPort = _oauthCallbackPort++;
|
|
549
|
+
const redirectUri = `http://localhost:${cbPort}/callback`;
|
|
550
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
551
|
+
const authorizeUrl = new NodeURL(`${apiBase}/api/mcp/oauth/authorize`);
|
|
552
|
+
authorizeUrl.searchParams.set('client_id', clientId);
|
|
553
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
554
|
+
authorizeUrl.searchParams.set('state', state);
|
|
555
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
556
|
+
if (authToken) authorizeUrl.searchParams.set('bridge_token', authToken);
|
|
557
|
+
const successHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Connected</title>
|
|
558
|
+
<style>body{font-family:system-ui,sans-serif;background:#0f0f14;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:16px;}
|
|
559
|
+
.check{width:56px;height:56px;background:rgba(139,92,246,.15);border:2px solid rgba(139,92,246,.4);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:24px;}
|
|
560
|
+
h1{font-size:20px;font-weight:700;margin:0;}p{font-size:13px;color:rgba(255,255,255,.45);margin:0;text-align:center;}</style></head>
|
|
561
|
+
<body><div class="check">✓</div><h1>Connected to Promethios!</h1><p>Authorization complete.<br>You can close this tab and return to the overlay.</p></body></html>`;
|
|
562
|
+
const errorHtml = (msg) => `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Failed</title>
|
|
563
|
+
<style>body{font-family:system-ui,sans-serif;background:#0f0f14;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:16px;}
|
|
564
|
+
.x{width:56px;height:56px;background:rgba(239,68,68,.1);border:2px solid rgba(239,68,68,.3);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:24px;color:#ef4444;}
|
|
565
|
+
h1{font-size:20px;font-weight:700;margin:0;}p{font-size:13px;color:rgba(255,255,255,.45);margin:0;text-align:center;}</style></head>
|
|
566
|
+
<body><div class="x">✗</div><h1>Connection Failed</h1><p>${msg}<br>Please close this tab and try again.</p></body></html>`;
|
|
567
|
+
const server = http.createServer(async (cbReq, cbRes) => {
|
|
568
|
+
const reqUrl = new NodeURL(cbReq.url, `http://localhost:${cbPort}`);
|
|
569
|
+
if (reqUrl.pathname !== '/callback') { cbRes.writeHead(404); cbRes.end('Not found'); return; }
|
|
570
|
+
const code = reqUrl.searchParams.get('code');
|
|
571
|
+
const retState = reqUrl.searchParams.get('state');
|
|
572
|
+
const token = reqUrl.searchParams.get('token') || reqUrl.searchParams.get('access_token');
|
|
573
|
+
const error = reqUrl.searchParams.get('error');
|
|
574
|
+
try { server.close(); } catch {}
|
|
575
|
+
_pendingOAuth.delete(clientId);
|
|
576
|
+
if (error) {
|
|
577
|
+
cbRes.writeHead(200, { 'Content-Type': 'text/html' }); cbRes.end(errorHtml(error));
|
|
578
|
+
return res.json({ ok: false, error: `Authorization denied: ${error}` });
|
|
579
|
+
}
|
|
580
|
+
if (token) {
|
|
581
|
+
cbRes.writeHead(200, { 'Content-Type': 'text/html' }); cbRes.end(successHtml);
|
|
582
|
+
mcpTokens[clientId] = token;
|
|
583
|
+
saveMcpTokens(mcpTokens);
|
|
584
|
+
return res.json({ ok: true, providerName: PROVIDER_NAMES[clientId] || clientId });
|
|
585
|
+
}
|
|
586
|
+
if (!code) {
|
|
587
|
+
cbRes.writeHead(400, { 'Content-Type': 'text/html' }); cbRes.end(errorHtml('No authorization code received'));
|
|
588
|
+
return res.json({ ok: false, error: 'No authorization code received' });
|
|
589
|
+
}
|
|
590
|
+
if (retState !== state) {
|
|
591
|
+
cbRes.writeHead(400, { 'Content-Type': 'text/html' }); cbRes.end(errorHtml('State mismatch'));
|
|
592
|
+
return res.json({ ok: false, error: 'State mismatch — possible CSRF' });
|
|
593
|
+
}
|
|
594
|
+
cbRes.writeHead(200, { 'Content-Type': 'text/html' }); cbRes.end(successHtml);
|
|
595
|
+
try {
|
|
596
|
+
const tokenRes = await fetch(`${apiBase}/api/mcp/oauth/token`, {
|
|
597
|
+
method: 'POST',
|
|
598
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
599
|
+
body: new URLSearchParams({
|
|
600
|
+
grant_type: 'authorization_code',
|
|
601
|
+
code,
|
|
602
|
+
client_id: clientId,
|
|
603
|
+
redirect_uri: redirectUri,
|
|
604
|
+
}).toString(),
|
|
605
|
+
});
|
|
606
|
+
const tokenData = await tokenRes.json();
|
|
607
|
+
if (!tokenRes.ok || !tokenData.access_token) {
|
|
608
|
+
const msg = tokenData.error_description || tokenData.error || 'Token exchange failed';
|
|
609
|
+
return res.json({ ok: false, error: msg });
|
|
610
|
+
}
|
|
611
|
+
const tok = tokenData.access_token;
|
|
612
|
+
mcpTokens[clientId] = tok;
|
|
613
|
+
saveMcpTokens(mcpTokens);
|
|
614
|
+
res.json({ ok: true, providerName: PROVIDER_NAMES[clientId] || clientId });
|
|
615
|
+
} catch (err) {
|
|
616
|
+
res.json({ ok: false, error: err.message });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
server.on('error', (err) => {
|
|
620
|
+
_pendingOAuth.delete(clientId);
|
|
621
|
+
res.json({ ok: false, error: 'OAuth callback server error: ' + err.message });
|
|
622
|
+
});
|
|
623
|
+
_pendingOAuth.set(clientId, { server });
|
|
624
|
+
server.listen(cbPort, '127.0.0.1', () => {
|
|
625
|
+
// Open system browser
|
|
626
|
+
const { exec } = require('child_process');
|
|
627
|
+
const platform = process.platform;
|
|
628
|
+
const url = authorizeUrl.toString();
|
|
629
|
+
let cmd;
|
|
630
|
+
if (platform === 'win32') cmd = `start "" "${url}"`;
|
|
631
|
+
else if (platform === 'darwin') cmd = `open "${url}"`;
|
|
632
|
+
else cmd = `xdg-open "${url}"`;
|
|
633
|
+
exec(cmd, (err) => { if (err) log('[mcp-oauth] open browser failed:', err.message); });
|
|
634
|
+
log('[mcp-oauth] Opened browser for', clientId, '→', url);
|
|
635
|
+
// Auto-timeout after 5 minutes
|
|
636
|
+
setTimeout(() => {
|
|
637
|
+
if (_pendingOAuth.has(clientId)) {
|
|
638
|
+
try { server.close(); } catch {}
|
|
639
|
+
_pendingOAuth.delete(clientId);
|
|
640
|
+
if (!res.headersSent) res.json({ ok: false, error: 'Authorization timed out — please try again' });
|
|
641
|
+
}
|
|
642
|
+
}, 5 * 60 * 1000);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// POST /mcp-oauth-disconnect — remove stored token for a provider
|
|
647
|
+
app.post('/mcp-oauth-disconnect', async (req, res) => {
|
|
648
|
+
const { clientId } = req.body || {};
|
|
649
|
+
if (!clientId) return res.status(400).json({ ok: false, error: 'Missing clientId' });
|
|
650
|
+
const tok = mcpTokens[clientId];
|
|
651
|
+
if (tok) {
|
|
652
|
+
// Best-effort server-side revocation
|
|
653
|
+
try {
|
|
654
|
+
await fetch(`${apiBase}/api/mcp/oauth/revoke`, {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
657
|
+
body: new URLSearchParams({ token: tok, client_id: clientId }).toString(),
|
|
658
|
+
});
|
|
659
|
+
} catch {}
|
|
660
|
+
delete mcpTokens[clientId];
|
|
661
|
+
saveMcpTokens(mcpTokens);
|
|
662
|
+
}
|
|
663
|
+
res.json({ ok: true });
|
|
664
|
+
});
|
|
665
|
+
|
|
488
666
|
app.get('/overlay', (req, res) => {
|
|
489
667
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
490
|
-
// NOTE: The overlay HTML talks to /chat-proxy, /models-proxy, /set-model-proxy
|
|
668
|
+
// NOTE: The overlay HTML talks to /chat-proxy, /models-proxy, /set-model-proxy,
|
|
669
|
+
// /mcp-oauth-start, /mcp-oauth-status, /mcp-oauth-disconnect
|
|
491
670
|
// (all same origin, no CORS). It never calls the remote API directly.
|
|
492
671
|
res.send(`<!DOCTYPE html>
|
|
493
672
|
<html lang="en">
|
|
@@ -533,7 +712,29 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
533
712
|
background: #1e1030; border: 1px solid #3b1f6b;
|
|
534
713
|
border-radius: 4px; padding: 1px 6px;
|
|
535
714
|
}
|
|
536
|
-
/* ──
|
|
715
|
+
/* ── Tab bar ── */
|
|
716
|
+
#tab-bar {
|
|
717
|
+
display: flex;
|
|
718
|
+
background: #111113;
|
|
719
|
+
border-bottom: 1px solid #1f1f23;
|
|
720
|
+
flex-shrink: 0;
|
|
721
|
+
}
|
|
722
|
+
.tab-btn {
|
|
723
|
+
flex: 1;
|
|
724
|
+
padding: 9px 0;
|
|
725
|
+
font-size: 12px;
|
|
726
|
+
font-weight: 500;
|
|
727
|
+
color: #52525b;
|
|
728
|
+
background: none;
|
|
729
|
+
border: none;
|
|
730
|
+
border-bottom: 2px solid transparent;
|
|
731
|
+
cursor: pointer;
|
|
732
|
+
transition: color 0.15s, border-color 0.15s;
|
|
733
|
+
letter-spacing: 0.01em;
|
|
734
|
+
}
|
|
735
|
+
.tab-btn:hover { color: #a1a1aa; }
|
|
736
|
+
.tab-btn.active { color: #a855f7; border-bottom-color: #7c3aed; }
|
|
737
|
+
/* ── Model selector (in header) ── */
|
|
537
738
|
#model-wrap {
|
|
538
739
|
margin-left: auto;
|
|
539
740
|
position: relative;
|
|
@@ -602,7 +803,10 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
602
803
|
background: #22c55e; flex-shrink: 0;
|
|
603
804
|
box-shadow: 0 0 6px #22c55e88;
|
|
604
805
|
}
|
|
605
|
-
/* ──
|
|
806
|
+
/* ── Tab panels ── */
|
|
807
|
+
.tab-panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
|
|
808
|
+
.tab-panel.active { display: flex; }
|
|
809
|
+
/* ── Chat panel ── */
|
|
606
810
|
#messages {
|
|
607
811
|
flex: 1;
|
|
608
812
|
overflow-y: auto;
|
|
@@ -701,6 +905,143 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
701
905
|
}
|
|
702
906
|
#send:hover { background: #6d28d9; }
|
|
703
907
|
#send:disabled { background: #27272a; cursor: not-allowed; }
|
|
908
|
+
/* ── Providers tab ── */
|
|
909
|
+
#providers-panel {
|
|
910
|
+
flex: 1;
|
|
911
|
+
overflow-y: auto;
|
|
912
|
+
padding: 14px 12px;
|
|
913
|
+
display: flex;
|
|
914
|
+
flex-direction: column;
|
|
915
|
+
gap: 8px;
|
|
916
|
+
}
|
|
917
|
+
#providers-panel::-webkit-scrollbar { width: 4px; }
|
|
918
|
+
#providers-panel::-webkit-scrollbar-track { background: transparent; }
|
|
919
|
+
#providers-panel::-webkit-scrollbar-thumb { background: #27272a; border-radius: 2px; }
|
|
920
|
+
.providers-heading {
|
|
921
|
+
font-size: 11px;
|
|
922
|
+
font-weight: 600;
|
|
923
|
+
color: #52525b;
|
|
924
|
+
text-transform: uppercase;
|
|
925
|
+
letter-spacing: 0.07em;
|
|
926
|
+
padding: 4px 2px 8px;
|
|
927
|
+
}
|
|
928
|
+
.provider-row {
|
|
929
|
+
background: #111113;
|
|
930
|
+
border: 1px solid #1f1f23;
|
|
931
|
+
border-radius: 12px;
|
|
932
|
+
padding: 12px 14px;
|
|
933
|
+
display: flex;
|
|
934
|
+
align-items: center;
|
|
935
|
+
gap: 12px;
|
|
936
|
+
transition: border-color 0.15s;
|
|
937
|
+
}
|
|
938
|
+
.provider-row:hover { border-color: #27272a; }
|
|
939
|
+
.provider-row.connected { border-color: #1a3a2a; background: #0d1f17; }
|
|
940
|
+
.provider-icon {
|
|
941
|
+
font-size: 22px;
|
|
942
|
+
width: 36px; height: 36px;
|
|
943
|
+
display: flex; align-items: center; justify-content: center;
|
|
944
|
+
flex-shrink: 0;
|
|
945
|
+
background: #18181b;
|
|
946
|
+
border-radius: 8px;
|
|
947
|
+
border: 1px solid #27272a;
|
|
948
|
+
}
|
|
949
|
+
.provider-info { flex: 1; min-width: 0; }
|
|
950
|
+
.provider-name {
|
|
951
|
+
font-size: 13px;
|
|
952
|
+
font-weight: 600;
|
|
953
|
+
color: #e4e4e7;
|
|
954
|
+
margin-bottom: 2px;
|
|
955
|
+
}
|
|
956
|
+
.provider-desc {
|
|
957
|
+
font-size: 11px;
|
|
958
|
+
color: #52525b;
|
|
959
|
+
white-space: nowrap;
|
|
960
|
+
overflow: hidden;
|
|
961
|
+
text-overflow: ellipsis;
|
|
962
|
+
}
|
|
963
|
+
.provider-desc.connected-text { color: #22c55e; }
|
|
964
|
+
.provider-actions {
|
|
965
|
+
display: flex;
|
|
966
|
+
gap: 6px;
|
|
967
|
+
flex-shrink: 0;
|
|
968
|
+
}
|
|
969
|
+
.btn-open {
|
|
970
|
+
background: #18181b;
|
|
971
|
+
border: 1px solid #27272a;
|
|
972
|
+
border-radius: 7px;
|
|
973
|
+
color: #a1a1aa;
|
|
974
|
+
font-size: 11px;
|
|
975
|
+
font-weight: 500;
|
|
976
|
+
padding: 5px 10px;
|
|
977
|
+
cursor: pointer;
|
|
978
|
+
white-space: nowrap;
|
|
979
|
+
transition: border-color 0.15s, color 0.15s;
|
|
980
|
+
text-decoration: none;
|
|
981
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
982
|
+
}
|
|
983
|
+
.btn-open:hover { border-color: #7c3aed; color: #e4e4e7; }
|
|
984
|
+
.btn-connect {
|
|
985
|
+
background: #1e1030;
|
|
986
|
+
border: 1px solid #3b1f6b;
|
|
987
|
+
border-radius: 7px;
|
|
988
|
+
color: #a855f7;
|
|
989
|
+
font-size: 11px;
|
|
990
|
+
font-weight: 500;
|
|
991
|
+
padding: 5px 10px;
|
|
992
|
+
cursor: pointer;
|
|
993
|
+
white-space: nowrap;
|
|
994
|
+
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
|
995
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
996
|
+
}
|
|
997
|
+
.btn-connect:hover:not(:disabled) { background: #2d1a4a; border-color: #7c3aed; }
|
|
998
|
+
.btn-connect:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
999
|
+
.btn-connect.connected-state {
|
|
1000
|
+
background: #0d1f17;
|
|
1001
|
+
border-color: #1a3a2a;
|
|
1002
|
+
color: #22c55e;
|
|
1003
|
+
}
|
|
1004
|
+
.btn-connect.connecting { opacity: 0.7; cursor: wait; }
|
|
1005
|
+
.connect-dot {
|
|
1006
|
+
width: 6px; height: 6px; border-radius: 50%;
|
|
1007
|
+
background: currentColor;
|
|
1008
|
+
display: inline-block;
|
|
1009
|
+
}
|
|
1010
|
+
.providers-footer {
|
|
1011
|
+
margin-top: 8px;
|
|
1012
|
+
padding: 10px 12px;
|
|
1013
|
+
background: #0d0d10;
|
|
1014
|
+
border: 1px solid #1f1f23;
|
|
1015
|
+
border-radius: 10px;
|
|
1016
|
+
font-size: 11px;
|
|
1017
|
+
color: #3f3f46;
|
|
1018
|
+
line-height: 1.6;
|
|
1019
|
+
}
|
|
1020
|
+
.providers-footer a { color: #7c3aed; text-decoration: none; }
|
|
1021
|
+
.providers-footer a:hover { text-decoration: underline; }
|
|
1022
|
+
/* ── Toast notification ── */
|
|
1023
|
+
#toast {
|
|
1024
|
+
position: fixed;
|
|
1025
|
+
bottom: 16px;
|
|
1026
|
+
left: 50%;
|
|
1027
|
+
transform: translateX(-50%) translateY(20px);
|
|
1028
|
+
background: #1a1a24;
|
|
1029
|
+
border: 1px solid #2a2a3a;
|
|
1030
|
+
border-radius: 10px;
|
|
1031
|
+
padding: 9px 16px;
|
|
1032
|
+
font-size: 12px;
|
|
1033
|
+
color: #e4e4e7;
|
|
1034
|
+
z-index: 9999;
|
|
1035
|
+
opacity: 0;
|
|
1036
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
1037
|
+
pointer-events: none;
|
|
1038
|
+
white-space: nowrap;
|
|
1039
|
+
max-width: 90vw;
|
|
1040
|
+
text-align: center;
|
|
1041
|
+
}
|
|
1042
|
+
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
1043
|
+
#toast.error { border-color: #7f1d1d; background: #1a0d0d; color: #fca5a5; }
|
|
1044
|
+
#toast.success { border-color: #14532d; background: #0d1f17; color: #86efac; }
|
|
704
1045
|
</style>
|
|
705
1046
|
</head>
|
|
706
1047
|
<body>
|
|
@@ -714,19 +1055,38 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
714
1055
|
</div>
|
|
715
1056
|
<div id="status-dot"></div>
|
|
716
1057
|
</div>
|
|
717
|
-
<div id="
|
|
718
|
-
<
|
|
1058
|
+
<div id="tab-bar">
|
|
1059
|
+
<button class="tab-btn active" data-tab="chat">Chat</button>
|
|
1060
|
+
<button class="tab-btn" data-tab="providers">Providers</button>
|
|
1061
|
+
</div>
|
|
1062
|
+
<!-- Chat tab -->
|
|
1063
|
+
<div id="tab-chat" class="tab-panel active">
|
|
1064
|
+
<div id="messages">
|
|
1065
|
+
<div class="msg system">Your computer is connected. Ask me anything.</div>
|
|
1066
|
+
</div>
|
|
1067
|
+
<div id="input-row">
|
|
1068
|
+
<textarea id="input" placeholder="Ask Promethios..." rows="1"></textarea>
|
|
1069
|
+
<button id="send" title="Send (Enter)">▲</button>
|
|
1070
|
+
</div>
|
|
719
1071
|
</div>
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
<
|
|
1072
|
+
<!-- Providers tab -->
|
|
1073
|
+
<div id="tab-providers" class="tab-panel">
|
|
1074
|
+
<div id="providers-panel">
|
|
1075
|
+
<div class="providers-heading">AI Platforms</div>
|
|
1076
|
+
<!-- Rows injected by JS -->
|
|
1077
|
+
<div id="provider-list"></div>
|
|
1078
|
+
<div class="providers-footer">
|
|
1079
|
+
Connect an AI platform to give it access to your local tools via MCP.<br>
|
|
1080
|
+
Click <strong>Open ↗</strong> to open the platform, then <strong>Connect MCP</strong> to authorize.
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
723
1083
|
</div>
|
|
1084
|
+
<div id="toast"></div>
|
|
724
1085
|
<script>
|
|
725
1086
|
const BASE = 'http://127.0.0.1:${port}';
|
|
726
1087
|
const PROXY = BASE + '/chat-proxy';
|
|
727
1088
|
const MODELS_URL = BASE + '/models-proxy';
|
|
728
1089
|
const SET_MODEL = BASE + '/set-model-proxy';
|
|
729
|
-
|
|
730
1090
|
const messagesEl = document.getElementById('messages');
|
|
731
1091
|
const inputEl = document.getElementById('input');
|
|
732
1092
|
const sendBtn = document.getElementById('send');
|
|
@@ -734,19 +1094,15 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
734
1094
|
const modelBtn = document.getElementById('model-btn');
|
|
735
1095
|
const modelLabel = document.getElementById('model-label');
|
|
736
1096
|
const modelDropdown = document.getElementById('model-dropdown');
|
|
737
|
-
|
|
738
1097
|
// ── Conversation history (multi-turn context) ──────────────────────────────
|
|
739
1098
|
const conversationHistory = [];
|
|
740
|
-
|
|
741
1099
|
// ── Current model state ────────────────────────────────────────────────────
|
|
742
1100
|
let currentModel = { provider: null, modelId: null, modelName: null };
|
|
743
|
-
|
|
744
1101
|
// ── Status helpers ─────────────────────────────────────────────────────────
|
|
745
1102
|
function setStatus(ok) {
|
|
746
1103
|
statusDot.style.background = ok ? '#22c55e' : '#f59e0b';
|
|
747
1104
|
statusDot.style.boxShadow = ok ? '0 0 6px #22c55e88' : '0 0 6px #f59e0b88';
|
|
748
1105
|
}
|
|
749
|
-
|
|
750
1106
|
function addMsg(role, text) {
|
|
751
1107
|
const d = document.createElement('div');
|
|
752
1108
|
d.className = 'msg ' + role;
|
|
@@ -755,20 +1111,34 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
755
1111
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
756
1112
|
return d;
|
|
757
1113
|
}
|
|
758
|
-
|
|
1114
|
+
// ── Toast ──────────────────────────────────────────────────────────────────
|
|
1115
|
+
let _toastTimer = null;
|
|
1116
|
+
function showToast(msg, type = '') {
|
|
1117
|
+
const el = document.getElementById('toast');
|
|
1118
|
+
el.textContent = msg;
|
|
1119
|
+
el.className = 'show' + (type ? ' ' + type : '');
|
|
1120
|
+
clearTimeout(_toastTimer);
|
|
1121
|
+
_toastTimer = setTimeout(() => { el.className = ''; }, 3200);
|
|
1122
|
+
}
|
|
1123
|
+
// ── Tab switching ──────────────────────────────────────────────────────────
|
|
1124
|
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
1125
|
+
btn.addEventListener('click', () => {
|
|
1126
|
+
const tab = btn.dataset.tab;
|
|
1127
|
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn));
|
|
1128
|
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + tab));
|
|
1129
|
+
if (tab === 'providers') loadProviders();
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
759
1132
|
// ── Model switcher ─────────────────────────────────────────────────────────
|
|
760
1133
|
async function loadModels() {
|
|
761
1134
|
try {
|
|
762
1135
|
const res = await fetch(MODELS_URL);
|
|
763
1136
|
if (!res.ok) { modelLabel.textContent = 'No model'; return; }
|
|
764
1137
|
const data = await res.json();
|
|
765
|
-
// data = { current: { provider, modelId, modelName } | null, groups: [{ provider, label, models }] }
|
|
766
|
-
|
|
767
1138
|
if (data.current) {
|
|
768
1139
|
currentModel = { provider: data.current.provider, modelId: data.current.modelId, modelName: data.current.modelName };
|
|
769
1140
|
modelLabel.textContent = data.current.modelName || data.current.modelId;
|
|
770
1141
|
} else if (data.groups?.length) {
|
|
771
|
-
// Pick first available model as default display
|
|
772
1142
|
const first = data.groups[0];
|
|
773
1143
|
const firstModel = first.models?.[0];
|
|
774
1144
|
if (firstModel) {
|
|
@@ -778,8 +1148,6 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
778
1148
|
} else {
|
|
779
1149
|
modelLabel.textContent = 'No API key';
|
|
780
1150
|
}
|
|
781
|
-
|
|
782
|
-
// Build dropdown
|
|
783
1151
|
modelDropdown.innerHTML = '';
|
|
784
1152
|
if (!data.groups?.length) {
|
|
785
1153
|
modelDropdown.innerHTML = '<div class="model-option" style="color:#52525b;cursor:default">No API keys configured</div>';
|
|
@@ -799,67 +1167,183 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
799
1167
|
modelDropdown.appendChild(opt);
|
|
800
1168
|
}
|
|
801
1169
|
}
|
|
802
|
-
} catch
|
|
803
|
-
modelLabel.textContent = 'Error';
|
|
804
|
-
}
|
|
1170
|
+
} catch { modelLabel.textContent = 'Error'; }
|
|
805
1171
|
}
|
|
806
|
-
|
|
807
1172
|
async function selectModel(provider, modelId, modelName) {
|
|
808
|
-
currentModel = { provider, modelId, modelName };
|
|
809
|
-
modelLabel.textContent = modelName;
|
|
810
|
-
modelDropdown.classList.remove('open');
|
|
811
|
-
// Persist to Promethios (syncs with main UI)
|
|
812
1173
|
try {
|
|
813
|
-
await fetch(SET_MODEL, {
|
|
1174
|
+
const res = await fetch(SET_MODEL, {
|
|
814
1175
|
method: 'POST',
|
|
815
1176
|
headers: { 'Content-Type': 'application/json' },
|
|
816
|
-
body: JSON.stringify({ provider, modelId
|
|
1177
|
+
body: JSON.stringify({ provider, modelId }),
|
|
817
1178
|
});
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1179
|
+
if (res.ok) {
|
|
1180
|
+
currentModel = { provider, modelId, modelName };
|
|
1181
|
+
modelLabel.textContent = modelName;
|
|
1182
|
+
modelDropdown.classList.remove('open');
|
|
1183
|
+
await loadModels();
|
|
1184
|
+
}
|
|
1185
|
+
} catch {}
|
|
821
1186
|
}
|
|
822
|
-
|
|
823
1187
|
modelBtn.addEventListener('click', (e) => {
|
|
824
1188
|
e.stopPropagation();
|
|
825
1189
|
modelDropdown.classList.toggle('open');
|
|
826
1190
|
});
|
|
827
1191
|
document.addEventListener('click', () => modelDropdown.classList.remove('open'));
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1192
|
+
// ── Providers tab ──────────────────────────────────────────────────────────
|
|
1193
|
+
const PROVIDERS = [
|
|
1194
|
+
{ id: 'manus', name: 'Manus', icon: '🤖', desc: 'AI agent platform', url: 'https://manus.im' },
|
|
1195
|
+
{ id: 'claude', name: 'Claude', icon: '🧠', desc: 'Anthropic AI assistant', url: 'https://claude.ai' },
|
|
1196
|
+
{ id: 'chatgpt', name: 'ChatGPT', icon: '💬', desc: 'OpenAI ChatGPT', url: 'https://chatgpt.com' },
|
|
1197
|
+
{ id: 'gemini', name: 'Gemini', icon: '✨', desc: 'Google Gemini', url: 'https://gemini.google.com' },
|
|
1198
|
+
{ id: 'perplexity', name: 'Perplexity', icon: '🔍', desc: 'Perplexity AI', url: 'https://perplexity.ai' },
|
|
1199
|
+
];
|
|
1200
|
+
// Track which providers the user has opened (enables Connect MCP button)
|
|
1201
|
+
const openedProviders = new Set(JSON.parse(localStorage.getItem('prom_opened') || '[]'));
|
|
1202
|
+
// Track connected providers (from server)
|
|
1203
|
+
let connectedProviders = {};
|
|
1204
|
+
// Track in-progress connections
|
|
1205
|
+
const connectingProviders = new Set();
|
|
1206
|
+
async function loadProviders() {
|
|
1207
|
+
try {
|
|
1208
|
+
const res = await fetch(BASE + '/mcp-oauth-status');
|
|
1209
|
+
if (res.ok) {
|
|
1210
|
+
const data = await res.json();
|
|
1211
|
+
connectedProviders = data.tokens || {};
|
|
1212
|
+
}
|
|
1213
|
+
} catch {}
|
|
1214
|
+
renderProviders();
|
|
1215
|
+
}
|
|
1216
|
+
function renderProviders() {
|
|
1217
|
+
const list = document.getElementById('provider-list');
|
|
1218
|
+
list.innerHTML = '';
|
|
1219
|
+
for (const p of PROVIDERS) {
|
|
1220
|
+
const isConnected = !!connectedProviders[p.id];
|
|
1221
|
+
const isOpened = openedProviders.has(p.id);
|
|
1222
|
+
const isConnecting = connectingProviders.has(p.id);
|
|
1223
|
+
const row = document.createElement('div');
|
|
1224
|
+
row.className = 'provider-row' + (isConnected ? ' connected' : '');
|
|
1225
|
+
row.dataset.id = p.id;
|
|
1226
|
+
const connectLabel = isConnected ? '✓ Connected' : (isConnecting ? 'Connecting...' : 'Connect MCP');
|
|
1227
|
+
const connectClass = 'btn-connect' +
|
|
1228
|
+
(isConnected ? ' connected-state' : '') +
|
|
1229
|
+
(isConnecting ? ' connecting' : '');
|
|
1230
|
+
row.innerHTML =
|
|
1231
|
+
'<div class="provider-icon">' + p.icon + '</div>' +
|
|
1232
|
+
'<div class="provider-info">' +
|
|
1233
|
+
'<div class="provider-name">' + p.name + '</div>' +
|
|
1234
|
+
'<div class="provider-desc' + (isConnected ? ' connected-text' : '') + '">' +
|
|
1235
|
+
(isConnected ? 'MCP connected · tools available' : p.desc) +
|
|
1236
|
+
'</div>' +
|
|
1237
|
+
'</div>' +
|
|
1238
|
+
'<div class="provider-actions">' +
|
|
1239
|
+
'<button class="btn-open" data-url="' + p.url + '" data-id="' + p.id + '">Open ↗</button>' +
|
|
1240
|
+
'<button class="' + connectClass + '"' +
|
|
1241
|
+
(!isOpened && !isConnected ? ' disabled' : '') +
|
|
1242
|
+
(isConnecting ? ' disabled' : '') +
|
|
1243
|
+
' data-id="' + p.id + '">' +
|
|
1244
|
+
connectLabel +
|
|
1245
|
+
'</button>' +
|
|
1246
|
+
'</div>';
|
|
1247
|
+
list.appendChild(row);
|
|
1248
|
+
}
|
|
1249
|
+
// Attach event listeners
|
|
1250
|
+
list.querySelectorAll('.btn-open').forEach(btn => {
|
|
1251
|
+
btn.addEventListener('click', () => {
|
|
1252
|
+
const id = btn.dataset.id;
|
|
1253
|
+
const url = btn.dataset.url;
|
|
1254
|
+
// Mark as opened so Connect MCP becomes enabled
|
|
1255
|
+
openedProviders.add(id);
|
|
1256
|
+
localStorage.setItem('prom_opened', JSON.stringify([...openedProviders]));
|
|
1257
|
+
// Open via bridge's open-external endpoint (uses system default browser)
|
|
1258
|
+
fetch(BASE + '/open-external', {
|
|
1259
|
+
method: 'POST',
|
|
1260
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1261
|
+
body: JSON.stringify({ url }),
|
|
1262
|
+
}).catch(() => {
|
|
1263
|
+
// Fallback: open directly
|
|
1264
|
+
window.open(url, '_blank');
|
|
1265
|
+
});
|
|
1266
|
+
renderProviders();
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
list.querySelectorAll('.btn-connect').forEach(btn => {
|
|
1270
|
+
if (btn.disabled) return;
|
|
1271
|
+
const id = btn.dataset.id;
|
|
1272
|
+
if (btn.classList.contains('connected-state')) {
|
|
1273
|
+
// Disconnect
|
|
1274
|
+
btn.addEventListener('click', () => disconnectProvider(id));
|
|
1275
|
+
} else {
|
|
1276
|
+
btn.addEventListener('click', () => connectProvider(id));
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
async function connectProvider(id) {
|
|
1281
|
+
if (connectingProviders.has(id)) return;
|
|
1282
|
+
connectingProviders.add(id);
|
|
1283
|
+
renderProviders();
|
|
1284
|
+
showToast('Opening browser for authorization...', '');
|
|
1285
|
+
try {
|
|
1286
|
+
const res = await fetch(BASE + '/mcp-oauth-start', {
|
|
1287
|
+
method: 'POST',
|
|
1288
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1289
|
+
body: JSON.stringify({ clientId: id }),
|
|
1290
|
+
});
|
|
1291
|
+
const data = await res.json();
|
|
1292
|
+
if (data.ok) {
|
|
1293
|
+
connectedProviders[id] = { connected: true };
|
|
1294
|
+
showToast(data.providerName + ' connected!', 'success');
|
|
1295
|
+
} else {
|
|
1296
|
+
showToast('Connection failed: ' + (data.error || 'Unknown error'), 'error');
|
|
1297
|
+
}
|
|
1298
|
+
} catch (e) {
|
|
1299
|
+
showToast('Connection error: ' + e.message, 'error');
|
|
1300
|
+
}
|
|
1301
|
+
connectingProviders.delete(id);
|
|
1302
|
+
renderProviders();
|
|
1303
|
+
}
|
|
1304
|
+
async function disconnectProvider(id) {
|
|
1305
|
+
try {
|
|
1306
|
+
const res = await fetch(BASE + '/mcp-oauth-disconnect', {
|
|
1307
|
+
method: 'POST',
|
|
1308
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1309
|
+
body: JSON.stringify({ clientId: id }),
|
|
1310
|
+
});
|
|
1311
|
+
const data = await res.json();
|
|
1312
|
+
if (data.ok) {
|
|
1313
|
+
delete connectedProviders[id];
|
|
1314
|
+
showToast('Disconnected', '');
|
|
1315
|
+
renderProviders();
|
|
1316
|
+
}
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
showToast('Disconnect error: ' + e.message, 'error');
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
// ── Chat send ──────────────────────────────────────────────────────────────
|
|
831
1322
|
async function sendMessage() {
|
|
832
1323
|
const text = inputEl.value.trim();
|
|
833
1324
|
if (!text || sendBtn.disabled) return;
|
|
834
1325
|
inputEl.value = '';
|
|
835
1326
|
inputEl.style.height = 'auto';
|
|
836
|
-
addMsg('user', text);
|
|
837
1327
|
sendBtn.disabled = true;
|
|
838
|
-
|
|
839
|
-
const thinking = addMsg('thinking', '
|
|
1328
|
+
addMsg('user', text);
|
|
1329
|
+
const thinking = addMsg('thinking', 'Thinking…');
|
|
1330
|
+
setStatus(false);
|
|
840
1331
|
try {
|
|
841
1332
|
const res = await fetch(PROXY, {
|
|
842
1333
|
method: 'POST',
|
|
843
1334
|
headers: { 'Content-Type': 'application/json' },
|
|
844
1335
|
body: JSON.stringify({
|
|
845
1336
|
message: text,
|
|
846
|
-
|
|
847
|
-
|
|
1337
|
+
history: conversationHistory,
|
|
1338
|
+
model: currentModel.modelId ? { provider: currentModel.provider, modelId: currentModel.modelId } : undefined,
|
|
848
1339
|
}),
|
|
849
1340
|
});
|
|
850
1341
|
thinking.remove();
|
|
851
1342
|
if (res.ok) {
|
|
852
1343
|
const data = await res.json();
|
|
853
|
-
const reply = data.reply || data.message || JSON.stringify(data);
|
|
1344
|
+
const reply = data.reply || data.content || data.message || JSON.stringify(data);
|
|
854
1345
|
addMsg('ai', reply);
|
|
855
|
-
|
|
856
|
-
if (data.model) {
|
|
857
|
-
const meta = document.createElement('div');
|
|
858
|
-
meta.className = 'msg system';
|
|
859
|
-
meta.textContent = data.provider + ' / ' + data.model;
|
|
860
|
-
messagesEl.appendChild(meta);
|
|
861
|
-
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
862
|
-
}
|
|
1346
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
863
1347
|
conversationHistory.push({ role: 'user', content: text });
|
|
864
1348
|
conversationHistory.push({ role: 'assistant', content: reply });
|
|
865
1349
|
} else {
|
|
@@ -875,7 +1359,6 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
875
1359
|
sendBtn.disabled = false;
|
|
876
1360
|
setStatus(true);
|
|
877
1361
|
}
|
|
878
|
-
|
|
879
1362
|
sendBtn.addEventListener('click', sendMessage);
|
|
880
1363
|
inputEl.addEventListener('keydown', e => {
|
|
881
1364
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
|
@@ -884,7 +1367,6 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
884
1367
|
inputEl.style.height = 'auto';
|
|
885
1368
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
|
886
1369
|
});
|
|
887
|
-
|
|
888
1370
|
// Load models on startup
|
|
889
1371
|
loadModels();
|
|
890
1372
|
<\/script>
|