commons-proxy 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +757 -0
  3. package/bin/cli.js +146 -0
  4. package/package.json +97 -0
  5. package/public/Complaint Details.pdf +0 -0
  6. package/public/Cyber Crime Portal.pdf +0 -0
  7. package/public/app.js +229 -0
  8. package/public/css/src/input.css +523 -0
  9. package/public/css/style.css +1 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +549 -0
  12. package/public/js/components/account-manager.js +356 -0
  13. package/public/js/components/add-account-modal.js +414 -0
  14. package/public/js/components/claude-config.js +420 -0
  15. package/public/js/components/dashboard/charts.js +605 -0
  16. package/public/js/components/dashboard/filters.js +362 -0
  17. package/public/js/components/dashboard/stats.js +110 -0
  18. package/public/js/components/dashboard.js +236 -0
  19. package/public/js/components/logs-viewer.js +100 -0
  20. package/public/js/components/models.js +36 -0
  21. package/public/js/components/server-config.js +349 -0
  22. package/public/js/config/constants.js +102 -0
  23. package/public/js/data-store.js +375 -0
  24. package/public/js/settings-store.js +58 -0
  25. package/public/js/store.js +99 -0
  26. package/public/js/translations/en.js +367 -0
  27. package/public/js/translations/id.js +412 -0
  28. package/public/js/translations/pt.js +308 -0
  29. package/public/js/translations/tr.js +358 -0
  30. package/public/js/translations/zh.js +373 -0
  31. package/public/js/utils/account-actions.js +189 -0
  32. package/public/js/utils/error-handler.js +96 -0
  33. package/public/js/utils/model-config.js +42 -0
  34. package/public/js/utils/ui-logger.js +143 -0
  35. package/public/js/utils/validators.js +77 -0
  36. package/public/js/utils.js +69 -0
  37. package/public/proxy-server-64.png +0 -0
  38. package/public/views/accounts.html +361 -0
  39. package/public/views/dashboard.html +484 -0
  40. package/public/views/logs.html +97 -0
  41. package/public/views/models.html +331 -0
  42. package/public/views/settings.html +1327 -0
  43. package/src/account-manager/credentials.js +378 -0
  44. package/src/account-manager/index.js +462 -0
  45. package/src/account-manager/onboarding.js +112 -0
  46. package/src/account-manager/rate-limits.js +369 -0
  47. package/src/account-manager/storage.js +160 -0
  48. package/src/account-manager/strategies/base-strategy.js +109 -0
  49. package/src/account-manager/strategies/hybrid-strategy.js +339 -0
  50. package/src/account-manager/strategies/index.js +79 -0
  51. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  52. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  53. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  54. package/src/account-manager/strategies/trackers/index.js +9 -0
  55. package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
  56. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
  57. package/src/auth/database.js +169 -0
  58. package/src/auth/oauth.js +548 -0
  59. package/src/auth/token-extractor.js +117 -0
  60. package/src/cli/accounts.js +648 -0
  61. package/src/cloudcode/index.js +29 -0
  62. package/src/cloudcode/message-handler.js +510 -0
  63. package/src/cloudcode/model-api.js +248 -0
  64. package/src/cloudcode/rate-limit-parser.js +235 -0
  65. package/src/cloudcode/request-builder.js +93 -0
  66. package/src/cloudcode/session-manager.js +47 -0
  67. package/src/cloudcode/sse-parser.js +121 -0
  68. package/src/cloudcode/sse-streamer.js +293 -0
  69. package/src/cloudcode/streaming-handler.js +615 -0
  70. package/src/config.js +125 -0
  71. package/src/constants.js +407 -0
  72. package/src/errors.js +242 -0
  73. package/src/fallback-config.js +29 -0
  74. package/src/format/content-converter.js +193 -0
  75. package/src/format/index.js +20 -0
  76. package/src/format/request-converter.js +255 -0
  77. package/src/format/response-converter.js +120 -0
  78. package/src/format/schema-sanitizer.js +673 -0
  79. package/src/format/signature-cache.js +88 -0
  80. package/src/format/thinking-utils.js +648 -0
  81. package/src/index.js +148 -0
  82. package/src/modules/usage-stats.js +205 -0
  83. package/src/providers/anthropic-provider.js +258 -0
  84. package/src/providers/base-provider.js +157 -0
  85. package/src/providers/cloudcode.js +94 -0
  86. package/src/providers/copilot.js +399 -0
  87. package/src/providers/github-provider.js +287 -0
  88. package/src/providers/google-provider.js +192 -0
  89. package/src/providers/index.js +211 -0
  90. package/src/providers/openai-compatible.js +265 -0
  91. package/src/providers/openai-provider.js +271 -0
  92. package/src/providers/openrouter-provider.js +325 -0
  93. package/src/providers/setup.js +83 -0
  94. package/src/server.js +870 -0
  95. package/src/utils/claude-config.js +245 -0
  96. package/src/utils/helpers.js +51 -0
  97. package/src/utils/logger.js +142 -0
  98. package/src/utils/native-module-helper.js +162 -0
  99. package/src/webui/index.js +1134 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Data Store
3
+ * Holds Accounts, Models, and Computed Quota Rows
4
+ * Shared between Dashboard and AccountManager
5
+ */
6
+
7
+ // utils is loaded globally as window.utils in utils.js
8
+
9
+ document.addEventListener('alpine:init', () => {
10
+ Alpine.store('data', {
11
+ accounts: [],
12
+ models: [], // Source of truth
13
+ modelConfig: {}, // Model metadata (hidden, pinned, alias)
14
+ quotaRows: [], // Filtered view
15
+ usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
16
+ maxAccounts: 10, // Maximum number of accounts allowed (from config)
17
+ loading: false,
18
+ initialLoad: true, // Track first load for skeleton screen
19
+ connectionStatus: 'connecting',
20
+ lastUpdated: '-',
21
+ healthCheckTimer: null,
22
+
23
+ // Filters state
24
+ filters: {
25
+ account: 'all',
26
+ family: 'all',
27
+ search: '',
28
+ sortCol: 'avgQuota',
29
+ sortAsc: true
30
+ },
31
+
32
+ // Settings for calculation
33
+ // We need to access global settings? Or duplicate?
34
+ // Let's assume settings are passed or in another store.
35
+ // For simplicity, let's keep relevant filters here.
36
+
37
+ init() {
38
+ // Restore from cache first for instant render
39
+ this.loadFromCache();
40
+
41
+ // Watch filters to recompute
42
+ // Alpine stores don't have $watch automatically unless inside a component?
43
+ // We can manually call compute when filters change.
44
+
45
+ // Start health check monitoring
46
+ this.startHealthCheck();
47
+ },
48
+
49
+ loadFromCache() {
50
+ try {
51
+ const cached = localStorage.getItem('ag_data_cache');
52
+ if (cached) {
53
+ const data = JSON.parse(cached);
54
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
55
+
56
+ // Check TTL
57
+ if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) {
58
+ if (window.UILogger) window.UILogger.debug('Cache expired, skipping restoration');
59
+ localStorage.removeItem('ag_data_cache');
60
+ return;
61
+ }
62
+
63
+ // Basic validity check
64
+ if (data.accounts && data.models) {
65
+ this.accounts = data.accounts;
66
+ this.models = data.models;
67
+ this.modelConfig = data.modelConfig || {};
68
+ this.usageHistory = data.usageHistory || {};
69
+
70
+ // Don't show loading on initial load if we have cache
71
+ this.initialLoad = false;
72
+ this.computeQuotaRows();
73
+ if (window.UILogger) window.UILogger.debug('Restored data from cache');
74
+ }
75
+ }
76
+ } catch (e) {
77
+ if (window.UILogger) window.UILogger.debug('Failed to load cache', e.message);
78
+ }
79
+ },
80
+
81
+ saveToCache() {
82
+ try {
83
+ const cacheData = {
84
+ accounts: this.accounts,
85
+ models: this.models,
86
+ modelConfig: this.modelConfig,
87
+ usageHistory: this.usageHistory,
88
+ timestamp: Date.now()
89
+ };
90
+ localStorage.setItem('ag_data_cache', JSON.stringify(cacheData));
91
+ } catch (e) {
92
+ if (window.UILogger) window.UILogger.debug('Failed to save cache', e.message);
93
+ }
94
+ },
95
+
96
+ async fetchData() {
97
+ // Only show skeleton on initial load if we didn't restore from cache
98
+ if (this.initialLoad) {
99
+ this.loading = true;
100
+ }
101
+ try {
102
+ // Get password from global store
103
+ const password = Alpine.store('global').webuiPassword;
104
+
105
+ // Include history for dashboard (single API call optimization)
106
+ const url = '/account-limits?includeHistory=true';
107
+ const { response, newPassword } = await window.utils.request(url, {}, password);
108
+
109
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
110
+
111
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
112
+
113
+ const data = await response.json();
114
+ this.accounts = data.accounts || [];
115
+ if (data.models && data.models.length > 0) {
116
+ this.models = data.models;
117
+ }
118
+ this.modelConfig = data.modelConfig || {};
119
+
120
+ // Store usage history if included (for dashboard)
121
+ if (data.history) {
122
+ this.usageHistory = data.history;
123
+ }
124
+
125
+ this.saveToCache(); // Save fresh data
126
+ this.computeQuotaRows();
127
+
128
+ this.lastUpdated = new Date().toLocaleTimeString();
129
+ } catch (error) {
130
+ // Keep error logging for actual fetch failures
131
+ console.error('Fetch error:', error);
132
+ const store = Alpine.store('global');
133
+ store.showToast(store.t('connectionLost'), 'error');
134
+ } finally {
135
+ this.loading = false;
136
+ this.initialLoad = false; // Mark initial load as complete
137
+ }
138
+ },
139
+
140
+ async performHealthCheck() {
141
+ try {
142
+ // Get password from global store
143
+ const password = Alpine.store('global').webuiPassword;
144
+
145
+ // Use lightweight endpoint (no quota fetching)
146
+ const { response, newPassword } = await window.utils.request('/api/config', {}, password);
147
+
148
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
149
+
150
+ if (response.ok) {
151
+ this.connectionStatus = 'connected';
152
+ } else {
153
+ this.connectionStatus = 'disconnected';
154
+ }
155
+ } catch (error) {
156
+ console.error('Health check error:', error);
157
+ this.connectionStatus = 'disconnected';
158
+ }
159
+ },
160
+
161
+ startHealthCheck() {
162
+ // Clear existing timer
163
+ if (this.healthCheckTimer) {
164
+ clearInterval(this.healthCheckTimer);
165
+ }
166
+
167
+ // Setup visibility change listener (only once)
168
+ if (!this._healthVisibilitySetup) {
169
+ this._healthVisibilitySetup = true;
170
+ this._visibilityHandler = () => {
171
+ if (document.hidden) {
172
+ // Tab hidden - stop health checks
173
+ this.stopHealthCheck();
174
+ } else {
175
+ // Tab visible - restart health checks
176
+ this.startHealthCheck();
177
+ }
178
+ };
179
+ document.addEventListener('visibilitychange', this._visibilityHandler);
180
+ }
181
+
182
+ // Perform immediate health check
183
+ this.performHealthCheck();
184
+
185
+ // Schedule regular health checks every 15 seconds
186
+ this.healthCheckTimer = setInterval(() => {
187
+ // Only perform health check if tab is visible
188
+ if (!document.hidden) {
189
+ this.performHealthCheck();
190
+ }
191
+ }, 15000);
192
+ },
193
+
194
+ stopHealthCheck() {
195
+ if (this.healthCheckTimer) {
196
+ clearInterval(this.healthCheckTimer);
197
+ this.healthCheckTimer = null;
198
+ }
199
+ },
200
+
201
+ computeQuotaRows() {
202
+ const models = this.models || [];
203
+ const rows = [];
204
+ const showExhausted = Alpine.store('settings')?.showExhausted ?? true;
205
+
206
+ models.forEach(modelId => {
207
+ // Config
208
+ const config = this.modelConfig[modelId] || {};
209
+ const family = this.getModelFamily(modelId);
210
+
211
+ // Visibility Logic for Models Page (quotaRows):
212
+ // 1. If explicitly hidden via config, ALWAYS hide (clean interface)
213
+ // 2. If no config, default 'unknown' families to HIDDEN
214
+ // 3. Known families (Claude/Gemini) default to VISIBLE
215
+ // Note: To manage hidden models, use Settings → Models tab
216
+ let isHidden = config.hidden;
217
+ if (isHidden === undefined) {
218
+ isHidden = (family === 'other' || family === 'unknown');
219
+ }
220
+
221
+ // Models Page: Check settings for visibility
222
+ const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
223
+ if (isHidden && !showHidden) return;
224
+
225
+ // Filters
226
+ if (this.filters.family !== 'all' && this.filters.family !== family) return;
227
+ if (this.filters.search) {
228
+ const searchLower = this.filters.search.toLowerCase();
229
+ const idMatch = modelId.toLowerCase().includes(searchLower);
230
+ if (!idMatch) return;
231
+ }
232
+
233
+ // Data Collection
234
+ const quotaInfo = [];
235
+ let minQuota = 100;
236
+ let totalQuotaSum = 0;
237
+ let validAccountCount = 0;
238
+ let minResetTime = null;
239
+
240
+ this.accounts.forEach(acc => {
241
+ if (acc.enabled === false) return;
242
+ if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
243
+
244
+ const limit = acc.limits?.[modelId];
245
+ if (!limit) return;
246
+
247
+ const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
248
+ minQuota = Math.min(minQuota, pct);
249
+
250
+ // Accumulate for average
251
+ totalQuotaSum += pct;
252
+ validAccountCount++;
253
+
254
+ if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) {
255
+ minResetTime = limit.resetTime;
256
+ }
257
+
258
+ quotaInfo.push({
259
+ email: acc.email.split('@')[0],
260
+ fullEmail: acc.email,
261
+ pct: pct,
262
+ resetTime: limit.resetTime,
263
+ provider: acc.provider || 'google'
264
+ });
265
+ });
266
+
267
+ if (quotaInfo.length === 0) return;
268
+ const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0;
269
+
270
+ if (!showExhausted && minQuota === 0) return;
271
+
272
+ rows.push({
273
+ modelId,
274
+ displayName: modelId, // Simplified: no longer using alias
275
+ family,
276
+ minQuota,
277
+ avgQuota, // Added Average Quota
278
+ minResetTime,
279
+ resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
280
+ quotaInfo,
281
+ pinned: !!config.pinned,
282
+ hidden: !!isHidden, // Use computed visibility
283
+ activeCount: quotaInfo.filter(q => q.pct > 0).length,
284
+ providers: [...new Set(quotaInfo.map(q => q.provider || 'google'))] // Unique providers for this model
285
+ });
286
+ });
287
+
288
+ // Sort: Pinned first, then by selected column
289
+ const sortCol = this.filters.sortCol;
290
+ const sortAsc = this.filters.sortAsc;
291
+
292
+ this.quotaRows = rows.sort((a, b) => {
293
+ if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
294
+
295
+ let valA = a[sortCol];
296
+ let valB = b[sortCol];
297
+
298
+ // Handle nulls (always push to bottom)
299
+ if (valA === valB) return 0;
300
+ if (valA === null || valA === undefined) return 1;
301
+ if (valB === null || valB === undefined) return -1;
302
+
303
+ if (typeof valA === 'string' && typeof valB === 'string') {
304
+ return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
305
+ }
306
+
307
+ return sortAsc ? valA - valB : valB - valA;
308
+ });
309
+
310
+ // Trigger Dashboard Update if active
311
+ // Ideally dashboard watches this store.
312
+ },
313
+
314
+ setSort(col) {
315
+ if (this.filters.sortCol === col) {
316
+ this.filters.sortAsc = !this.filters.sortAsc;
317
+ } else {
318
+ this.filters.sortCol = col;
319
+ // Default sort direction: Descending for numbers/stats, Ascending for text/time
320
+ if (['avgQuota', 'activeCount'].includes(col)) {
321
+ this.filters.sortAsc = false;
322
+ } else {
323
+ this.filters.sortAsc = true;
324
+ }
325
+ }
326
+ this.computeQuotaRows();
327
+ },
328
+
329
+ getModelFamily(modelId) {
330
+ const lower = modelId.toLowerCase();
331
+ if (lower.includes('claude')) return 'claude';
332
+ if (lower.includes('gemini')) return 'gemini';
333
+ if (lower.includes('gpt')) return 'gpt';
334
+ if (lower.startsWith('o1') || lower.startsWith('o3')) return 'o1';
335
+ return 'other';
336
+ },
337
+
338
+ /**
339
+ * Get quota data without filters applied (for Dashboard global charts)
340
+ * Returns array of { modelId, family, quotaInfo: [{pct}] }
341
+ */
342
+ getUnfilteredQuotaData() {
343
+ const models = this.models || [];
344
+ const rows = [];
345
+ const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
346
+
347
+ models.forEach(modelId => {
348
+ const config = this.modelConfig[modelId] || {};
349
+ const family = this.getModelFamily(modelId);
350
+
351
+ // Smart visibility (same logic as computeQuotaRows)
352
+ let isHidden = config.hidden;
353
+ if (isHidden === undefined) {
354
+ isHidden = (family === 'other' || family === 'unknown');
355
+ }
356
+ if (isHidden && !showHidden) return;
357
+
358
+ const quotaInfo = [];
359
+ // Use ALL accounts (no account filter)
360
+ this.accounts.forEach(acc => {
361
+ if (acc.enabled === false) return;
362
+ const limit = acc.limits?.[modelId];
363
+ if (!limit) return;
364
+ const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
365
+ quotaInfo.push({ pct });
366
+ });
367
+
368
+ // treat missing quotaInfo as 0%/unknown; still include row
369
+ rows.push({ modelId, family, quotaInfo });
370
+ });
371
+
372
+ return rows;
373
+ }
374
+ });
375
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Settings Store
3
+ */
4
+ document.addEventListener('alpine:init', () => {
5
+ Alpine.store('settings', {
6
+ refreshInterval: 60,
7
+ logLimit: 2000,
8
+ showExhausted: true,
9
+ showHiddenModels: false, // New field
10
+ compact: false,
11
+ port: 8080, // Display only
12
+
13
+ init() {
14
+ this.loadSettings();
15
+ },
16
+
17
+ // Call this method when toggling settings in the UI
18
+ toggle(key) {
19
+ if (this.hasOwnProperty(key) && typeof this[key] === 'boolean') {
20
+ this[key] = !this[key];
21
+ this.saveSettings(true);
22
+ }
23
+ },
24
+
25
+ loadSettings() {
26
+ const saved = localStorage.getItem('commons_settings');
27
+ if (saved) {
28
+ const parsed = JSON.parse(saved);
29
+ Object.keys(parsed).forEach(k => {
30
+ // Only load keys that exist in our default state (safety)
31
+ if (this.hasOwnProperty(k)) this[k] = parsed[k];
32
+ });
33
+ }
34
+ },
35
+
36
+ saveSettings(silent = false) {
37
+ const toSave = {
38
+ refreshInterval: this.refreshInterval,
39
+ logLimit: this.logLimit,
40
+ showExhausted: this.showExhausted,
41
+ showHiddenModels: this.showHiddenModels,
42
+ compact: this.compact
43
+ };
44
+ localStorage.setItem('commons_settings', JSON.stringify(toSave));
45
+
46
+ if (!silent) {
47
+ const store = Alpine.store('global');
48
+ store.showToast(store.t('configSaved'), 'success');
49
+ }
50
+
51
+ // Trigger updates
52
+ document.dispatchEvent(new CustomEvent('refresh-interval-changed'));
53
+ if (Alpine.store('data')) {
54
+ Alpine.store('data').computeQuotaRows();
55
+ }
56
+ }
57
+ });
58
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Global Store for Antigravity Console
3
+ * Handles Translations, Toasts, and Shared Config
4
+ */
5
+
6
+ document.addEventListener('alpine:init', () => {
7
+ Alpine.store('global', {
8
+ init() {
9
+ // Hash-based routing
10
+ const validTabs = ['dashboard', 'models', 'accounts', 'logs', 'settings'];
11
+ const getHash = () => window.location.hash.substring(1);
12
+
13
+ // 1. Initial load from hash
14
+ const initialHash = getHash();
15
+ if (validTabs.includes(initialHash)) {
16
+ this.activeTab = initialHash;
17
+ }
18
+
19
+ // 2. Sync State -> URL
20
+ Alpine.effect(() => {
21
+ if (validTabs.includes(this.activeTab) && getHash() !== this.activeTab) {
22
+ window.location.hash = this.activeTab;
23
+ }
24
+ });
25
+
26
+ // 3. Sync URL -> State (Back/Forward buttons)
27
+ window.addEventListener('hashchange', () => {
28
+ const hash = getHash();
29
+ if (validTabs.includes(hash) && this.activeTab !== hash) {
30
+ this.activeTab = hash;
31
+ }
32
+ });
33
+
34
+ // 4. Fetch version from API
35
+ this.fetchVersion();
36
+ },
37
+
38
+ async fetchVersion() {
39
+ try {
40
+ const response = await fetch('/api/config');
41
+ if (response.ok) {
42
+ const data = await response.json();
43
+ if (data.version) {
44
+ this.version = data.version;
45
+ }
46
+ // Update maxAccounts in data store
47
+ if (data.config && typeof data.config.maxAccounts === 'number') {
48
+ Alpine.store('data').maxAccounts = data.config.maxAccounts;
49
+ }
50
+ }
51
+ } catch (error) {
52
+ console.debug('Could not fetch version:', error);
53
+ }
54
+ },
55
+
56
+ // App State
57
+ version: '1.0.0',
58
+ activeTab: 'dashboard',
59
+ webuiPassword: localStorage.getItem('commons_webui_password') || '',
60
+
61
+ // i18n
62
+ lang: localStorage.getItem('app_lang') || 'en',
63
+ translations: window.translations || {},
64
+
65
+ // Toast Messages
66
+ toast: null,
67
+
68
+ // OAuth Progress
69
+ oauthProgress: {
70
+ active: false,
71
+ current: 0,
72
+ max: 60,
73
+ cancel: null
74
+ },
75
+
76
+ t(key, params = {}) {
77
+ let str = this.translations[this.lang][key] || key;
78
+ if (typeof str === 'string') {
79
+ Object.keys(params).forEach(p => {
80
+ str = str.replace(`{${p}}`, params[p]);
81
+ });
82
+ }
83
+ return str;
84
+ },
85
+
86
+ setLang(l) {
87
+ this.lang = l;
88
+ localStorage.setItem('app_lang', l);
89
+ },
90
+
91
+ showToast(message, type = 'info') {
92
+ const id = Date.now();
93
+ this.toast = { message, type, id };
94
+ setTimeout(() => {
95
+ if (this.toast && this.toast.id === id) this.toast = null;
96
+ }, 3000);
97
+ }
98
+ });
99
+ });