promethios-bridge 2.1.7 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/bridge.js +534 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promethios-bridge",
3
- "version": "2.1.7",
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">&#10003;</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">&#10007;</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
- /* ── Model selector ── */
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
- /* ── Messages ── */
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="messages">
718
- <div class="msg system">Your computer is connected. Ask me anything.</div>
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)">&#9650;</button>
1070
+ </div>
719
1071
  </div>
720
- <div id="input-row">
721
- <textarea id="input" placeholder="Ask Promethios..." rows="1"></textarea>
722
- <button id="send" title="Send (Enter)">&#9650;</button>
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 (e) {
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, modelName }),
1177
+ body: JSON.stringify({ provider, modelId }),
817
1178
  });
818
- } catch (_) {}
819
- // Rebuild dropdown to show new active state
820
- await loadModels();
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
- modelDropdown.addEventListener('click', e => e.stopPropagation());
829
-
830
- // ── Chat ───────────────────────────────────────────────────────────────────
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 ? '&#10003; 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 &#8599;</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
- setStatus(true);
839
- const thinking = addMsg('thinking', 'Promethios is thinking\u2026');
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
- conversationHistory: conversationHistory.slice(-20),
847
- source: 'overlay',
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
- // Show which model replied (subtle footer on message)
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>