anygate 0.1.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.
@@ -0,0 +1,2450 @@
1
+ // ─── State ───────────────────────────────────────────────────────────────────
2
+
3
+ const AGY_MAX = 6;
4
+ const GENERAL_MAX = 20;
5
+ const UPDATE_COMMAND = 'npm install -g anygate@latest';
6
+
7
+ const state = {
8
+ providers: [],
9
+ templates: [], // unconfigured available templates
10
+ allModels: [],
11
+ generalFavorites: [],
12
+ agyFavorites: [],
13
+ modelsLoaded: false,
14
+ modelsError: null,
15
+ providerFilter: '',
16
+ modelFilter: '',
17
+ modelFreeOnly: false,
18
+ agyFilter: '',
19
+ agyFreeOnly: false,
20
+ appModelFilter: '',
21
+ appFreeOnly: false,
22
+ providerNameMap: {}, // providerId → full display name
23
+ recentLaunchFolders: [],
24
+ appLaunchFolders: {},
25
+ appModelFilters: {},
26
+ appModelOpen: null,
27
+ appSelections: {},
28
+ server: {
29
+ status: null,
30
+ error: null,
31
+ starting: false,
32
+ form: {
33
+ seeded: false,
34
+ expose: 'favorites', // 'favorites' | 'specific'
35
+ exposedProviders: [],
36
+ freeModelsOnly: false,
37
+ providerSearch: '',
38
+ providersList: [],
39
+ providersLoaded: false,
40
+ maskGatewayIds: true,
41
+ listenMode: 'local', // 'local' | 'network'
42
+ passwordMode: 'new', // 'saved' | 'new'
43
+ password: '',
44
+ savePassword: false,
45
+ },
46
+ },
47
+ };
48
+
49
+ // ─── Provider logos via Simple Icons CDN ─────────────────────────────────────
50
+ // https://simpleicons.org — monochrome SVG brand marks, white on dark bg.
51
+ // Format: https://cdn.simpleicons.org/{slug}/ffffff
52
+
53
+ // Inline SVGs for providers whose official domains block hotlinking.
54
+ // White fill, renders on gradient background.
55
+ const PROVIDER_INLINE_SVGS = {
56
+ openai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.896zm16.597 3.855l-5.843-3.368L15.115 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.403-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>`,
57
+ kilo: `<svg viewBox="0 0 24 24" fill="white" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" style="width:22px;height:22px"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"/></svg>`,
58
+ xai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
59
+ 'xai-oauth': `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
60
+ };
61
+
62
+ // Verified working logo URLs. Slugs tested against cdn.simpleicons.org — 404s removed.
63
+ // Missing: openai, groq, cohere, togetherai, fireworks, cerebras → inline SVG or gradient letter fallback.
64
+ const PROVIDER_LOGO_URLS = {
65
+ // OpenCode — official favicon (proper square icon)
66
+ zen: 'https://opencode.ai/favicon.ico',
67
+ go: 'https://opencode.ai/favicon.ico',
68
+ // Verified Simple Icons slugs (200 responses confirmed)
69
+ anthropic: 'https://cdn.simpleicons.org/anthropic/ffffff',
70
+ google: 'https://cdn.simpleicons.org/google/ffffff',
71
+ nvidia: 'https://cdn.simpleicons.org/nvidia/ffffff',
72
+ deepseek: 'https://cdn.simpleicons.org/deepseek/ffffff',
73
+ mistral: 'https://cdn.simpleicons.org/mistralai/ffffff',
74
+ ollama: 'https://cdn.simpleicons.org/ollama/ffffff',
75
+ openrouter: 'https://cdn.simpleicons.org/openrouter/ffffff',
76
+ perplexity: 'https://cdn.simpleicons.org/perplexity/ffffff',
77
+ huggingface: 'https://cdn.simpleicons.org/huggingface/ffffff',
78
+ cloudflare: 'https://cdn.simpleicons.org/cloudflare/ffffff',
79
+ azure: 'https://cdn.simpleicons.org/microsoftazure/ffffff',
80
+ vertex: 'https://cdn.simpleicons.org/googlecloud/ffffff',
81
+ bedrock: 'https://cdn.simpleicons.org/amazonaws/ffffff',
82
+ // xai / xai-oauth → inline SVG in PROVIDER_INLINE_SVGS
83
+ // openai, groq, cohere, togetherai, fireworks, cerebras → no verified slug, use gradient letter
84
+ };
85
+
86
+ function getProviderLogoContent(providerId) {
87
+ const base = providerId.replace(/-oauth$/, '').replace(/-api$/, '');
88
+ // Inline SVG takes priority (for providers that block hotlinking)
89
+ const svg = PROVIDER_INLINE_SVGS[providerId] ?? PROVIDER_INLINE_SVGS[base];
90
+ if (svg) return { type: 'svg', content: svg };
91
+ const url = PROVIDER_LOGO_URLS[providerId] ?? PROVIDER_LOGO_URLS[base];
92
+ if (url) return { type: 'img', content: url };
93
+ return null;
94
+ }
95
+
96
+ // ─── Neon model colors (unique per model ID) ──────────────────────────────────
97
+
98
+ const NEONS = [
99
+ { color: 'oklch(75% 0.32 255)', bg: 'oklch(75% 0.32 255 / 0.12)', border: 'oklch(75% 0.32 255 / 0.35)' }, // electric blue
100
+ { color: 'oklch(72% 0.30 320)', bg: 'oklch(72% 0.30 320 / 0.12)', border: 'oklch(72% 0.30 320 / 0.35)' }, // hot pink
101
+ { color: 'oklch(78% 0.28 155)', bg: 'oklch(78% 0.28 155 / 0.12)', border: 'oklch(78% 0.28 155 / 0.35)' }, // neon green
102
+ { color: 'oklch(80% 0.26 85)', bg: 'oklch(80% 0.26 85 / 0.12)', border: 'oklch(80% 0.26 85 / 0.35)' }, // vivid amber
103
+ { color: 'oklch(76% 0.30 195)', bg: 'oklch(76% 0.30 195 / 0.12)', border: 'oklch(76% 0.30 195 / 0.35)' }, // electric cyan
104
+ { color: 'oklch(70% 0.30 285)', bg: 'oklch(70% 0.30 285 / 0.12)', border: 'oklch(70% 0.30 285 / 0.35)' }, // electric purple
105
+ { color: 'oklch(82% 0.24 55)', bg: 'oklch(82% 0.24 55 / 0.12)', border: 'oklch(82% 0.24 55 / 0.35)' }, // vivid orange
106
+ { color: 'oklch(73% 0.30 340)', bg: 'oklch(73% 0.30 340 / 0.12)', border: 'oklch(73% 0.30 340 / 0.35)' }, // hot magenta
107
+ { color: 'oklch(79% 0.28 130)', bg: 'oklch(79% 0.28 130 / 0.12)', border: 'oklch(79% 0.28 130 / 0.35)' }, // lime green
108
+ { color: 'oklch(74% 0.28 225)', bg: 'oklch(74% 0.28 225 / 0.12)', border: 'oklch(74% 0.28 225 / 0.35)' }, // sky blue
109
+ { color: 'oklch(76% 0.28 15)', bg: 'oklch(76% 0.28 15 / 0.12)', border: 'oklch(76% 0.28 15 / 0.35)' }, // coral red
110
+ { color: 'oklch(77% 0.28 175)', bg: 'oklch(77% 0.28 175 / 0.12)', border: 'oklch(77% 0.28 175 / 0.35)' }, // emerald
111
+ { color: 'oklch(74% 0.30 265)', bg: 'oklch(74% 0.30 265 / 0.12)', border: 'oklch(74% 0.30 265 / 0.35)' }, // indigo
112
+ { color: 'oklch(81% 0.26 70)', bg: 'oklch(81% 0.26 70 / 0.12)', border: 'oklch(81% 0.26 70 / 0.35)' }, // golden
113
+ ];
114
+
115
+ function modelNeon(modelId) {
116
+ let hash = 0;
117
+ for (let i = 0; i < modelId.length; i++) hash = (hash * 31 + modelId.charCodeAt(i)) | 0;
118
+ return NEONS[Math.abs(hash) % NEONS.length];
119
+ }
120
+
121
+ function getProviderName(providerId) {
122
+ return state.providerNameMap[providerId] ?? providerId;
123
+ }
124
+
125
+ function escapeHtml(value) {
126
+ return String(value ?? '')
127
+ .replace(/&/g, '&amp;')
128
+ .replace(/</g, '&lt;')
129
+ .replace(/>/g, '&gt;')
130
+ .replace(/"/g, '&quot;')
131
+ .replace(/'/g, '&#39;');
132
+ }
133
+
134
+ function modelChoiceValue(providerId, modelId) {
135
+ return `${encodeURIComponent(providerId)}::${encodeURIComponent(modelId)}`;
136
+ }
137
+
138
+ function parseModelChoiceValue(value) {
139
+ const idx = value.indexOf('::');
140
+ if (idx < 0) return null;
141
+ return {
142
+ providerId: decodeURIComponent(value.slice(0, idx)),
143
+ modelId: decodeURIComponent(value.slice(idx + 2)),
144
+ };
145
+ }
146
+
147
+ function isGeneralFavorite(providerId, modelId) {
148
+ return state.generalFavorites.some(f => f.providerId === providerId && f.modelId === modelId);
149
+ }
150
+
151
+ function appIconInitial(app) {
152
+ const explicit = {
153
+ claude: 'C',
154
+ codex: 'X',
155
+ gemini: 'G',
156
+ agy: 'A',
157
+ antigravity: 'A',
158
+ 'antigravity-ide': 'A',
159
+ 'claude-app': 'C',
160
+ 'codex-app': 'X',
161
+ };
162
+ return explicit[app.id] ?? providerInitial(app.name);
163
+ }
164
+
165
+ // ─── App icons ───────────────────────────────────────────────────────────────
166
+ // Inline SVGs for small/clean icons; IMG paths for rasterized large icons.
167
+ const APP_ICON_SVGS = {
168
+ claude: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width:65%;height:65%"><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="white" fill-rule="evenodd"/></svg>`,
169
+ codex: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="width:65%;height:65%" fill-rule="evenodd"><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z" fill="white"/></svg>`,
170
+ gemini: `<svg viewBox="0 0 24 24" style="width:100%;height:100%" xmlns="http://www.w3.org/2000/svg"><path d="M0 4.391A4.391 4.391 0 014.391 0h15.217A4.391 4.391 0 0124 4.391v15.217A4.391 4.391 0 0119.608 24H4.391A4.391 4.391 0 010 19.608V4.391z" fill="url(#gem-grad)"/><path clip-rule="evenodd" d="M19.74 1.444a2.816 2.816 0 012.816 2.816v15.48a2.816 2.816 0 01-2.816 2.816H4.26a2.816 2.816 0 01-2.816-2.816V4.26A2.816 2.816 0 014.26 1.444h15.48zM7.236 8.564l7.752 3.728-7.752 3.727v2.802l9.557-4.596v-3.866L7.236 5.763v2.801z" fill="#1E1E2E" fill-rule="evenodd"/><defs><linearGradient gradientUnits="userSpaceOnUse" id="gem-grad" x1="24" x2="0" y1="6.587" y2="16.494"><stop stop-color="#EE4D5D"/><stop offset=".328" stop-color="#B381DD"/><stop offset=".476" stop-color="#207CFE"/></linearGradient></defs></svg>`,
171
+ agy: `<svg fill="white" fill-rule="evenodd" viewBox="0 0 24 24" style="width:65%;height:65%" xmlns="http://www.w3.org/2000/svg"><path d="M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z"/></svg>`,
172
+ 'codex-app': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="width:65%;height:65%" fill-rule="evenodd"><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z" fill="white"/></svg>`,
173
+ 'claude-app': `<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 509.64" style="width:100%;height:100%"><path fill="#D77655" d="M115.612 0h280.775C459.974 0 512 52.026 512 115.612v278.415c0 63.587-52.026 115.612-115.613 115.612H115.612C52.026 509.639 0 457.614 0 394.027V115.612C0 52.026 52.026 0 115.612 0z"/><path fill="#FCF2EE" fill-rule="nonzero" d="M142.27 316.619l73.655-41.326 1.238-3.589-1.238-1.996-3.589-.001-12.31-.759-42.084-1.138-36.498-1.516-35.361-1.896-8.897-1.895-8.34-10.995.859-5.484 7.482-5.03 10.717.935 23.683 1.617 35.537 2.452 25.782 1.517 38.193 3.968h6.064l.86-2.451-2.073-1.517-1.618-1.517-36.776-24.922-39.81-26.338-20.852-15.166-11.273-7.683-5.687-7.204-2.451-15.721 10.237-11.273 13.75.935 3.513.936 13.928 10.716 29.749 23.027 38.848 28.612 5.687 4.727 2.275-1.617.278-1.138-2.553-4.271-21.13-38.193-22.546-38.848-10.035-16.101-2.654-9.655c-.935-3.968-1.617-7.304-1.617-11.374l11.652-15.823 6.445-2.073 15.545 2.073 6.547 5.687 9.655 22.092 15.646 34.78 24.265 47.291 7.103 14.028 3.791 12.992 1.416 3.968 2.449-.001v-2.275l1.997-26.641 3.69-32.707 3.589-42.084 1.239-11.854 5.863-14.206 11.652-7.683 9.099 4.348 7.482 10.716-1.036 6.926-4.449 28.915-8.72 45.294-5.687 30.331h3.313l3.792-3.791 15.342-20.372 25.782-32.227 11.374-12.789 13.27-14.129 8.517-6.724 16.1-.001 11.854 17.617-5.307 18.199-16.581 21.029-13.75 17.819-19.716 26.54-12.309 21.231 1.138 1.694 2.932-.278 44.536-9.479 24.062-4.347 28.714-4.928 12.992 6.066 1.416 6.167-5.106 12.613-30.71 7.583-36.018 7.204-53.636 12.689-.657.48.758.935 24.164 2.275 10.337.556h25.301l47.114 3.514 12.309 8.139 7.381 9.959-1.238 7.583-18.957 9.655-25.579-6.066-59.702-14.205-20.474-5.106-2.83-.001v1.694l17.061 16.682 31.266 28.233 39.152 36.397 1.997 8.999-5.03 7.102-5.307-.758-34.401-25.883-13.27-11.651-30.053-25.302-1.996-.001v2.654l6.926 10.136 36.574 54.975 1.895 16.859-2.653 5.485-9.479 3.311-10.414-1.895-21.408-30.054-22.092-33.844-17.819-30.331-2.173 1.238-10.515 113.261-4.929 5.788-11.374 4.348-9.478-7.204-5.03-11.652 5.03-23.027 6.066-30.052 4.928-23.886 4.449-29.674 2.654-9.858-.177-.657-2.173.278-22.37 30.71-34.021 45.977-26.919 28.815-6.445 2.553-11.173-5.789 1.037-10.337 6.243-9.2 37.257-47.392 22.47-29.371 14.508-16.961-.101-2.451h-.859l-98.954 64.251-17.618 2.275-7.583-7.103.936-11.652 3.589-3.791 29.749-20.474-.101.102.024.101z"/></svg>`,
174
+ };
175
+ const APP_ICON_IMGS = {
176
+ antigravity: 'icon-antigravity.png',
177
+ 'antigravity-ide': 'icon-antigravity-ide.png',
178
+ };
179
+
180
+ function getAppIconHtml(app, fallbackInitial) {
181
+ const svg = APP_ICON_SVGS[app.id];
182
+ if (svg) return svg;
183
+ const img = APP_ICON_IMGS[app.id];
184
+ if (img) return `<img src="${img}" style="width:100%;height:100%;object-fit:contain;border-radius:inherit;" alt="${app.name}" onerror="this.style.display='none'">`;
185
+ return `<span class="provider-logo-fallback">${fallbackInitial}</span>`;
186
+ }
187
+
188
+ // ─── Provider icon palettes ──────────────────────────────────────────────────
189
+
190
+ const PALETTES = [
191
+ ['oklch(65% 0.28 255)', 'oklch(60% 0.26 285)'], // indigo→purple
192
+ ['oklch(68% 0.24 175)', 'oklch(62% 0.26 215)'], // teal→blue
193
+ ['oklch(70% 0.22 25)', 'oklch(65% 0.26 350)'], // orange→pink
194
+ ['oklch(68% 0.24 145)', 'oklch(62% 0.26 175)'], // green→teal
195
+ ['oklch(72% 0.22 85)', 'oklch(65% 0.26 55)'], // amber→orange
196
+ ['oklch(68% 0.26 315)', 'oklch(62% 0.28 285)'], // pink→purple
197
+ ['oklch(65% 0.26 195)', 'oklch(60% 0.26 225)'], // sky→indigo
198
+ ];
199
+
200
+ function providerPalette(id) {
201
+ let hash = 0;
202
+ for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
203
+ return PALETTES[Math.abs(hash) % PALETTES.length];
204
+ }
205
+
206
+ function providerInitial(name) {
207
+ return (name || '?').replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || '?';
208
+ }
209
+
210
+ // ─── API ─────────────────────────────────────────────────────────────────────
211
+
212
+ async function api(method, path, body) {
213
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
214
+ if (body !== undefined) opts.body = JSON.stringify(body);
215
+ return (await fetch(path, opts)).json();
216
+ }
217
+
218
+ async function loadConfig() {
219
+ const data = await api('GET', '/api/config');
220
+ state.generalFavorites = data.favoriteModels ?? [];
221
+ state.agyFavorites = data.antigravityCliFavoriteModels ?? [];
222
+ }
223
+
224
+ async function initUpdateIndicator() {
225
+ const indicator = document.getElementById('update-indicator');
226
+ const popover = document.getElementById('update-popover');
227
+ if (!indicator || !popover) return;
228
+
229
+ try {
230
+ const status = await api('GET', '/api/update-status');
231
+ if (!status.updateAvailable || !status.latestVersion) return;
232
+
233
+ document.getElementById('update-current-version').textContent = status.currentVersion;
234
+ document.getElementById('update-latest-version').textContent = status.latestVersion;
235
+ indicator.title = `Version ${status.latestVersion} is available. Click for update instructions.`;
236
+ indicator.hidden = false;
237
+ } catch {
238
+ return;
239
+ }
240
+
241
+ indicator.addEventListener('click', () => {
242
+ const open = popover.hidden;
243
+ popover.hidden = !open;
244
+ indicator.setAttribute('aria-expanded', String(open));
245
+ });
246
+
247
+ document.getElementById('update-copy-btn')?.addEventListener('click', async () => {
248
+ try {
249
+ await navigator.clipboard.writeText(UPDATE_COMMAND);
250
+ showToast('Update command copied');
251
+ } catch {
252
+ showToast('Could not copy the update command');
253
+ }
254
+ });
255
+
256
+ document.addEventListener('click', event => {
257
+ if (!popover.hidden && !document.getElementById('sidebar-version-area')?.contains(event.target)) {
258
+ popover.hidden = true;
259
+ indicator.setAttribute('aria-expanded', 'false');
260
+ }
261
+ });
262
+
263
+ document.addEventListener('keydown', event => {
264
+ if (event.key === 'Escape' && !popover.hidden) {
265
+ popover.hidden = true;
266
+ indicator.setAttribute('aria-expanded', 'false');
267
+ indicator.focus();
268
+ }
269
+ });
270
+ }
271
+
272
+ async function loadTemplates() {
273
+ const data = await api('GET', '/api/providers/templates');
274
+ state.templates = data.templates ?? [];
275
+ }
276
+
277
+ async function loadModels() {
278
+ const data = await api('GET', '/api/models');
279
+ if (data.error) throw new Error(data.error);
280
+ state.providers = data.providers ?? [];
281
+ const flat = [];
282
+
283
+ for (const p of state.providers) {
284
+ state.providerNameMap[p.id] = p.name;
285
+ const favoriteProviderName = p.favoriteName ?? p.name;
286
+ for (const m of p.models ?? []) flat.push({
287
+ id: m.id,
288
+ name: m.name,
289
+ providerId: p.id,
290
+ providerName: favoriteProviderName,
291
+ contextWindow: m.contextWindow ?? null,
292
+ isFree: Boolean(m.isFree),
293
+ freeStatus: m.freeStatus,
294
+ freeLabel: m.freeLabel,
295
+ cost: m.cost,
296
+ });
297
+ if (typeof p.modelCount === 'number') p._rawCount = p.modelCount;
298
+ }
299
+
300
+ // Disambiguate API-key providers that share a first-word name with an OAuth sibling.
301
+ // e.g. "OpenAI (ChatGPT)" (oauth) + "OpenAI" (api) → "OpenAI (API)"
302
+ // "xAI Grok (SuperGrok)" (oauth) + "xAI" (api) → "xAI (API)"
303
+ const oauthFirstWords = new Set(
304
+ state.providers
305
+ .filter(p => p.authType === 'oauth')
306
+ .map(p => p.name.split(' ')[0].toLowerCase()),
307
+ );
308
+ for (const p of state.providers) {
309
+ if (p.authType !== 'oauth') {
310
+ const firstWord = p.name.split(' ')[0].toLowerCase();
311
+ if (oauthFirstWords.has(firstWord)) {
312
+ state.providerNameMap[p.id] = p.name + ' (API)';
313
+ }
314
+ }
315
+ }
316
+ state.allModels = flat;
317
+ }
318
+
319
+ async function saveFavorites(payload) { return api('POST', '/api/config', payload); }
320
+ async function saveKey(providerId, key) { return api('POST', '/api/keys', { providerId, key }); }
321
+ async function refreshProvider(providerId) { return api('POST', '/api/providers/refresh', { providerId }); }
322
+
323
+ // ─── Toast ────────────────────────────────────────────────────────────────────
324
+
325
+ function showToast(msg, undoFn) {
326
+ const container = document.getElementById('toast-container');
327
+ const toast = document.createElement('div');
328
+ toast.className = 'toast';
329
+
330
+ const span = document.createElement('span');
331
+ span.className = 'toast-msg';
332
+ span.textContent = msg;
333
+ toast.appendChild(span);
334
+
335
+ let dismissed = false;
336
+
337
+ function dismiss() {
338
+ if (dismissed) return;
339
+ dismissed = true;
340
+ toast.classList.add('out');
341
+ toast.addEventListener('animationend', () => toast.remove(), { once: true });
342
+ }
343
+
344
+ if (undoFn) {
345
+ const btn = document.createElement('button');
346
+ btn.className = 'btn-undo';
347
+ btn.textContent = 'Undo';
348
+ btn.addEventListener('click', () => { dismiss(); undoFn(); });
349
+ toast.appendChild(btn);
350
+ setTimeout(() => { if (!dismissed) { const b = toast.querySelector('.btn-undo'); if (b) b.style.opacity = '0.3'; } }, 4000);
351
+ setTimeout(dismiss, 5500);
352
+ } else {
353
+ setTimeout(dismiss, 2200);
354
+ }
355
+
356
+ container.appendChild(toast);
357
+ }
358
+
359
+ // ─── Utilities ────────────────────────────────────────────────────────────────
360
+
361
+ function fmtCtx(n) {
362
+ if (!n) return '';
363
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
364
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
365
+ return String(n);
366
+ }
367
+
368
+ function isFreeModel(model) {
369
+ return Boolean(model?.isFree || model?.freeStatus === 'verified_free' || model?.freeStatus === 'free_provider');
370
+ }
371
+
372
+ function freeBadgeLabel(model) {
373
+ if (model?.freeStatus === 'free_provider') return 'Free dev access';
374
+ return model?.freeLabel || 'Free';
375
+ }
376
+
377
+ // ─── Providers ────────────────────────────────────────────────────────────────
378
+
379
+ function renderProviders() {
380
+ const list = document.getElementById('providers-list');
381
+ const availSection = document.getElementById('available-providers-section');
382
+ const availList = document.getElementById('available-providers-list');
383
+ const filter = state.providerFilter.toLowerCase();
384
+
385
+ const visibleConfigured = state.providers.filter(p => {
386
+ const displayName = getProviderName(p.id);
387
+ return !filter || displayName.toLowerCase().includes(filter) || p.id.toLowerCase().includes(filter);
388
+ });
389
+ const visibleTemplates = state.templates.filter(t =>
390
+ !filter || t.name.toLowerCase().includes(filter) || t.id.toLowerCase().includes(filter)
391
+ );
392
+
393
+ list.innerHTML = '';
394
+
395
+ if (!state.modelsLoaded) {
396
+ for (let i = 0; i < 5; i++) {
397
+ const sk = document.createElement('div');
398
+ sk.className = 'skeleton skeleton-card';
399
+ list.appendChild(sk);
400
+ }
401
+ availSection.hidden = true;
402
+ return;
403
+ }
404
+
405
+ if (visibleConfigured.length === 0 && visibleTemplates.length === 0) {
406
+ list.innerHTML = '<div class="fav-empty">No providers match your search.</div>';
407
+ availSection.hidden = true;
408
+ return;
409
+ }
410
+
411
+ if (visibleConfigured.length === 0) {
412
+ list.innerHTML = '<div class="fav-empty">No configured providers match.</div>';
413
+ } else {
414
+ for (const p of visibleConfigured) list.appendChild(buildProviderCard(p));
415
+ }
416
+
417
+ if (visibleTemplates.length > 0) {
418
+ availSection.hidden = false;
419
+ availList.innerHTML = '';
420
+ for (const t of visibleTemplates) availList.appendChild(buildTemplateCard(t));
421
+ } else {
422
+ availSection.hidden = true;
423
+ }
424
+ }
425
+
426
+ function buildProviderCard(provider) {
427
+ const [c1, c2] = providerPalette(provider.id);
428
+ const displayName = getProviderName(provider.id);
429
+ const initial = providerInitial(displayName);
430
+ const logo = getProviderLogoContent(provider.id);
431
+ const card = document.createElement('div');
432
+ card.className = 'provider-card';
433
+ card.dataset.id = provider.id;
434
+
435
+ const header = document.createElement('div');
436
+ header.className = 'provider-card-header';
437
+ header.setAttribute('role', 'button');
438
+ header.setAttribute('tabindex', '0');
439
+ header.setAttribute('aria-expanded', 'false');
440
+
441
+ const logoHtml = logo?.type === 'svg'
442
+ ? logo.content
443
+ : logo?.type === 'img'
444
+ ? `<img src="${logo.content}" class="provider-logo-img" alt="${escapeHtml(displayName)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`
445
+ : initial;
446
+
447
+ header.innerHTML = `
448
+ <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${logoHtml}</div>
449
+ <div class="provider-info">
450
+ <div class="provider-name">${escapeHtml(displayName)}</div>
451
+ <div class="provider-models-count">${provider.modelCount ?? provider.models?.length ?? 0} models</div>
452
+ </div>
453
+ <div class="provider-status">
454
+ <span class="status-chip ${provider.hasKey ? 'has-key' : provider.freeAccess ? 'free-access' : 'no-key'}">
455
+ ${provider.hasKey ? 'Key stored' : provider.freeAccess ? 'Free models' : 'Not configured'}
456
+ </span>
457
+ <svg class="provider-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
458
+ </div>
459
+ `;
460
+
461
+ const body = document.createElement('div');
462
+ body.className = 'provider-body';
463
+ body.setAttribute('aria-hidden', 'true');
464
+
465
+ const inner = document.createElement('div');
466
+ inner.className = 'provider-body-inner';
467
+ inner.appendChild(buildProviderBodyContent(provider));
468
+ body.appendChild(inner);
469
+
470
+ function toggle() {
471
+ const isOpen = card.classList.toggle('open');
472
+ header.setAttribute('aria-expanded', String(isOpen));
473
+ body.setAttribute('aria-hidden', String(!isOpen));
474
+ }
475
+
476
+ header.addEventListener('click', toggle);
477
+ header.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
478
+
479
+ card.appendChild(header);
480
+ card.appendChild(body);
481
+ return card;
482
+ }
483
+
484
+ function buildTemplateCard(template) {
485
+ const [c1, c2] = providerPalette(template.id);
486
+ const initial = providerInitial(template.name);
487
+ const logo = getProviderLogoContent(template.id);
488
+ const logoHtml = logo?.type === 'svg'
489
+ ? logo.content
490
+ : logo?.type === 'img'
491
+ ? `<img src="${logo.content}" class="provider-logo-img" alt="${template.name}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`
492
+ : initial;
493
+ const card = document.createElement('div');
494
+ card.className = 'provider-card';
495
+ card.dataset.id = template.id;
496
+
497
+ const header = document.createElement('div');
498
+ header.className = 'provider-card-header';
499
+ header.setAttribute('role', 'button');
500
+ header.setAttribute('tabindex', '0');
501
+ header.setAttribute('aria-expanded', 'false');
502
+
503
+ const signupLink = template.signupUrl
504
+ ? `<a class="template-signup-link" href="${template.signupUrl}" target="_blank" rel="noopener">${template.authType === 'oauth' ? 'Learn more ↗' : 'Get API key ↗'}</a>`
505
+ : '';
506
+
507
+ header.innerHTML = `
508
+ <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${logoHtml}</div>
509
+ <div class="provider-info">
510
+ <div class="provider-name">${template.name}</div>
511
+ <div class="provider-models-count">Not configured ${signupLink}</div>
512
+ </div>
513
+ <div class="provider-status">
514
+ <span class="status-chip no-key">Add provider</span>
515
+ <svg class="provider-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
516
+ </div>
517
+ `;
518
+
519
+ const body = document.createElement('div');
520
+ body.className = 'provider-body';
521
+ body.setAttribute('aria-hidden', 'true');
522
+
523
+ const inner = document.createElement('div');
524
+ inner.className = 'provider-body-inner';
525
+ inner.appendChild(buildTemplateBodyContent(template, card));
526
+ body.appendChild(inner);
527
+
528
+ function toggle() {
529
+ const isOpen = card.classList.toggle('open');
530
+ header.setAttribute('aria-expanded', String(isOpen));
531
+ body.setAttribute('aria-hidden', String(!isOpen));
532
+ }
533
+
534
+ header.addEventListener('click', toggle);
535
+ header.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
536
+
537
+ card.appendChild(header);
538
+ card.appendChild(body);
539
+ return card;
540
+ }
541
+
542
+ function buildCustomEndpointBodyContent(template, card) {
543
+ const kind = template.id === '__custom_anthropic__' ? 'anthropic' : 'openai';
544
+ const isAnthropicKind = kind === 'anthropic';
545
+
546
+ const content = document.createElement('div');
547
+ content.className = 'provider-body-content';
548
+
549
+ function row(label, input) {
550
+ const wrap = document.createElement('div');
551
+ wrap.className = 'key-row';
552
+ wrap.style.flexDirection = 'column';
553
+ wrap.style.gap = '4px';
554
+ const lbl = document.createElement('label');
555
+ lbl.textContent = label;
556
+ lbl.style.cssText = 'font-size:12px;color:var(--text-muted,#aaa);display:block';
557
+ wrap.appendChild(lbl);
558
+ wrap.appendChild(input);
559
+ return wrap;
560
+ }
561
+
562
+ const nameInput = document.createElement('input');
563
+ nameInput.type = 'text';
564
+ nameInput.className = 'key-input';
565
+ nameInput.placeholder = isAnthropicKind ? 'e.g. My LiteLLM Proxy' : 'e.g. Local Ollama';
566
+ nameInput.autocomplete = 'off';
567
+
568
+ const urlInput = document.createElement('input');
569
+ urlInput.type = 'url';
570
+ urlInput.className = 'key-input';
571
+ urlInput.placeholder = isAnthropicKind ? 'https://my-proxy.example.com/v1' : 'http://localhost:11434/v1';
572
+ urlInput.autocomplete = 'off';
573
+
574
+ const keyInput = document.createElement('input');
575
+ keyInput.type = 'password';
576
+ keyInput.className = 'key-input';
577
+ keyInput.placeholder = isAnthropicKind ? 'API key (required)' : 'API key (leave blank if not needed)';
578
+ keyInput.autocomplete = 'off';
579
+
580
+ const headersInput = document.createElement('textarea');
581
+ headersInput.className = 'key-input';
582
+ headersInput.rows = 2;
583
+ headersInput.placeholder = 'Optional custom headers, one per line\nX-Plan: coding';
584
+ headersInput.autocomplete = 'off';
585
+
586
+ const addBtn = document.createElement('button');
587
+ addBtn.className = 'btn btn-primary';
588
+ addBtn.textContent = 'Add Provider';
589
+ addBtn.style.marginTop = '4px';
590
+
591
+ const feedback = document.createElement('div');
592
+ feedback.className = 'key-feedback';
593
+
594
+ content.appendChild(row('Provider name', nameInput));
595
+ content.appendChild(row('Base URL', urlInput));
596
+ content.appendChild(row('API key', keyInput));
597
+ content.appendChild(row('Custom headers (optional)', headersInput));
598
+ content.appendChild(addBtn);
599
+ content.appendChild(feedback);
600
+
601
+ function parseHeaders(text) {
602
+ const headers = {};
603
+ for (const line of text.split('\n')) {
604
+ const trimmed = line.trim();
605
+ if (!trimmed) continue;
606
+ const idx = trimmed.indexOf(':');
607
+ if (idx < 1) continue;
608
+ const name = trimmed.slice(0, idx).trim();
609
+ const value = trimmed.slice(idx + 1).trim();
610
+ if (name) headers[name] = value;
611
+ }
612
+ return headers;
613
+ }
614
+
615
+ addBtn.addEventListener('click', async () => {
616
+ const displayName = nameInput.value.trim();
617
+ const baseUrl = urlInput.value.trim();
618
+ const apiKey = keyInput.value.trim();
619
+ const headers = parseHeaders(headersInput.value);
620
+
621
+ if (!displayName) { feedback.textContent = 'Enter a provider name.'; feedback.className = 'key-feedback error'; return; }
622
+ if (!baseUrl) { feedback.textContent = 'Enter a base URL.'; feedback.className = 'key-feedback error'; return; }
623
+ if (isAnthropicKind && !apiKey) { feedback.textContent = 'API key is required for Anthropic-compatible providers.'; feedback.className = 'key-feedback error'; return; }
624
+
625
+ addBtn.disabled = true;
626
+ feedback.textContent = 'Connecting and fetching models…';
627
+ feedback.className = 'key-feedback muted';
628
+
629
+ const result = await api('POST', '/api/providers/add-custom', { kind, displayName, baseUrl, apiKey, headers });
630
+
631
+ addBtn.disabled = false;
632
+ if (result.ok) {
633
+ feedback.textContent = `✓ ${displayName} added · ${result.count} models available`;
634
+ feedback.className = 'key-feedback success';
635
+ nameInput.value = '';
636
+ urlInput.value = '';
637
+ keyInput.value = '';
638
+ headersInput.value = '';
639
+ state.modelsLoaded = false;
640
+ await loadTemplates();
641
+ await initModels();
642
+ renderProviders();
643
+ showToast(`${displayName} added successfully`);
644
+ } else {
645
+ feedback.textContent = result.error ?? 'Failed to add provider';
646
+ if (result.hint) feedback.textContent += ` — ${result.hint}`;
647
+ feedback.className = 'key-feedback error';
648
+ }
649
+ });
650
+
651
+ return content;
652
+ }
653
+
654
+ function buildOAuthTemplateBodyContent(template) {
655
+ const content = document.createElement('div');
656
+ content.className = 'provider-body-content';
657
+
658
+ const note = document.createElement('div');
659
+ note.style.cssText = 'font-size:13px;color:var(--text-secondary,#aaa);margin-bottom:10px';
660
+ note.textContent = 'Sign in via device code — no API key required.';
661
+
662
+ const actionRow = document.createElement('div');
663
+ actionRow.className = 'key-row';
664
+
665
+ const signInBtn = document.createElement('button');
666
+ signInBtn.className = 'btn btn-primary';
667
+ signInBtn.textContent = 'Sign in';
668
+
669
+ const feedback = document.createElement('div');
670
+ feedback.className = 'key-feedback';
671
+
672
+ actionRow.appendChild(signInBtn);
673
+ content.appendChild(note);
674
+ content.appendChild(actionRow);
675
+ content.appendChild(feedback);
676
+
677
+ let pollInterval = null;
678
+ function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
679
+
680
+ signInBtn.addEventListener('click', async () => {
681
+ stopPolling();
682
+ signInBtn.disabled = true;
683
+ feedback.textContent = 'Starting authorization…';
684
+ feedback.className = 'key-feedback muted';
685
+
686
+ const startResult = await api('POST', '/api/providers/oauth/start', { providerId: template.id });
687
+ if (startResult.error) {
688
+ feedback.textContent = startResult.error;
689
+ feedback.className = 'key-feedback error';
690
+ signInBtn.disabled = false;
691
+ return;
692
+ }
693
+
694
+ const { sessionId } = startResult;
695
+ if (startResult.pkce) {
696
+ // PKCE / browser-redirect flow — open auth URL, poll for completion.
697
+ const { authUrl } = startResult;
698
+ window.open(authUrl, '_blank');
699
+ feedback.innerHTML = `Browser opened for authorization.<br>
700
+ <span style="opacity:0.6;font-size:12px">Complete sign-in in the browser window, then return here…</span>`;
701
+ feedback.className = 'key-feedback muted';
702
+ } else {
703
+ // Device code flow — show URL and code.
704
+ const { url, userCode } = startResult;
705
+ window.open(url, '_blank');
706
+ feedback.innerHTML = `Go to <a href="${url}" target="_blank" style="color:inherit">${url}</a><br>
707
+ Enter code: <strong style="letter-spacing:0.1em;font-size:15px">${userCode}</strong><br>
708
+ <span style="opacity:0.6;font-size:12px">Waiting for authorization…</span>`;
709
+ feedback.className = 'key-feedback muted';
710
+ }
711
+
712
+ pollInterval = setInterval(async () => {
713
+ const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
714
+ if (statusResult.status === 'done') {
715
+ stopPolling();
716
+ feedback.textContent = '✓ Signed in — provider added';
717
+ feedback.className = 'key-feedback success';
718
+ showToast(`${template.name} added`);
719
+ state.modelsLoaded = false;
720
+ await loadTemplates();
721
+ await initModels();
722
+ renderProviders();
723
+ } else if (statusResult.status === 'error') {
724
+ stopPolling();
725
+ feedback.textContent = statusResult.error ?? 'Authorization failed';
726
+ feedback.className = 'key-feedback error';
727
+ signInBtn.disabled = false;
728
+ } else if (statusResult.error) {
729
+ stopPolling();
730
+ feedback.textContent = 'Session expired — please try again';
731
+ feedback.className = 'key-feedback error';
732
+ signInBtn.disabled = false;
733
+ }
734
+ }, 3000);
735
+ });
736
+
737
+ return content;
738
+ }
739
+
740
+ function buildTemplateBodyContent(template, card) {
741
+ const isCustom = template.id === '__custom_openai__' || template.id === '__custom_anthropic__';
742
+ if (isCustom) return buildCustomEndpointBodyContent(template, card);
743
+ if (template.authType === 'oauth') return buildOAuthTemplateBodyContent(template, card);
744
+
745
+ const content = document.createElement('div');
746
+ content.className = 'provider-body-content';
747
+
748
+ let urlInput = null;
749
+ if (template.urlPrompt) {
750
+ const urlLabel = document.createElement('label');
751
+ urlLabel.textContent = template.urlPrompt;
752
+ urlLabel.style.cssText = 'font-size:12px;color:var(--text-muted,#aaa);display:block;margin-bottom:4px';
753
+ content.appendChild(urlLabel);
754
+
755
+ urlInput = document.createElement('input');
756
+ urlInput.type = 'url';
757
+ urlInput.className = 'key-input';
758
+ urlInput.placeholder = template.defaultBaseUrl || 'http://localhost:11434/v1';
759
+ urlInput.value = template.defaultBaseUrl || '';
760
+ urlInput.autocomplete = 'off';
761
+ urlInput.style.marginBottom = '8px';
762
+ content.appendChild(urlInput);
763
+ }
764
+
765
+ const keyRow = document.createElement('div');
766
+ keyRow.className = 'key-row';
767
+
768
+ const wrap = document.createElement('div');
769
+ wrap.className = 'key-input-wrap';
770
+
771
+ const input = document.createElement('input');
772
+ input.type = 'password';
773
+ input.className = 'key-input';
774
+ input.placeholder = template.anonymousFreeModels
775
+ ? 'Optional API key; leave empty for free models…'
776
+ : template.apiKeyOptional
777
+ ? 'API key (leave empty for local servers without auth)…'
778
+ : 'Paste API key to add this provider…';
779
+ input.autocomplete = 'off';
780
+
781
+ const addBtn = document.createElement('button');
782
+ addBtn.className = 'btn btn-primary';
783
+ addBtn.textContent = 'Add Provider';
784
+
785
+ const feedback = document.createElement('div');
786
+ feedback.className = 'key-feedback';
787
+
788
+ wrap.appendChild(input);
789
+ keyRow.appendChild(wrap);
790
+ keyRow.appendChild(addBtn);
791
+ content.appendChild(keyRow);
792
+ content.appendChild(feedback);
793
+
794
+ addBtn.addEventListener('click', async () => {
795
+ const key = input.value.trim();
796
+ const baseUrl = urlInput ? urlInput.value.trim() : undefined;
797
+ if (!key && !template.anonymousFreeModels && !template.apiKeyOptional) { feedback.textContent = 'Enter an API key first.'; feedback.className = 'key-feedback error'; return; }
798
+ if (urlInput && !baseUrl) { feedback.textContent = 'Enter a base URL.'; feedback.className = 'key-feedback error'; return; }
799
+ addBtn.disabled = true;
800
+ feedback.textContent = key ? 'Validating key and fetching models…' : 'Fetching models…';
801
+ feedback.className = 'key-feedback muted';
802
+
803
+ const result = await api('POST', '/api/providers/add', { templateId: template.id, key, baseUrl });
804
+
805
+ addBtn.disabled = false;
806
+ if (result.ok) {
807
+ feedback.textContent = `✓ ${template.name} added · ${result.count} models available`;
808
+ feedback.className = 'key-feedback success';
809
+ // Reload full catalog so the new provider appears in configured list
810
+ state.modelsLoaded = false;
811
+ await loadTemplates();
812
+ await initModels();
813
+ renderProviders();
814
+ showToast(`${template.name} added successfully`);
815
+ } else {
816
+ feedback.textContent = result.error ?? 'Failed to add provider';
817
+ if (result.hint) feedback.textContent += ` — ${result.hint}`;
818
+ feedback.className = 'key-feedback error';
819
+ }
820
+ });
821
+
822
+ return content;
823
+ }
824
+
825
+ function buildProviderBodyContent(provider) {
826
+ if (provider.authType === 'oauth') return buildOAuthProviderBodyContent(provider);
827
+
828
+ const content = document.createElement('div');
829
+ content.className = 'provider-body-content';
830
+
831
+ const keyRow = document.createElement('div');
832
+ keyRow.className = 'key-row';
833
+
834
+ const wrap = document.createElement('div');
835
+ wrap.className = 'key-input-wrap';
836
+
837
+ const input = document.createElement('input');
838
+ input.type = 'password';
839
+ input.className = 'key-input';
840
+ input.placeholder = provider.hasKey ? '•••••••• (key already stored)' : provider.freeAccess ? 'Add API key for premium models…' : 'Paste API key…';
841
+ input.autocomplete = 'off';
842
+
843
+ const testBtn = document.createElement('button');
844
+ testBtn.className = 'btn btn-primary';
845
+ testBtn.textContent = 'Test & Refresh';
846
+
847
+ const feedback = document.createElement('div');
848
+ feedback.className = 'key-feedback';
849
+
850
+ wrap.appendChild(input);
851
+ keyRow.appendChild(wrap);
852
+ keyRow.appendChild(testBtn);
853
+ content.appendChild(keyRow);
854
+ content.appendChild(feedback);
855
+
856
+ async function doSave(key) {
857
+ if (!key.trim()) return;
858
+ const result = await saveKey(provider.id, key);
859
+ if (result.ok) {
860
+ feedback.textContent = '✓ Key saved to keychain';
861
+ feedback.className = 'key-feedback success';
862
+ provider.hasKey = true;
863
+ const chip = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .status-chip`);
864
+ if (chip) { chip.className = 'status-chip has-key'; chip.textContent = 'Key stored'; }
865
+ } else {
866
+ feedback.textContent = result.error ?? 'Failed to save key';
867
+ feedback.className = 'key-feedback error';
868
+ }
869
+ setTimeout(() => { feedback.textContent = ''; feedback.className = 'key-feedback'; }, 3500);
870
+ }
871
+
872
+ input.addEventListener('blur', () => { if (input.value) doSave(input.value); });
873
+
874
+ testBtn.addEventListener('click', async () => {
875
+ if (input.value) await doSave(input.value);
876
+ feedback.textContent = 'Refreshing…';
877
+ feedback.className = 'key-feedback muted';
878
+ testBtn.disabled = true;
879
+ const result = await refreshProvider(provider.id);
880
+ testBtn.disabled = false;
881
+ if (result.ok) {
882
+ feedback.textContent = `✓ ${result.count} models available`;
883
+ feedback.className = 'key-feedback success';
884
+ const countEl = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .provider-models-count`);
885
+ if (countEl) countEl.textContent = `${result.count} models`;
886
+ provider.modelCount = result.count;
887
+ } else {
888
+ feedback.textContent = result.error ?? 'Refresh failed';
889
+ feedback.className = 'key-feedback error';
890
+ }
891
+ setTimeout(() => { feedback.textContent = ''; feedback.className = 'key-feedback'; }, 4000);
892
+ });
893
+
894
+ content.appendChild(buildDeleteProviderRow(provider));
895
+ return content;
896
+ }
897
+
898
+ function buildDeleteProviderRow(provider) {
899
+ const wrapper = document.createElement('div');
900
+ wrapper.style.cssText = 'margin-top:12px;padding-top:12px;border-top:1px solid oklch(22% 0.015 265)';
901
+
902
+ const deleteBtn = document.createElement('button');
903
+ deleteBtn.className = 'btn btn-ghost';
904
+ deleteBtn.style.cssText = 'color:oklch(65% 0.22 25);border-color:oklch(65% 0.22 25 / 0.4)';
905
+ deleteBtn.textContent = 'Delete Provider';
906
+
907
+ const confirmPanel = document.createElement('div');
908
+ confirmPanel.style.cssText = 'display:none;align-items:center;gap:10px;flex-wrap:wrap';
909
+
910
+ const confirmMsg = document.createElement('span');
911
+ confirmMsg.style.cssText = 'font-size:13px;color:oklch(65% 0.22 25);flex:1;min-width:160px';
912
+ confirmMsg.textContent = `Remove ${provider.name ?? provider.id}? This cannot be undone.`;
913
+
914
+ const cancelBtn = document.createElement('button');
915
+ cancelBtn.className = 'btn btn-ghost';
916
+ cancelBtn.textContent = 'Cancel';
917
+
918
+ const confirmBtn = document.createElement('button');
919
+ confirmBtn.className = 'btn btn-primary';
920
+ confirmBtn.style.background = 'oklch(65% 0.22 25)';
921
+ confirmBtn.textContent = 'Yes, Delete';
922
+
923
+ confirmPanel.appendChild(confirmMsg);
924
+ confirmPanel.appendChild(cancelBtn);
925
+ confirmPanel.appendChild(confirmBtn);
926
+
927
+ wrapper.appendChild(deleteBtn);
928
+ wrapper.appendChild(confirmPanel);
929
+
930
+ deleteBtn.addEventListener('click', () => {
931
+ deleteBtn.style.display = 'none';
932
+ confirmPanel.style.display = 'flex';
933
+ });
934
+
935
+ cancelBtn.addEventListener('click', () => {
936
+ confirmPanel.style.display = 'none';
937
+ deleteBtn.style.display = '';
938
+ });
939
+
940
+ confirmBtn.addEventListener('click', async () => {
941
+ confirmBtn.disabled = true;
942
+ cancelBtn.disabled = true;
943
+ const result = await api('POST', '/api/providers/delete', { providerId: provider.id });
944
+ if (result.ok) {
945
+ showToast(`${result.name ?? provider.id} removed`);
946
+ state.modelsLoaded = false;
947
+ await loadTemplates();
948
+ await initModels();
949
+ renderProviders();
950
+ } else {
951
+ confirmBtn.disabled = false;
952
+ cancelBtn.disabled = false;
953
+ confirmPanel.style.display = 'none';
954
+ deleteBtn.style.display = '';
955
+ showToast(result.error ?? 'Delete failed');
956
+ }
957
+ });
958
+
959
+ return wrapper;
960
+ }
961
+
962
+ function buildOAuthProviderBodyContent(provider) {
963
+ const content = document.createElement('div');
964
+ content.className = 'provider-body-content';
965
+
966
+ const status = document.createElement('div');
967
+ status.style.cssText = 'font-size:13px;color:var(--text-secondary,#aaa);margin-bottom:10px';
968
+ status.textContent = provider.hasKey
969
+ ? 'Signed in via OAuth.'
970
+ : 'Not signed in. Click below to start the device authorization flow.';
971
+
972
+ const actionRow = document.createElement('div');
973
+ actionRow.className = 'key-row';
974
+ actionRow.style.flexWrap = 'wrap';
975
+ actionRow.style.gap = '8px';
976
+
977
+ const signInBtn = document.createElement('button');
978
+ signInBtn.className = 'btn btn-primary';
979
+ signInBtn.textContent = provider.hasKey ? 'Re-authenticate' : 'Sign in';
980
+
981
+ const refreshBtn = document.createElement('button');
982
+ refreshBtn.className = 'btn btn-ghost';
983
+ refreshBtn.textContent = 'Refresh Models';
984
+ refreshBtn.style.display = provider.hasKey ? '' : 'none';
985
+
986
+ const feedback = document.createElement('div');
987
+ feedback.className = 'key-feedback';
988
+
989
+ actionRow.appendChild(signInBtn);
990
+ actionRow.appendChild(refreshBtn);
991
+ content.appendChild(status);
992
+ content.appendChild(actionRow);
993
+ content.appendChild(feedback);
994
+ content.appendChild(buildDeleteProviderRow(provider));
995
+
996
+ let pollInterval = null;
997
+ function stopPolling() {
998
+ if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
999
+ }
1000
+
1001
+ refreshBtn.addEventListener('click', async () => {
1002
+ refreshBtn.disabled = true;
1003
+ feedback.textContent = 'Fetching latest models…';
1004
+ feedback.className = 'key-feedback muted';
1005
+ const result = await refreshProvider(provider.id);
1006
+ refreshBtn.disabled = false;
1007
+ if (result.ok) {
1008
+ feedback.textContent = `✓ ${result.count} models`;
1009
+ feedback.className = 'key-feedback success';
1010
+ const countEl = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .provider-models-count`);
1011
+ if (countEl) countEl.textContent = `${result.count} models`;
1012
+ } else {
1013
+ feedback.textContent = result.error ?? 'Refresh failed';
1014
+ feedback.className = 'key-feedback error';
1015
+ }
1016
+ setTimeout(() => { feedback.textContent = ''; feedback.className = 'key-feedback'; }, 4000);
1017
+ });
1018
+
1019
+ signInBtn.addEventListener('click', async () => {
1020
+ stopPolling();
1021
+ signInBtn.disabled = true;
1022
+ refreshBtn.disabled = true;
1023
+ feedback.textContent = 'Starting authorization…';
1024
+ feedback.className = 'key-feedback muted';
1025
+
1026
+ const startResult = await api('POST', '/api/providers/oauth/start', { providerId: provider.id });
1027
+ if (startResult.error) {
1028
+ feedback.textContent = startResult.error;
1029
+ feedback.className = 'key-feedback error';
1030
+ signInBtn.disabled = false;
1031
+ refreshBtn.disabled = false;
1032
+ return;
1033
+ }
1034
+
1035
+ const { sessionId, url, userCode } = startResult;
1036
+ window.open(url, '_blank');
1037
+ feedback.innerHTML = `Go to <a href="${url}" target="_blank" style="color:inherit">${url}</a><br>
1038
+ Enter code: <strong style="letter-spacing:0.1em;font-size:15px">${userCode}</strong><br>
1039
+ <span style="opacity:0.6;font-size:12px">Waiting for authorization…</span>`;
1040
+ feedback.className = 'key-feedback muted';
1041
+
1042
+ pollInterval = setInterval(async () => {
1043
+ const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
1044
+ if (statusResult.status === 'done') {
1045
+ stopPolling();
1046
+ feedback.textContent = '✓ Signed in successfully';
1047
+ feedback.className = 'key-feedback success';
1048
+ provider.hasKey = true;
1049
+ status.textContent = 'Signed in via OAuth.';
1050
+ signInBtn.textContent = 'Re-authenticate';
1051
+ signInBtn.disabled = false;
1052
+ refreshBtn.style.display = '';
1053
+ refreshBtn.disabled = false;
1054
+ const chip = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .status-chip`);
1055
+ if (chip) { chip.className = 'status-chip has-key'; chip.textContent = 'Key stored'; }
1056
+ showToast(`Signed in to ${provider.name ?? provider.id}`);
1057
+ state.modelsLoaded = false;
1058
+ initModels().then(() => { renderProviders(); renderFavList(); });
1059
+ } else if (statusResult.status === 'error') {
1060
+ stopPolling();
1061
+ feedback.textContent = statusResult.error ?? 'Authorization failed';
1062
+ feedback.className = 'key-feedback error';
1063
+ signInBtn.disabled = false;
1064
+ refreshBtn.disabled = false;
1065
+ } else if (statusResult.error) {
1066
+ stopPolling();
1067
+ feedback.textContent = 'Session expired — please try again';
1068
+ feedback.className = 'key-feedback error';
1069
+ signInBtn.disabled = false;
1070
+ refreshBtn.disabled = false;
1071
+ }
1072
+ }, 3000);
1073
+ });
1074
+
1075
+ return content;
1076
+ }
1077
+
1078
+ // ─── Model search results ─────────────────────────────────────────────────────
1079
+
1080
+ function buildModelResults(filter, listType) {
1081
+ const containerId = listType === 'agy' ? 'agy-results' : 'model-results';
1082
+ const container = document.getElementById(containerId);
1083
+ const currentFavs = listType === 'agy' ? state.agyFavorites : state.generalFavorites;
1084
+ const atCapacity = listType === 'agy' && currentFavs.length >= AGY_MAX;
1085
+ const freeOnly = listType === 'agy' ? state.agyFreeOnly : state.modelFreeOnly;
1086
+
1087
+ if (!filter) { container.hidden = true; return; }
1088
+ container.hidden = false;
1089
+
1090
+ if (state.modelsError) {
1091
+ container.innerHTML = `<div class="model-results-error"><span>${state.modelsError}</span><button class="btn btn-ghost">Retry</button></div>`;
1092
+ container.querySelector('button')?.addEventListener('click', () => {
1093
+ state.modelsError = null; state.modelsLoaded = false;
1094
+ initModels().then(() => buildModelResults(filter, listType));
1095
+ });
1096
+ return;
1097
+ }
1098
+
1099
+ if (!state.modelsLoaded) {
1100
+ container.innerHTML = Array(3).fill('<div class="skeleton" style="height:36px;margin:4px 12px;border-radius:6px"></div>').join('');
1101
+ return;
1102
+ }
1103
+
1104
+ const q = filter.trim().toLowerCase();
1105
+ const base = state.allModels.filter(m => !freeOnly || isFreeModel(m));
1106
+ const matched = base.filter(m =>
1107
+ !q ||
1108
+ m.id.toLowerCase().includes(q) ||
1109
+ (m.name && m.name.toLowerCase().includes(q)) ||
1110
+ m.providerName.toLowerCase().includes(q)
1111
+ ).slice(0, freeOnly && !q ? 80 : 40);
1112
+
1113
+ if (matched.length === 0) {
1114
+ container.innerHTML = `<div class="model-results-empty">${freeOnly ? 'No free models found.' : 'No models found.'}</div>`;
1115
+ return;
1116
+ }
1117
+
1118
+ const groups = new Map();
1119
+ for (const m of matched) {
1120
+ if (!groups.has(m.providerId)) groups.set(m.providerId, { name: m.providerName, models: [] });
1121
+ groups.get(m.providerId).models.push(m);
1122
+ }
1123
+
1124
+ container.innerHTML = '';
1125
+ for (const [providerId, { name, models }] of groups) {
1126
+ const [c1, c2] = providerPalette(providerId);
1127
+ const initial = providerInitial(name);
1128
+
1129
+ const groupHeader = document.createElement('div');
1130
+ groupHeader.className = 'model-group-header';
1131
+ groupHeader.innerHTML = `<div class="model-group-icon" style="background:linear-gradient(135deg,${c1},${c2})">${initial}</div>${name}`;
1132
+ container.appendChild(groupHeader);
1133
+
1134
+ for (const m of models) {
1135
+ const isFav = currentFavs.some(f => f.providerId === m.providerId && f.modelId === m.id);
1136
+ const row = document.createElement('div');
1137
+ row.className = 'model-result-row';
1138
+
1139
+ const idEl = document.createElement('span');
1140
+ idEl.className = 'model-result-id';
1141
+ idEl.textContent = m.id;
1142
+ idEl.title = m.id;
1143
+
1144
+ const ctxEl = document.createElement('span');
1145
+ ctxEl.className = 'model-result-ctx';
1146
+ ctxEl.textContent = fmtCtx(m.contextWindow);
1147
+ const badgeEl = document.createElement('span');
1148
+ badgeEl.className = `free-badge ${m.freeStatus === 'free_provider' ? 'dev-access' : ''}`;
1149
+ badgeEl.textContent = freeBadgeLabel(m);
1150
+
1151
+ const addBtn = document.createElement('button');
1152
+ addBtn.className = 'btn-add' + (isFav ? ' already-added' : '');
1153
+ addBtn.textContent = isFav ? '✓' : '+';
1154
+ addBtn.disabled = isFav || (!isFav && atCapacity);
1155
+ addBtn.title = atCapacity && !isFav ? `Antigravity is full (${AGY_MAX}/${AGY_MAX})` : (isFav ? 'Already added' : 'Add to favorites');
1156
+ if (!isFav && !atCapacity) {
1157
+ addBtn.addEventListener('click', () => {
1158
+ addToFavorites({ providerId: m.providerId, modelId: m.id }, listType);
1159
+ buildModelResults(filter, listType);
1160
+ });
1161
+ }
1162
+
1163
+ row.appendChild(idEl);
1164
+ if (isFreeModel(m)) row.appendChild(badgeEl);
1165
+ row.appendChild(ctxEl);
1166
+ row.appendChild(addBtn);
1167
+ container.appendChild(row);
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ // ─── Favorites CRUD ───────────────────────────────────────────────────────────
1173
+
1174
+ function addToFavorites(fav, listType) {
1175
+ if (listType === 'agy') {
1176
+ if (state.agyFavorites.length >= AGY_MAX) return;
1177
+ state.agyFavorites = [...state.agyFavorites, fav];
1178
+ saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1179
+ renderAgyList();
1180
+ updateAgyCounter();
1181
+ } else {
1182
+ state.generalFavorites = [...state.generalFavorites, fav];
1183
+ saveFavorites({ favoriteModels: state.generalFavorites });
1184
+ renderFavList();
1185
+ }
1186
+ }
1187
+
1188
+ function removeFromFavorites(index, listType) {
1189
+ if (listType === 'agy') {
1190
+ const prev = [...state.agyFavorites];
1191
+ state.agyFavorites = state.agyFavorites.filter((_, i) => i !== index);
1192
+ saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1193
+ renderAgyList(); updateAgyCounter();
1194
+ showToast('Removed from Antigravity', () => {
1195
+ state.agyFavorites = prev;
1196
+ saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1197
+ renderAgyList(); updateAgyCounter();
1198
+ });
1199
+ } else {
1200
+ const prev = [...state.generalFavorites];
1201
+ state.generalFavorites = state.generalFavorites.filter((_, i) => i !== index);
1202
+ saveFavorites({ favoriteModels: state.generalFavorites });
1203
+ renderFavList();
1204
+ showToast('Removed from favorites', () => {
1205
+ state.generalFavorites = prev;
1206
+ saveFavorites({ favoriteModels: state.generalFavorites });
1207
+ renderFavList();
1208
+ });
1209
+ }
1210
+ }
1211
+
1212
+ function reorderFavorites(from, to, listType) {
1213
+ const arr = listType === 'agy' ? [...state.agyFavorites] : [...state.generalFavorites];
1214
+ const prev = [...arr];
1215
+ const [item] = arr.splice(from, 1);
1216
+ arr.splice(to, 0, item);
1217
+ if (listType === 'agy') {
1218
+ state.agyFavorites = arr;
1219
+ saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1220
+ renderAgyList();
1221
+ } else {
1222
+ state.generalFavorites = arr;
1223
+ saveFavorites({ favoriteModels: state.generalFavorites });
1224
+ renderFavList();
1225
+ }
1226
+ showToast('Order saved', () => {
1227
+ if (listType === 'agy') {
1228
+ state.agyFavorites = prev;
1229
+ saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1230
+ renderAgyList();
1231
+ } else {
1232
+ state.generalFavorites = prev;
1233
+ saveFavorites({ favoriteModels: state.generalFavorites });
1234
+ renderFavList();
1235
+ }
1236
+ });
1237
+ }
1238
+
1239
+ // ─── Fav item ────────────────────────────────────────────────────────────────
1240
+
1241
+ function buildFavItem(fav, index, listType) {
1242
+ const item = document.createElement('div');
1243
+ item.className = 'fav-item slide-in';
1244
+ item.setAttribute('draggable', 'true');
1245
+ item.setAttribute('role', 'listitem');
1246
+ item.setAttribute('tabindex', '0');
1247
+ item.dataset.index = String(index);
1248
+
1249
+ const handle = document.createElement('span');
1250
+ handle.className = 'drag-handle';
1251
+ handle.setAttribute('aria-hidden', 'true');
1252
+ handle.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="8" y="4" width="3" height="3" rx="1"/><rect x="13" y="4" width="3" height="3" rx="1"/><rect x="8" y="10" width="3" height="3" rx="1"/><rect x="13" y="10" width="3" height="3" rx="1"/><rect x="8" y="16" width="3" height="3" rx="1"/><rect x="13" y="16" width="3" height="3" rx="1"/></svg>`;
1253
+
1254
+ const neon = modelNeon(fav.modelId);
1255
+
1256
+ const rank = document.createElement('span');
1257
+ rank.className = 'fav-rank';
1258
+ rank.textContent = String(index + 1);
1259
+ rank.style.background = neon.bg;
1260
+ rank.style.color = neon.color;
1261
+ rank.style.borderColor = neon.border;
1262
+ rank.style.boxShadow = `0 0 8px ${neon.border}`;
1263
+
1264
+ const idEl = document.createElement('span');
1265
+ idEl.className = 'fav-model-id';
1266
+ idEl.textContent = fav.modelId;
1267
+ idEl.title = fav.modelId;
1268
+
1269
+ const provBadge = document.createElement('span');
1270
+ provBadge.className = 'fav-provider-badge';
1271
+ provBadge.textContent = getProviderName(fav.providerId);
1272
+ // Colored pill using the provider's palette
1273
+ const [c1] = providerPalette(fav.providerId);
1274
+ provBadge.style.color = c1;
1275
+ provBadge.style.background = c1.replace(')', ' / 0.1)');
1276
+ provBadge.style.borderColor = c1.replace(')', ' / 0.3)');
1277
+
1278
+ const removeBtn = document.createElement('button');
1279
+ removeBtn.className = 'btn-remove';
1280
+ removeBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
1281
+ removeBtn.setAttribute('aria-label', `Remove ${fav.modelId}`);
1282
+ removeBtn.addEventListener('click', () => removeFromFavorites(index, listType));
1283
+
1284
+ item.appendChild(handle);
1285
+ item.appendChild(rank);
1286
+ item.appendChild(idEl);
1287
+ item.appendChild(provBadge);
1288
+ item.appendChild(removeBtn);
1289
+
1290
+ // Drag-and-drop
1291
+ item.addEventListener('dragstart', e => {
1292
+ e.dataTransfer.effectAllowed = 'move';
1293
+ e.dataTransfer.setData('text/plain', String(index));
1294
+ requestAnimationFrame(() => item.classList.add('dragging'));
1295
+ });
1296
+
1297
+ item.addEventListener('dragend', () => {
1298
+ item.classList.remove('dragging');
1299
+ document.querySelectorAll('.fav-item').forEach(el => el.classList.remove('drag-over'));
1300
+ });
1301
+
1302
+ item.addEventListener('dragover', e => {
1303
+ e.preventDefault();
1304
+ e.dataTransfer.dropEffect = 'move';
1305
+ document.querySelectorAll('.fav-item').forEach(el => el.classList.remove('drag-over'));
1306
+ item.classList.add('drag-over');
1307
+ });
1308
+
1309
+ item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
1310
+
1311
+ item.addEventListener('drop', e => {
1312
+ e.preventDefault();
1313
+ item.classList.remove('drag-over');
1314
+ const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
1315
+ if (!isNaN(from) && from !== index) reorderFavorites(from, index, listType);
1316
+ });
1317
+
1318
+ // Keyboard reorder
1319
+ item.addEventListener('keydown', e => {
1320
+ const arr = listType === 'agy' ? state.agyFavorites : state.generalFavorites;
1321
+ if (e.altKey && e.key === 'ArrowUp' && index > 0) { e.preventDefault(); reorderFavorites(index, index - 1, listType); }
1322
+ else if (e.altKey && e.key === 'ArrowDown' && index < arr.length - 1) { e.preventDefault(); reorderFavorites(index, index + 1, listType); }
1323
+ });
1324
+
1325
+ return item;
1326
+ }
1327
+
1328
+ function renderFavList() {
1329
+ const list = document.getElementById('favorites-list');
1330
+ list.innerHTML = '';
1331
+ if (state.generalFavorites.length === 0) {
1332
+ list.innerHTML = '<div class="fav-empty">No favorites yet. Search above to add your first.</div>';
1333
+ } else {
1334
+ state.generalFavorites.forEach((f, i) => list.appendChild(buildFavItem(f, i, 'general')));
1335
+ }
1336
+ updateGeneralCounter();
1337
+ }
1338
+
1339
+ function updateGeneralCounter() {
1340
+ const count = state.generalFavorites.length;
1341
+
1342
+ const pips = document.getElementById('gen-pips');
1343
+ if (pips) {
1344
+ pips.innerHTML = '';
1345
+ for (let i = 0; i < GENERAL_MAX; i++) {
1346
+ const pip = document.createElement('div');
1347
+ pip.className = 'agy-pip' + (i < count ? ' filled' : '');
1348
+ pips.appendChild(pip);
1349
+ }
1350
+ }
1351
+
1352
+ const counter = document.getElementById('gen-counter');
1353
+ if (counter) counter.innerHTML = `${count}<span class="agy-slot-max">/${GENERAL_MAX}</span>`;
1354
+ }
1355
+
1356
+ function renderAgyList() {
1357
+ const list = document.getElementById('agy-list');
1358
+ list.innerHTML = '';
1359
+ if (state.agyFavorites.length === 0) {
1360
+ list.innerHTML = '<div class="fav-empty">No Antigravity favorites yet. Search above to add your first.</div>';
1361
+ return;
1362
+ }
1363
+ state.agyFavorites.forEach((f, i) => list.appendChild(buildFavItem(f, i, 'agy')));
1364
+ }
1365
+
1366
+ function updateAgyCounter() {
1367
+ const count = state.agyFavorites.length;
1368
+
1369
+ // Section counter
1370
+ const counter = document.getElementById('agy-counter');
1371
+ if (counter) counter.innerHTML = `${count}<span class="agy-slot-max">/6</span>`;
1372
+
1373
+ // Slot pips
1374
+ const pips = document.getElementById('agy-pips');
1375
+ if (pips) {
1376
+ pips.innerHTML = '';
1377
+ for (let i = 0; i < AGY_MAX; i++) {
1378
+ const pip = document.createElement('div');
1379
+ pip.className = 'agy-pip' + (i < count ? ' filled' : '');
1380
+ pips.appendChild(pip);
1381
+ }
1382
+ }
1383
+
1384
+ // Sidebar badge
1385
+ const badge = document.getElementById('nav-agy-badge');
1386
+ if (badge) {
1387
+ badge.textContent = `${count}/${AGY_MAX}`;
1388
+ badge.classList.toggle('full', count >= AGY_MAX);
1389
+ }
1390
+ }
1391
+
1392
+ // ─── Sidebar nav highlight ────────────────────────────────────────────────────
1393
+
1394
+ function initNav() {
1395
+ const sectionIds = ['providers', 'favorites', 'antigravity', 'apps', 'server'];
1396
+ const navItems = document.querySelectorAll('.nav-item');
1397
+ const content = document.getElementById('content');
1398
+
1399
+ // Always start on Providers & Keys — hardcoded, no computed check at mount.
1400
+ // The scroll listener updates this as the user scrolls.
1401
+ function setActive(id) {
1402
+ navItems.forEach(n => n.classList.toggle('active', n.dataset.section === id));
1403
+ }
1404
+ setActive('providers');
1405
+
1406
+ content.addEventListener('scroll', () => {
1407
+ const contentTop = content.getBoundingClientRect().top;
1408
+ const threshold = content.clientHeight * 0.4;
1409
+ let activeId = 'providers';
1410
+ for (const id of sectionIds) {
1411
+ const el = document.getElementById(id);
1412
+ if (!el) continue;
1413
+ const elTop = el.getBoundingClientRect().top - contentTop;
1414
+ if (elTop < threshold) activeId = id;
1415
+ }
1416
+ setActive(activeId);
1417
+ }, { passive: true });
1418
+ }
1419
+
1420
+ // ─── Provider stats ──────────────────────────────────────────────────────────
1421
+
1422
+ function renderProviderStats() {
1423
+ const el = document.getElementById('provider-stats');
1424
+ if (!el) return;
1425
+ const providerCount = state.providers.length;
1426
+ const modelCount = state.allModels.length;
1427
+ el.innerHTML = `
1428
+ <div class="provider-stat">
1429
+ <div class="provider-stat-value"><span>${providerCount}</span></div>
1430
+ <div class="provider-stat-label">providers configured</div>
1431
+ </div>
1432
+ <div class="provider-stat">
1433
+ <div class="provider-stat-value"><span>${modelCount}</span></div>
1434
+ <div class="provider-stat-label">models available</div>
1435
+ </div>
1436
+ `;
1437
+ }
1438
+
1439
+ // ─── Dark / light mode toggle ─────────────────────────────────────────────────
1440
+
1441
+ function initTheme() {
1442
+ const saved = localStorage.getItem('anygate-theme');
1443
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
1444
+ const isDark = saved ? saved === 'dark' : prefersDark;
1445
+ document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
1446
+ updateThemeIcon(isDark);
1447
+ }
1448
+
1449
+ function toggleTheme() {
1450
+ const current = document.documentElement.getAttribute('data-theme');
1451
+ const next = current === 'dark' ? 'light' : 'dark';
1452
+ document.documentElement.setAttribute('data-theme', next);
1453
+ localStorage.setItem('anygate-theme', next);
1454
+ updateThemeIcon(next === 'dark');
1455
+ }
1456
+
1457
+ function updateThemeIcon(isDark) {
1458
+ const btn = document.getElementById('theme-toggle');
1459
+ if (!btn) return;
1460
+ const iconEl = btn.querySelector('.theme-icon');
1461
+ const labelEl = btn.querySelector('.theme-label');
1462
+ if (iconEl) iconEl.innerHTML = isDark
1463
+ ? `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`
1464
+ : `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`;
1465
+ if (labelEl) labelEl.textContent = isDark ? 'Light mode' : 'Dark mode';
1466
+ }
1467
+
1468
+ // ─── Bootstrap ────────────────────────────────────────────────────────────────
1469
+
1470
+ async function initModels() {
1471
+ state.modelsError = null;
1472
+ try {
1473
+ await loadModels();
1474
+ state.modelsLoaded = true;
1475
+ } catch (err) {
1476
+ state.modelsError = String(err);
1477
+ state.modelsLoaded = true;
1478
+ }
1479
+ }
1480
+
1481
+ async function init() {
1482
+ initTheme();
1483
+ initNav();
1484
+ void initUpdateIndicator();
1485
+
1486
+ document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
1487
+
1488
+ await loadConfig();
1489
+ renderFavList();
1490
+ renderAgyList();
1491
+ updateAgyCounter();
1492
+ updateGeneralCounter();
1493
+ await loadApps();
1494
+ renderApps();
1495
+ renderProviders(); // shows skeletons
1496
+
1497
+ initServerPolling();
1498
+
1499
+ // Load templates in parallel with models — re-render providers when done
1500
+ loadTemplates().then(() => renderProviders());
1501
+
1502
+ initModels().then(() => {
1503
+ renderProviders();
1504
+ renderProviderStats();
1505
+ renderApps();
1506
+ // Re-render favorites now that we have full provider names
1507
+ renderFavList();
1508
+ renderAgyList();
1509
+ if (state.modelFilter) buildModelResults(state.modelFilter, 'general');
1510
+ if (state.agyFilter) buildModelResults(state.agyFilter, 'agy');
1511
+ });
1512
+
1513
+ document.getElementById('provider-search').addEventListener('input', e => {
1514
+ state.providerFilter = e.target.value;
1515
+ renderProviders();
1516
+ });
1517
+
1518
+ document.getElementById('refresh-all-btn').addEventListener('click', async () => {
1519
+ const btn = document.getElementById('refresh-all-btn');
1520
+ const status = document.getElementById('refresh-all-status');
1521
+ btn.disabled = true;
1522
+ btn.classList.add('loading');
1523
+ status.hidden = false;
1524
+ status.textContent = 'Refreshing all providers…';
1525
+
1526
+ const result = await api('POST', '/api/providers/refresh-all');
1527
+
1528
+ btn.disabled = false;
1529
+ btn.classList.remove('loading');
1530
+
1531
+ if (result.ok) {
1532
+ const succeeded = result.providers.filter(p => p.ok && !p.skipped);
1533
+ const skipped = result.providers.filter(p => p.skipped);
1534
+ const failed = result.providers.filter(p => !p.ok && !p.skipped);
1535
+
1536
+ // Reload filtered catalog first so the model count matches what search shows
1537
+ state.modelsLoaded = false;
1538
+ await initModels();
1539
+ renderProviders();
1540
+ if (state.modelFilter) buildModelResults(state.modelFilter, 'general');
1541
+ if (state.agyFilter) buildModelResults(state.agyFilter, 'agy');
1542
+
1543
+ // Build status panel — use filtered count so it matches the search
1544
+ status.innerHTML = '';
1545
+
1546
+ const oauthSkipped = result.providers.filter(p => p.oauthWarning);
1547
+ const realFailed = failed.filter(p => !p.oauthWarning);
1548
+ const allSkipped = skipped.length + oauthSkipped.length;
1549
+
1550
+ const summary = document.createElement('div');
1551
+ summary.textContent = `✓ Refreshed ${succeeded.length} provider${succeeded.length !== 1 ? 's' : ''}`
1552
+ + (allSkipped ? ` · ${allSkipped} skipped` : '')
1553
+ + (realFailed.length ? ` · ${realFailed.length} failed` : '')
1554
+ + ` · ${state.allModels.length} models available`;
1555
+ status.appendChild(summary);
1556
+
1557
+ if (oauthSkipped.length > 0 || realFailed.length > 0) {
1558
+ const detailSection = document.createElement('div');
1559
+ detailSection.style.marginTop = '8px';
1560
+ detailSection.style.paddingTop = '8px';
1561
+ detailSection.style.borderTop = '1px solid oklch(22% 0.015 265)';
1562
+
1563
+ for (const p of oauthSkipped) {
1564
+ const row = document.createElement('div');
1565
+ row.style.color = 'oklch(80% 0.18 75)'; // amber — warning, not error
1566
+ row.style.fontSize = '14px';
1567
+ row.style.marginTop = '4px';
1568
+ row.textContent = `⚠ ${p.name}: OAuth — model list refresh not available via API. Connection is still active.`;
1569
+ detailSection.appendChild(row);
1570
+ }
1571
+
1572
+ for (const p of realFailed) {
1573
+ const row = document.createElement('div');
1574
+ row.style.color = 'oklch(68% 0.22 25)'; // red — actual failure
1575
+ row.style.fontSize = '14px';
1576
+ row.style.marginTop = '4px';
1577
+ row.textContent = `✗ ${p.name}: ${p.reason ?? 'Unknown error'}`;
1578
+ detailSection.appendChild(row);
1579
+ }
1580
+
1581
+ status.appendChild(detailSection);
1582
+ }
1583
+
1584
+ // Auto-hide after longer delay if there are real failures
1585
+ setTimeout(() => { status.hidden = true; }, realFailed.length > 0 ? 12000 : 6000);
1586
+ } else {
1587
+ status.textContent = `✗ Refresh failed: ${result.error ?? 'Unknown error'}`;
1588
+ setTimeout(() => { status.hidden = true; }, 5000);
1589
+ }
1590
+ });
1591
+
1592
+ document.getElementById('model-search').addEventListener('input', e => {
1593
+ state.modelFilter = e.target.value;
1594
+ buildModelResults(state.modelFilter, 'general');
1595
+ });
1596
+ document.getElementById('model-free-only')?.addEventListener('change', e => {
1597
+ state.modelFreeOnly = e.target.checked;
1598
+ buildModelResults(state.modelFilter, 'general');
1599
+ });
1600
+
1601
+ document.getElementById('agy-search').addEventListener('input', e => {
1602
+ state.agyFilter = e.target.value;
1603
+ buildModelResults(state.agyFilter, 'agy');
1604
+ });
1605
+ document.getElementById('agy-free-only')?.addEventListener('change', e => {
1606
+ state.agyFreeOnly = e.target.checked;
1607
+ buildModelResults(state.agyFilter, 'agy');
1608
+ });
1609
+
1610
+ document.getElementById('app-model-search')?.addEventListener('input', e => {
1611
+ state.appModelFilter = e.target.value;
1612
+ renderApps();
1613
+ });
1614
+ document.getElementById('app-free-only')?.addEventListener('change', e => {
1615
+ state.appFreeOnly = e.target.checked;
1616
+ renderApps();
1617
+ });
1618
+ }
1619
+
1620
+ init();
1621
+
1622
+ // ─── Applications Control Center ──────────────────────────────────────────────
1623
+
1624
+ state.apps = [];
1625
+
1626
+ async function loadApps() {
1627
+ try {
1628
+ const data = await api('GET', '/api/apps');
1629
+ if (data && data.apps) {
1630
+ state.apps = data.apps;
1631
+ state.recentLaunchFolders = data.recentLaunchFolders ?? [];
1632
+ for (const app of state.apps) {
1633
+ if (!state.appLaunchFolders[app.id]) {
1634
+ state.appLaunchFolders[app.id] = state.recentLaunchFolders[0] ?? '';
1635
+ }
1636
+ }
1637
+ }
1638
+ } catch (err) {
1639
+ console.error('Failed to load apps:', err);
1640
+ }
1641
+ }
1642
+
1643
+ function getAppSelection(appId) {
1644
+ return state.appSelections[appId] ?? { mode: 'default', label: 'Choose at launch' };
1645
+ }
1646
+
1647
+ function appModelInputValue(appId) {
1648
+ if (state.appModelOpen === appId) return state.appModelFilters[appId] ?? '';
1649
+ return getAppSelection(appId).label;
1650
+ }
1651
+
1652
+ function matchedAppModels(appId) {
1653
+ const localFilter = state.appModelFilters[appId] ?? '';
1654
+ const q = (localFilter || state.appModelFilter).trim().toLowerCase();
1655
+ const matched = state.allModels.filter(m => {
1656
+ const providerName = getProviderName(m.providerId);
1657
+ if (state.appFreeOnly && !isFreeModel(m)) return false;
1658
+ if (!q) return true;
1659
+ return m.id.toLowerCase().includes(q)
1660
+ || (m.name && m.name.toLowerCase().includes(q))
1661
+ || providerName.toLowerCase().includes(q)
1662
+ || (m.providerName && m.providerName.toLowerCase().includes(q));
1663
+ });
1664
+ return matched.slice(0, 80);
1665
+ }
1666
+
1667
+ function buildAppModelResults(appId) {
1668
+ const matched = matchedAppModels(appId);
1669
+ const groups = new Map();
1670
+ for (const m of matched) {
1671
+ const providerName = getProviderName(m.providerId);
1672
+ if (!groups.has(m.providerId)) {
1673
+ groups.set(m.providerId, { name: providerName, models: [] });
1674
+ }
1675
+ groups.get(m.providerId).models.push(m);
1676
+ }
1677
+
1678
+ if (groups.size === 0) {
1679
+ return '<div class="launch-model-empty">No models match.</div>';
1680
+ }
1681
+
1682
+ return Array.from(groups.entries())
1683
+ .sort((a, b) => a[1].name.localeCompare(b[1].name))
1684
+ .map(([providerId, group]) => {
1685
+ const rows = group.models
1686
+ .slice()
1687
+ .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id))
1688
+ .map(m => {
1689
+ const fav = isGeneralFavorite(m.providerId, m.id);
1690
+ const label = `${fav ? '★ ' : ''}${m.name || m.id}`;
1691
+ const badge = isFreeModel(m) ? `<span class="free-badge ${m.freeStatus === 'free_provider' ? 'dev-access' : ''}">${escapeHtml(freeBadgeLabel(m))}</span>` : '';
1692
+ const encodedProvider = encodeURIComponent(m.providerId);
1693
+ const encodedModel = encodeURIComponent(m.id);
1694
+ return `
1695
+ <button class="launch-model-row" type="button" onclick="selectLaunchModel('${appId}', '${encodedProvider}', '${encodedModel}')">
1696
+ <span class="launch-model-name">${escapeHtml(label)}</span>
1697
+ ${badge}
1698
+ <span class="launch-model-id">${escapeHtml(m.id)}</span>
1699
+ </button>
1700
+ `;
1701
+ })
1702
+ .join('');
1703
+ return `
1704
+ <div class="launch-model-group">
1705
+ <div class="launch-model-group-heading">${escapeHtml(group.name)}</div>
1706
+ ${rows}
1707
+ </div>
1708
+ `;
1709
+ })
1710
+ .join('');
1711
+ }
1712
+
1713
+ function renderApps() {
1714
+ const cliContainer = document.getElementById('apps-list-cli');
1715
+ const desktopContainer = document.getElementById('apps-list-apps');
1716
+ if (!cliContainer || !desktopContainer) return;
1717
+
1718
+ if (state.apps.length === 0) {
1719
+ cliContainer.innerHTML = '<div class="apps-empty">No applications found.</div>';
1720
+ desktopContainer.innerHTML = '';
1721
+ return;
1722
+ }
1723
+
1724
+ const searchSuffix = state.appModelFilter.trim()
1725
+ ? ` matching "${escapeHtml(state.appModelFilter.trim())}"`
1726
+ : '';
1727
+
1728
+ // Per-app icon background overrides (when we have a real icon)
1729
+ const APP_ICON_BG = {
1730
+ claude: '#D97757',
1731
+ codex: '#1a1a2e',
1732
+ gemini: '#1E1E2E',
1733
+ agy: '#111827',
1734
+ antigravity: '#ffffff',
1735
+ 'antigravity-ide': '#000000',
1736
+ 'claude-app': '#D77655',
1737
+ 'codex-app': '#111827',
1738
+ };
1739
+
1740
+ const renderAppCard = (app) => {
1741
+ const statusText = app.installed ? 'Installed' : 'Not Detected';
1742
+ const statusClass = app.installed ? 'provider-badge active' : 'provider-badge';
1743
+ const [iconStart, iconEnd] = providerPalette(app.id);
1744
+ const iconInitial = appIconInitial(app);
1745
+ const hasCustomIcon = APP_ICON_SVGS[app.id] || APP_ICON_IMGS[app.id];
1746
+ const iconBg = hasCustomIcon
1747
+ ? (APP_ICON_BG[app.id] ?? `linear-gradient(135deg, ${iconStart}, ${iconEnd})`)
1748
+ : `linear-gradient(135deg, ${iconStart}, ${iconEnd})`;
1749
+ const modelInputValue = appModelInputValue(app.id);
1750
+ const modelResults = state.appModelOpen === app.id ? buildAppModelResults(app.id) : '';
1751
+ const launchFolder = state.appLaunchFolders[app.id] ?? '';
1752
+ const recentFolders = state.recentLaunchFolders
1753
+ .map(folder => `<button class="launch-folder-chip" type="button" onclick="selectLaunchFolder('${app.id}', '${encodeURIComponent(folder)}')">${escapeHtml(folder)}</button>`)
1754
+ .join('');
1755
+
1756
+ return `
1757
+ <div class="provider-card${state.appModelOpen === app.id ? ' launch-model-open' : ''}" style="opacity: ${app.installed ? 1 : 0.65}; padding: 24px; position: relative; overflow: visible;">
1758
+ <div class="provider-header" style="margin-bottom: 16px; display: flex; align-items: center; gap: 14px;">
1759
+ <div class="provider-icon" style="background: ${iconBg}; display:flex; align-items:center; justify-content:center;">
1760
+ ${getAppIconHtml(app, iconInitial)}
1761
+ </div>
1762
+ <div class="provider-info">
1763
+ <h3 class="provider-name" style="font-size: 1.15rem; font-weight: 600;">${app.name}</h3>
1764
+ <span class="${statusClass}" style="margin-top: 4px; display: inline-block;">${statusText}</span>
1765
+ </div>
1766
+ </div>
1767
+
1768
+ ${app.installed ? `
1769
+ <div class="launch-controls" style="display: flex; flex-direction: column; gap: 12px;">
1770
+ <div class="launch-model-picker">
1771
+ <label style="font-size: 12px; font-weight: 500; color: var(--color-muted);">Model 🧠${searchSuffix}</label>
1772
+ <div class="launch-model-input-wrap">
1773
+ <input id="launch-model-input-${app.id}" class="search-input launch-model-input" value="${escapeHtml(modelInputValue)}" placeholder="Search models..." onfocus="openLaunchModelPicker('${app.id}')" oninput="filterLaunchModels('${app.id}', this.value)">
1774
+ ${state.appModelOpen === app.id ? `
1775
+ <div class="launch-model-menu">
1776
+ <div class="launch-model-quick">
1777
+ <button type="button" onclick="selectLaunchDefault('${app.id}')">Choose at launch</button>
1778
+ <button type="button" onclick="selectLaunchFavorites('${app.id}')">★ Favorites</button>
1779
+ </div>
1780
+ ${modelResults}
1781
+ </div>
1782
+ ` : ''}
1783
+ </div>
1784
+ </div>
1785
+ ${app.type !== 'app' ? `
1786
+ <div class="launch-folder-control">
1787
+ <label style="font-size: 12px; font-weight: 500; color: var(--color-muted);">Launch folder 📁</label>
1788
+ <div style="display: flex; gap: 8px; align-items: center;">
1789
+ <input id="launch-folder-${app.id}" class="search-input launch-folder-input" style="flex: 1;" value="${escapeHtml(launchFolder)}" placeholder="/path/to/codebase" oninput="setLaunchFolder('${app.id}', this.value)">
1790
+ <button class="btn btn-ghost" onclick="browseLaunchFolder('${app.id}')" style="height: 42px; padding: 0 12px; display: flex; align-items: center; justify-content: center; font-size: 16px;" title="Browse Folder">
1791
+ 📂
1792
+ </button>
1793
+ </div>
1794
+ ${recentFolders ? `<div class="launch-folder-recents">${recentFolders}</div>` : ''}
1795
+ </div>
1796
+ ` : ''}
1797
+ <button class="btn btn-primary" onclick="launchApp('${app.id}')" style="width: 100%; height: 38px; font-size: 14px; font-weight: 600;">
1798
+ Launch
1799
+ </button>
1800
+ </div>
1801
+ ` : `
1802
+ <div style="font-size: 14px; color: var(--color-muted); line-height: 1.5; margin-bottom: 20px;">
1803
+ This tool is not installed or not found in your system's PATH variable.
1804
+ </div>
1805
+ <div style="font-size: 12px; color: var(--color-muted); background: oklch(14% 0.005 265); padding: 8px 12px; border-radius: 6px;">
1806
+ Install via npm: <code style="color: var(--color-accent); font-family: monospace;">npm install -g @anygate/cli</code>
1807
+ </div>
1808
+ `}
1809
+ </div>
1810
+ `;
1811
+ };
1812
+
1813
+ const byName = (a, b) => a.name.localeCompare(b.name);
1814
+ const cliApps = state.apps.filter(app => app.type === 'cli').sort(byName);
1815
+ const desktopApps = state.apps.filter(app => app.type !== 'cli').sort(byName);
1816
+
1817
+ const columnHtml = (label, apps) => `
1818
+ <div class="apps-group-heading">${escapeHtml(label)}</div>
1819
+ ${apps.length === 0 ? '<div class="apps-empty">None detected.</div>' : apps.map(renderAppCard).join('')}
1820
+ `;
1821
+
1822
+ cliContainer.innerHTML = columnHtml('CLI', cliApps);
1823
+ desktopContainer.innerHTML = columnHtml('Apps', desktopApps);
1824
+
1825
+ renderAppPathSettings();
1826
+ }
1827
+
1828
+ function renderAppPathSettings() {
1829
+ const container = document.getElementById('app-paths-list');
1830
+ if (!container) return;
1831
+
1832
+ if (state.apps.length === 0) {
1833
+ container.innerHTML = '<div class="advanced-empty">No applications detected yet.</div>';
1834
+ return;
1835
+ }
1836
+
1837
+ container.innerHTML = state.apps.map(app => {
1838
+ const pathValue = app.path ?? '';
1839
+ const sourceLabel = app.pathSource === 'override' ? 'Custom path' : app.path ? 'Auto-detected' : 'Not detected';
1840
+ const statusClass = app.installed ? 'provider-badge active' : 'provider-badge';
1841
+ return `
1842
+ <div class="app-path-row">
1843
+ <div class="app-path-meta">
1844
+ <div class="app-path-name">${escapeHtml(app.name)}</div>
1845
+ <span class="${statusClass}">${sourceLabel}</span>
1846
+ </div>
1847
+ <input id="app-path-input-${app.id}" class="search-input app-path-input" value="${escapeHtml(pathValue)}" placeholder="Path to ${escapeHtml(app.name)}">
1848
+ <div class="app-path-actions">
1849
+ <button class="btn btn-primary" onclick="saveAppPath('${app.id}')">Save</button>
1850
+ <button class="btn btn-ghost" onclick="clearAppPath('${app.id}')" ${app.pathSource === 'override' ? '' : 'disabled'}>Auto</button>
1851
+ </div>
1852
+ </div>
1853
+ `;
1854
+ }).join('');
1855
+ }
1856
+
1857
+ async function launchApp(appId) {
1858
+ const selection = getAppSelection(appId);
1859
+ const body = { appId };
1860
+
1861
+ if (selection.mode === 'favorites') {
1862
+ body.favorites = true;
1863
+ } else if (selection.mode === 'model') {
1864
+ body.providerId = selection.providerId;
1865
+ body.modelId = selection.modelId;
1866
+ }
1867
+
1868
+ const folder = (state.appLaunchFolders[appId] ?? '').trim();
1869
+ if (folder) {
1870
+ body.cwd = folder;
1871
+ }
1872
+
1873
+ // Visual feedback on button
1874
+ const button = document.querySelector(`button[onclick="launchApp('${appId}')"]`);
1875
+ if (!button) return;
1876
+ const originalText = button.textContent;
1877
+ button.disabled = true;
1878
+ button.textContent = 'Launching...';
1879
+
1880
+ try {
1881
+ const res = await api('POST', '/api/apps/launch', body);
1882
+ if (res.ok) {
1883
+ await loadApps();
1884
+ button.textContent = 'Launched!';
1885
+ setTimeout(() => {
1886
+ button.disabled = false;
1887
+ button.textContent = originalText;
1888
+ renderApps();
1889
+ }, 2000);
1890
+ } else {
1891
+ alert('Launch failed: ' + (res.error || 'Unknown error'));
1892
+ button.disabled = false;
1893
+ button.textContent = originalText;
1894
+ }
1895
+ } catch (err) {
1896
+ alert('Launch failed: ' + err);
1897
+ button.disabled = false;
1898
+ button.textContent = originalText;
1899
+ }
1900
+ }
1901
+
1902
+ function updateLaunchModelMenu(appId, clearInput) {
1903
+ const input = document.getElementById(`launch-model-input-${appId}`);
1904
+ if (!input) return false;
1905
+
1906
+ if (clearInput) {
1907
+ input.value = '';
1908
+ }
1909
+
1910
+ const wrap = input.closest('.launch-model-input-wrap');
1911
+ if (!wrap) return false;
1912
+ document.querySelectorAll('.provider-card.launch-model-open').forEach(card => {
1913
+ card.classList.remove('launch-model-open');
1914
+ });
1915
+ wrap.closest('.provider-card')?.classList.add('launch-model-open');
1916
+
1917
+ let menu = wrap.querySelector('.launch-model-menu');
1918
+ if (!menu) {
1919
+ menu = document.createElement('div');
1920
+ menu.className = 'launch-model-menu';
1921
+ wrap.appendChild(menu);
1922
+ }
1923
+
1924
+ const modelResults = buildAppModelResults(appId);
1925
+ menu.innerHTML = `
1926
+ <div class="launch-model-quick">
1927
+ <button type="button" onclick="selectLaunchDefault('${appId}')">Choose at launch</button>
1928
+ <button type="button" onclick="selectLaunchFavorites('${appId}')">★ Favorites</button>
1929
+ </div>
1930
+ ${modelResults}
1931
+ `;
1932
+ return true;
1933
+ }
1934
+
1935
+ function openLaunchModelPicker(appId) {
1936
+ state.appModelOpen = appId;
1937
+ state.appModelFilters[appId] = '';
1938
+ if (!updateLaunchModelMenu(appId, true)) {
1939
+ renderApps();
1940
+ setTimeout(() => document.getElementById(`launch-model-input-${appId}`)?.focus(), 0);
1941
+ }
1942
+ }
1943
+
1944
+ function filterLaunchModels(appId, value) {
1945
+ state.appModelOpen = appId;
1946
+ state.appModelFilters[appId] = value;
1947
+ updateLaunchModelMenu(appId, false);
1948
+ }
1949
+
1950
+ function selectLaunchDefault(appId) {
1951
+ state.appSelections[appId] = { mode: 'default', label: 'Choose at launch' };
1952
+ state.appModelOpen = null;
1953
+ state.appModelFilters[appId] = '';
1954
+ renderApps();
1955
+ }
1956
+
1957
+ function selectLaunchFavorites(appId) {
1958
+ state.appSelections[appId] = { mode: 'favorites', label: '★ Favorites' };
1959
+ state.appModelOpen = null;
1960
+ state.appModelFilters[appId] = '';
1961
+ renderApps();
1962
+ }
1963
+
1964
+ function selectLaunchModel(appId, encodedProviderId, encodedModelId) {
1965
+ const providerId = decodeURIComponent(encodedProviderId);
1966
+ const modelId = decodeURIComponent(encodedModelId);
1967
+ const model = state.allModels.find(m => m.providerId === providerId && m.id === modelId);
1968
+ const favorite = isGeneralFavorite(providerId, modelId);
1969
+ const label = `${favorite ? '★ ' : ''}${model?.name || modelId}`;
1970
+ state.appSelections[appId] = { mode: 'model', providerId, modelId, label };
1971
+ state.appModelOpen = null;
1972
+ state.appModelFilters[appId] = '';
1973
+ renderApps();
1974
+ }
1975
+
1976
+ function setLaunchFolder(appId, value) {
1977
+ state.appLaunchFolders[appId] = value;
1978
+ }
1979
+
1980
+ function selectLaunchFolder(appId, encodedFolder) {
1981
+ state.appLaunchFolders[appId] = decodeURIComponent(encodedFolder);
1982
+ renderApps();
1983
+ }
1984
+
1985
+ async function browseLaunchFolder(appId) {
1986
+ try {
1987
+ const res = await api('POST', '/api/apps/browse-folder');
1988
+ if (res.path) {
1989
+ state.appLaunchFolders[appId] = res.path;
1990
+ renderApps();
1991
+ } else if (res.error) {
1992
+ alert('Failed to open folder picker: ' + res.error);
1993
+ }
1994
+ } catch (err) {
1995
+ alert('Failed to open folder picker: ' + err);
1996
+ }
1997
+ }
1998
+
1999
+ async function saveAppPath(appId) {
2000
+ const input = document.getElementById(`app-path-input-${appId}`);
2001
+ if (!input) return;
2002
+
2003
+ const path = input.value.trim();
2004
+ if (!path) {
2005
+ alert('Enter a path, or click Auto to use auto-detection.');
2006
+ return;
2007
+ }
2008
+
2009
+ const res = await api('POST', '/api/apps/path', { appId, path });
2010
+ if (res.ok) {
2011
+ state.apps = res.apps ?? state.apps;
2012
+ renderApps();
2013
+ } else {
2014
+ alert('Path not saved: ' + (res.error || 'Unknown error'));
2015
+ }
2016
+ }
2017
+
2018
+ async function clearAppPath(appId) {
2019
+ const res = await api('POST', '/api/apps/path', { appId, path: null });
2020
+ if (res.ok) {
2021
+ state.apps = res.apps ?? state.apps;
2022
+ renderApps();
2023
+ } else {
2024
+ alert('Path not cleared: ' + (res.error || 'Unknown error'));
2025
+ }
2026
+ }
2027
+
2028
+ // Expose launchApp to window scope for onclick handlers
2029
+ window.launchApp = launchApp;
2030
+ window.openLaunchModelPicker = openLaunchModelPicker;
2031
+ window.filterLaunchModels = filterLaunchModels;
2032
+ window.selectLaunchDefault = selectLaunchDefault;
2033
+ window.selectLaunchFavorites = selectLaunchFavorites;
2034
+ window.selectLaunchModel = selectLaunchModel;
2035
+ window.setLaunchFolder = setLaunchFolder;
2036
+ window.selectLaunchFolder = selectLaunchFolder;
2037
+ window.browseLaunchFolder = browseLaunchFolder;
2038
+ window.saveAppPath = saveAppPath;
2039
+ window.clearAppPath = clearAppPath;
2040
+
2041
+ // Close the launch model picker when clicking outside
2042
+ document.addEventListener('click', (e) => {
2043
+ if (!state.appModelOpen) return;
2044
+ const wrap = e.target.closest('.launch-model-input-wrap');
2045
+ if (!wrap) {
2046
+ state.appModelOpen = null;
2047
+ renderApps();
2048
+ }
2049
+ });
2050
+
2051
+ // ─── Server Gateway ────────────────────────────────────────────────────────
2052
+ // Runs `anygate server` in-process from the browser: same wizard, same
2053
+ // URLs/model-catalog output, no terminal needed.
2054
+
2055
+ async function refreshServerStatus() {
2056
+ try {
2057
+ const data = await api('GET', '/api/server/status');
2058
+ state.server.status = data;
2059
+ if (!data.running) seedServerFormFromSaved(data.saved);
2060
+ } catch {
2061
+ // Transient poll failure — keep showing the last known state.
2062
+ }
2063
+ renderServerPanel();
2064
+ updateServerNavBadge();
2065
+ }
2066
+
2067
+ function seedServerFormFromSaved(saved) {
2068
+ const f = state.server.form;
2069
+ if (f.seeded || !saved) return;
2070
+ f.seeded = true;
2071
+ // The CLI wizard also allows "expose all providers" (favoritesOnly: false, exposedProviders: null),
2072
+ // which this form has no toggle for. Land on "Specific providers" with nothing pre-checked rather
2073
+ // than silently reinterpreting it as "Favorites only" — a materially narrower, wrong scope.
2074
+ f.expose = saved.favoritesOnly ? 'favorites' : 'specific';
2075
+ f.freeModelsOnly = Boolean(saved.freeModelsOnly);
2076
+ f.exposedProviders = saved.exposedProviders ? [...saved.exposedProviders] : [];
2077
+ f.maskGatewayIds = saved.maskGatewayIds;
2078
+ f.listenMode = saved.listenMode === 'network' ? 'network' : 'local';
2079
+ f.passwordMode = saved.hasSavedPassword ? 'saved' : 'new';
2080
+ if (f.expose === 'specific') loadServerProviders().then(renderServerPanel);
2081
+ }
2082
+
2083
+ function updateServerNavBadge() {
2084
+ const badge = document.getElementById('nav-server-badge');
2085
+ if (!badge) return;
2086
+ badge.hidden = !(state.server.status && state.server.status.running);
2087
+ }
2088
+
2089
+ function initServerPolling() {
2090
+ refreshServerStatus();
2091
+ setInterval(() => {
2092
+ // Skip while the tab is backgrounded — the endpoint reads config/keychain state that
2093
+ // doesn't need sub-second freshness, and there's no reason to keep polling unseen.
2094
+ if (!document.hidden) refreshServerStatus();
2095
+ }, 5000);
2096
+ document.addEventListener('visibilitychange', () => {
2097
+ if (!document.hidden) refreshServerStatus();
2098
+ });
2099
+ }
2100
+
2101
+ async function loadServerProviders() {
2102
+ if (state.server.form.providersLoaded) return;
2103
+ try {
2104
+ const data = await api('GET', '/api/server/providers');
2105
+ state.server.form.providersList = data.providers ?? [];
2106
+ } catch {
2107
+ state.server.form.providersList = [];
2108
+ }
2109
+ state.server.form.providersLoaded = true;
2110
+ }
2111
+
2112
+ function renderServerPanel() {
2113
+ const panel = document.getElementById('server-panel');
2114
+ if (!panel) return;
2115
+ const s = state.server;
2116
+ if (!s.status) {
2117
+ panel.innerHTML = '<div class="skeleton skeleton-card"></div>';
2118
+ return;
2119
+ }
2120
+ panel.innerHTML = s.status.running ? renderServerRunning(s.status) : renderServerSetup(s);
2121
+ }
2122
+
2123
+ function buildServerProviderRows() {
2124
+ const s = state.server;
2125
+ const q = s.form.providerSearch.trim().toLowerCase();
2126
+ const filtered = s.form.providersList.filter(p =>
2127
+ !q || p.name.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)
2128
+ );
2129
+ if (!s.form.providersLoaded) {
2130
+ return Array(3).fill('<div class="skeleton" style="height:36px;margin:4px 0;border-radius:6px"></div>').join('');
2131
+ }
2132
+ if (filtered.length === 0) return '<div class="fav-empty">No providers match.</div>';
2133
+ return filtered.map(p => {
2134
+ const isSel = s.form.exposedProviders.includes(p.id);
2135
+ return `
2136
+ <label class="server-provider-row">
2137
+ <input type="checkbox" ${isSel ? 'checked' : ''} onchange="toggleServerProvider('${p.id}')">
2138
+ <span class="server-provider-row-name">${escapeHtml(p.name)}</span>
2139
+ <span class="server-provider-row-count">${p.modelCount} model${p.modelCount !== 1 ? 's' : ''}</span>
2140
+ </label>
2141
+ `;
2142
+ }).join('');
2143
+ }
2144
+
2145
+ function renderServerProviderPicker() {
2146
+ const s = state.server;
2147
+ const chips = s.form.exposedProviders.map(id => {
2148
+ const p = s.form.providersList.find(x => x.id === id);
2149
+ const label = p ? p.name : id;
2150
+ return `<span class="server-provider-chip">${escapeHtml(label)}<button type="button" onclick="toggleServerProvider('${id}')" aria-label="Remove ${escapeHtml(label)}">&times;</button></span>`;
2151
+ }).join('');
2152
+
2153
+ return `
2154
+ <div class="server-providers-picker">
2155
+ ${chips ? `<div class="server-provider-chips">${chips}</div>` : ''}
2156
+ <div class="search-field">
2157
+ <svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
2158
+ <input type="search" class="search-input" placeholder="Search providers to expose…" value="${escapeHtml(s.form.providerSearch)}" oninput="setServerProviderSearch(this.value)">
2159
+ </div>
2160
+ <div class="server-provider-list">${buildServerProviderRows()}</div>
2161
+ </div>
2162
+ `;
2163
+ }
2164
+
2165
+ function toggleGroupHtml(options) {
2166
+ return `<div class="toggle-group">${options.map(o =>
2167
+ `<button type="button" class="toggle-option ${o.active ? 'active' : ''}" onclick="${o.onClick}">${escapeHtml(o.label)}</button>`
2168
+ ).join('')}</div>`;
2169
+ }
2170
+
2171
+ function renderServerNetworkOptions() {
2172
+ const s = state.server;
2173
+ const f = s.form;
2174
+ const hasSaved = Boolean(s.status?.saved?.hasSavedPassword);
2175
+ const showPasswordInput = f.passwordMode === 'new' || !hasSaved;
2176
+
2177
+ return `
2178
+ <div class="server-network-options">
2179
+ ${hasSaved ? toggleGroupHtml([
2180
+ { label: 'Use saved password', active: f.passwordMode === 'saved', onClick: "setServerPasswordMode('saved')" },
2181
+ { label: 'Enter new password', active: f.passwordMode === 'new', onClick: "setServerPasswordMode('new')" },
2182
+ ]) : ''}
2183
+ ${showPasswordInput ? `
2184
+ <input type="password" class="key-input" placeholder="Server password" value="${escapeHtml(f.password)}" oninput="setServerPassword(this.value)">
2185
+ <label class="server-toggle-row">
2186
+ <input type="checkbox" ${f.savePassword ? 'checked' : ''} onchange="setServerSavePassword(this.checked)">
2187
+ <span>Save this password for next time</span>
2188
+ </label>
2189
+ ` : ''}
2190
+ </div>
2191
+ `;
2192
+ }
2193
+
2194
+ function renderServerSetup(s) {
2195
+ const f = s.form;
2196
+ const specific = f.expose === 'specific';
2197
+ const providersOk = f.expose === 'favorites' || (specific && f.exposedProviders.length > 0);
2198
+ const passwordOk = f.listenMode !== 'network' || f.passwordMode === 'saved' || f.password.trim().length > 0;
2199
+ const canStart = providersOk && passwordOk && !s.starting;
2200
+
2201
+ let hint = '';
2202
+ if (!providersOk) hint = 'Select at least one provider to expose.';
2203
+ else if (!passwordOk) hint = 'Enter a server password for network mode.';
2204
+
2205
+ return `
2206
+ <div class="server-setup">
2207
+ <div class="server-field">
2208
+ <div class="server-field-label">Expose</div>
2209
+ ${toggleGroupHtml([
2210
+ { label: 'Favorites only', active: f.expose === 'favorites', onClick: "setServerExpose('favorites')" },
2211
+ { label: 'Specific providers', active: specific, onClick: "setServerExpose('specific')" },
2212
+ ])}
2213
+ ${specific ? renderServerProviderPicker() : ''}
2214
+ </div>
2215
+
2216
+ <div class="server-field">
2217
+ <label class="server-toggle-row">
2218
+ <input type="checkbox" ${f.maskGatewayIds ? 'checked' : ''} onchange="setServerMask(this.checked)">
2219
+ <span>Mask gateway model ids <span class="server-field-hint">(needed for Claude Desktop / Cowork)</span></span>
2220
+ </label>
2221
+ <label class="server-toggle-row">
2222
+ <input type="checkbox" ${f.freeModelsOnly ? 'checked' : ''} onchange="setServerFreeModelsOnly(this.checked)">
2223
+ <span>Free models only <span class="server-field-hint">(includes verified $0 models and free developer access)</span></span>
2224
+ </label>
2225
+ </div>
2226
+
2227
+ <div class="server-field">
2228
+ <div class="server-field-label">Listen</div>
2229
+ ${toggleGroupHtml([
2230
+ { label: 'Local only', active: f.listenMode === 'local', onClick: "setServerListenMode('local')" },
2231
+ { label: 'Network', active: f.listenMode === 'network', onClick: "setServerListenMode('network')" },
2232
+ ])}
2233
+ ${f.listenMode === 'network' ? renderServerNetworkOptions() : ''}
2234
+ </div>
2235
+
2236
+ ${s.error ? `<div class="key-feedback error server-start-error">${escapeHtml(s.error)}</div>` : ''}
2237
+
2238
+ <button class="btn btn-primary server-start-btn" ${canStart ? '' : 'disabled'} onclick="submitServerStart()">
2239
+ ${s.starting ? 'Starting…' : 'Start Server'}
2240
+ </button>
2241
+ ${hint && !s.error ? `<div class="server-field-hint">${escapeHtml(hint)}</div>` : ''}
2242
+ </div>
2243
+ `;
2244
+ }
2245
+
2246
+ function renderServerRunning(status) {
2247
+ let idx = 0;
2248
+ const nextId = () => `server-url-${idx++}`;
2249
+
2250
+ function urlCard(label, url) {
2251
+ const id = nextId();
2252
+ return `
2253
+ <div class="url-card">
2254
+ <div class="url-card-label">${escapeHtml(label)}</div>
2255
+ <span class="url-card-value" id="${id}">${escapeHtml(url)}</span>
2256
+ <button class="btn btn-ghost btn-sm" onclick="copyServerValue('${id}')">Copy</button>
2257
+ </div>
2258
+ `;
2259
+ }
2260
+
2261
+ const urlCards = [urlCard('Anthropic', status.anthropicUrl), urlCard('OpenAI', status.openaiUrl)];
2262
+ for (const n of status.networkUrls ?? []) {
2263
+ urlCards.push(urlCard(`Anthropic (${n.name})`, n.anthropicUrl));
2264
+ urlCards.push(urlCard(`OpenAI (${n.name})`, n.openaiUrl));
2265
+ }
2266
+
2267
+ const apiKeyCard = status.listenMode === 'network'
2268
+ ? `
2269
+ <div class="url-card">
2270
+ <div class="url-card-label">API key</div>
2271
+ <span class="url-card-value masked" id="server-api-key-value" data-value="${escapeHtml(status.apiKey || '')}">••••••••••••</span>
2272
+ <button class="btn btn-ghost btn-sm" onclick="toggleServerApiKeyVisibility()">Reveal</button>
2273
+ <button class="btn btn-ghost btn-sm" onclick="copyServerValue('server-api-key-value')">Copy</button>
2274
+ </div>
2275
+ `
2276
+ : `
2277
+ <div class="url-card">
2278
+ <div class="url-card-label">API key</div>
2279
+ <span class="url-card-value">any non-empty value</span>
2280
+ </div>
2281
+ `;
2282
+
2283
+ const configBits = [];
2284
+ if (status.favoritesOnly) configBits.push('Favorites only');
2285
+ else if (status.exposedProviders) configBits.push(`${status.exposedProviders.length} provider${status.exposedProviders.length !== 1 ? 's' : ''}`);
2286
+ else configBits.push('All providers');
2287
+ if (status.freeModelsOnly) configBits.push('Free models only');
2288
+ configBits.push(status.maskGatewayIds ? 'Discovery ids masked' : 'Discovery ids raw');
2289
+ configBits.push(status.listenMode === 'network' ? 'Network' : 'Local only');
2290
+
2291
+ const modelRows = (status.models ?? []).map(m => `
2292
+ <tr>
2293
+ <td>${escapeHtml(m.providerLabel)}</td>
2294
+ <td>${escapeHtml(m.name)}</td>
2295
+ <td><code>${escapeHtml(m.anthropicId)}</code></td>
2296
+ <td><code>${escapeHtml(m.openaiId)}</code></td>
2297
+ </tr>
2298
+ `).join('');
2299
+
2300
+ return `
2301
+ <div class="server-running">
2302
+ <div class="server-status-row">
2303
+ <span class="server-status-badge"><span class="server-status-dot"></span>Server running</span>
2304
+ <button class="btn btn-ghost server-stop-btn" onclick="stopServerGateway()">Stop Server</button>
2305
+ </div>
2306
+ <div class="server-config-summary">
2307
+ ${escapeHtml(configBits.join(' · '))}${status.providerSummary ? ' — ' + escapeHtml(status.providerSummary) : ''}
2308
+ </div>
2309
+ <div class="url-card-grid">
2310
+ ${urlCards.join('')}
2311
+ ${apiKeyCard}
2312
+ </div>
2313
+ <div class="server-model-table-wrap">
2314
+ <table class="server-model-table">
2315
+ <thead><tr><th>Provider</th><th>Model</th><th>Anthropic ID</th><th>OpenAI ID</th></tr></thead>
2316
+ <tbody>${modelRows || '<tr><td colspan="4" class="fav-empty">No models exposed.</td></tr>'}</tbody>
2317
+ </table>
2318
+ </div>
2319
+ </div>
2320
+ `;
2321
+ }
2322
+
2323
+ function setServerExpose(mode) {
2324
+ state.server.form.expose = mode;
2325
+ state.server.error = null;
2326
+ if (mode === 'specific' && !state.server.form.providersLoaded) {
2327
+ loadServerProviders().then(renderServerPanel);
2328
+ }
2329
+ renderServerPanel();
2330
+ }
2331
+
2332
+ function toggleServerProvider(id) {
2333
+ const arr = state.server.form.exposedProviders;
2334
+ const idx = arr.indexOf(id);
2335
+ if (idx >= 0) arr.splice(idx, 1); else arr.push(id);
2336
+ renderServerPanel();
2337
+ }
2338
+
2339
+ function setServerProviderSearch(value) {
2340
+ state.server.form.providerSearch = value;
2341
+ const list = document.querySelector('#server-panel .server-provider-list');
2342
+ if (list) list.innerHTML = buildServerProviderRows();
2343
+ }
2344
+
2345
+ function setServerMask(checked) {
2346
+ state.server.form.maskGatewayIds = checked;
2347
+ }
2348
+
2349
+ function setServerFreeModelsOnly(checked) {
2350
+ state.server.form.freeModelsOnly = checked;
2351
+ }
2352
+
2353
+ function setServerListenMode(mode) {
2354
+ state.server.form.listenMode = mode;
2355
+ state.server.error = null;
2356
+ renderServerPanel();
2357
+ }
2358
+
2359
+ function setServerPasswordMode(mode) {
2360
+ state.server.form.passwordMode = mode;
2361
+ renderServerPanel();
2362
+ }
2363
+
2364
+ function setServerPassword(value) {
2365
+ state.server.form.password = value;
2366
+ }
2367
+
2368
+ function setServerSavePassword(checked) {
2369
+ state.server.form.savePassword = checked;
2370
+ }
2371
+
2372
+ async function submitServerStart() {
2373
+ const f = state.server.form;
2374
+ state.server.starting = true;
2375
+ state.server.error = null;
2376
+ renderServerPanel();
2377
+
2378
+ const body = {
2379
+ favoritesOnly: f.expose === 'favorites',
2380
+ freeModelsOnly: f.freeModelsOnly,
2381
+ exposedProviders: f.expose === 'specific' ? f.exposedProviders : null,
2382
+ maskGatewayIds: f.maskGatewayIds,
2383
+ listenMode: f.listenMode,
2384
+ passwordMode: f.passwordMode,
2385
+ password: f.password,
2386
+ savePassword: f.savePassword,
2387
+ };
2388
+
2389
+ let result;
2390
+ try {
2391
+ result = await api('POST', '/api/server/start', body);
2392
+ } catch (err) {
2393
+ result = { ok: false, error: String(err) };
2394
+ }
2395
+
2396
+ state.server.starting = false;
2397
+ if (result.ok) {
2398
+ state.server.status = result.status;
2399
+ state.server.form.password = '';
2400
+ showToast('Server started');
2401
+ } else {
2402
+ state.server.error = result.error || 'Failed to start server';
2403
+ }
2404
+ renderServerPanel();
2405
+ updateServerNavBadge();
2406
+ }
2407
+
2408
+ async function stopServerGateway() {
2409
+ try {
2410
+ await api('POST', '/api/server/stop');
2411
+ state.server.form.seeded = false;
2412
+ await refreshServerStatus();
2413
+ showToast('Server stopped');
2414
+ } catch (err) {
2415
+ showToast('Failed to stop server: ' + err);
2416
+ }
2417
+ }
2418
+
2419
+ async function copyServerValue(id) {
2420
+ const el = document.getElementById(id);
2421
+ if (!el) return;
2422
+ const text = el.dataset.value ?? el.textContent;
2423
+ try {
2424
+ await navigator.clipboard.writeText(text);
2425
+ showToast('Copied to clipboard');
2426
+ } catch {
2427
+ showToast('Could not copy — copy manually');
2428
+ }
2429
+ }
2430
+
2431
+ function toggleServerApiKeyVisibility() {
2432
+ const el = document.getElementById('server-api-key-value');
2433
+ if (!el) return;
2434
+ const masked = el.classList.toggle('masked');
2435
+ el.textContent = masked ? '••••••••••••' : (el.dataset.value ?? '');
2436
+ }
2437
+
2438
+ window.setServerExpose = setServerExpose;
2439
+ window.toggleServerProvider = toggleServerProvider;
2440
+ window.setServerProviderSearch = setServerProviderSearch;
2441
+ window.setServerMask = setServerMask;
2442
+ window.setServerFreeModelsOnly = setServerFreeModelsOnly;
2443
+ window.setServerListenMode = setServerListenMode;
2444
+ window.setServerPasswordMode = setServerPasswordMode;
2445
+ window.setServerPassword = setServerPassword;
2446
+ window.setServerSavePassword = setServerSavePassword;
2447
+ window.submitServerStart = submitServerStart;
2448
+ window.stopServerGateway = stopServerGateway;
2449
+ window.copyServerValue = copyServerValue;
2450
+ window.toggleServerApiKeyVisibility = toggleServerApiKeyVisibility;