@chimerai/cli 0.2.73

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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -0
  3. package/dist/cli.d.ts +7 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +317 -0
  6. package/dist/commands/add.d.ts +11 -0
  7. package/dist/commands/add.d.ts.map +1 -0
  8. package/dist/commands/add.js +2126 -0
  9. package/dist/commands/create.d.ts +12 -0
  10. package/dist/commands/create.d.ts.map +1 -0
  11. package/dist/commands/create.js +1703 -0
  12. package/dist/commands/deploy.d.ts +11 -0
  13. package/dist/commands/deploy.d.ts.map +1 -0
  14. package/dist/commands/deploy.js +219 -0
  15. package/dist/commands/dev.d.ts +17 -0
  16. package/dist/commands/dev.d.ts.map +1 -0
  17. package/dist/commands/dev.js +206 -0
  18. package/dist/commands/doctor.d.ts +11 -0
  19. package/dist/commands/doctor.d.ts.map +1 -0
  20. package/dist/commands/doctor.js +728 -0
  21. package/dist/commands/generate.d.ts +19 -0
  22. package/dist/commands/generate.d.ts.map +1 -0
  23. package/dist/commands/generate.js +429 -0
  24. package/dist/commands/init.d.ts +11 -0
  25. package/dist/commands/init.d.ts.map +1 -0
  26. package/dist/commands/init.js +269 -0
  27. package/dist/commands/list.d.ts +12 -0
  28. package/dist/commands/list.d.ts.map +1 -0
  29. package/dist/commands/list.js +328 -0
  30. package/dist/commands/migrate.d.ts +14 -0
  31. package/dist/commands/migrate.d.ts.map +1 -0
  32. package/dist/commands/migrate.js +197 -0
  33. package/dist/commands/plugin.d.ts +10 -0
  34. package/dist/commands/plugin.d.ts.map +1 -0
  35. package/dist/commands/plugin.js +239 -0
  36. package/dist/commands/remove.d.ts +11 -0
  37. package/dist/commands/remove.d.ts.map +1 -0
  38. package/dist/commands/remove.js +472 -0
  39. package/dist/commands/secret.d.ts +12 -0
  40. package/dist/commands/secret.d.ts.map +1 -0
  41. package/dist/commands/secret.js +102 -0
  42. package/dist/commands/setup.d.ts +9 -0
  43. package/dist/commands/setup.d.ts.map +1 -0
  44. package/dist/commands/setup.js +788 -0
  45. package/dist/commands/update.d.ts +14 -0
  46. package/dist/commands/update.d.ts.map +1 -0
  47. package/dist/commands/update.js +211 -0
  48. package/dist/commands/use.d.ts +9 -0
  49. package/dist/commands/use.d.ts.map +1 -0
  50. package/dist/commands/use.js +51 -0
  51. package/dist/index.d.ts +22 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +45 -0
  54. package/dist/license.d.ts +55 -0
  55. package/dist/license.d.ts.map +1 -0
  56. package/dist/license.js +258 -0
  57. package/dist/scanner.d.ts +31 -0
  58. package/dist/scanner.d.ts.map +1 -0
  59. package/dist/scanner.js +113 -0
  60. package/dist/schema-manager.d.ts +26 -0
  61. package/dist/schema-manager.d.ts.map +1 -0
  62. package/dist/schema-manager.js +132 -0
  63. package/dist/templates/admin.d.ts +49 -0
  64. package/dist/templates/admin.d.ts.map +1 -0
  65. package/dist/templates/admin.js +1358 -0
  66. package/dist/templates/ai-routes.d.ts +17 -0
  67. package/dist/templates/ai-routes.d.ts.map +1 -0
  68. package/dist/templates/ai-routes.js +1130 -0
  69. package/dist/templates/ai-service-tools.d.ts +22 -0
  70. package/dist/templates/ai-service-tools.d.ts.map +1 -0
  71. package/dist/templates/ai-service-tools.js +1424 -0
  72. package/dist/templates/ai-service.d.ts +66 -0
  73. package/dist/templates/ai-service.d.ts.map +1 -0
  74. package/dist/templates/ai-service.js +2202 -0
  75. package/dist/templates/api-routes.d.ts +108 -0
  76. package/dist/templates/api-routes.d.ts.map +1 -0
  77. package/dist/templates/api-routes.js +1219 -0
  78. package/dist/templates/auth.d.ts +48 -0
  79. package/dist/templates/auth.d.ts.map +1 -0
  80. package/dist/templates/auth.js +381 -0
  81. package/dist/templates/billing.d.ts +44 -0
  82. package/dist/templates/billing.d.ts.map +1 -0
  83. package/dist/templates/billing.js +551 -0
  84. package/dist/templates/chat.d.ts +63 -0
  85. package/dist/templates/chat.d.ts.map +1 -0
  86. package/dist/templates/chat.js +1979 -0
  87. package/dist/templates/components.d.ts +22 -0
  88. package/dist/templates/components.d.ts.map +1 -0
  89. package/dist/templates/components.js +672 -0
  90. package/dist/templates/config.d.ts +6 -0
  91. package/dist/templates/config.d.ts.map +1 -0
  92. package/dist/templates/config.js +86 -0
  93. package/dist/templates/docker.d.ts +25 -0
  94. package/dist/templates/docker.d.ts.map +1 -0
  95. package/dist/templates/docker.js +165 -0
  96. package/dist/templates/gdpr.d.ts +16 -0
  97. package/dist/templates/gdpr.d.ts.map +1 -0
  98. package/dist/templates/gdpr.js +259 -0
  99. package/dist/templates/index.d.ts +77 -0
  100. package/dist/templates/index.d.ts.map +1 -0
  101. package/dist/templates/index.js +339 -0
  102. package/dist/templates/layout.d.ts +67 -0
  103. package/dist/templates/layout.d.ts.map +1 -0
  104. package/dist/templates/layout.js +670 -0
  105. package/dist/templates/mfa.d.ts +23 -0
  106. package/dist/templates/mfa.d.ts.map +1 -0
  107. package/dist/templates/mfa.js +353 -0
  108. package/dist/templates/middleware.d.ts +12 -0
  109. package/dist/templates/middleware.d.ts.map +1 -0
  110. package/dist/templates/middleware.js +116 -0
  111. package/dist/templates/prisma.d.ts +35 -0
  112. package/dist/templates/prisma.d.ts.map +1 -0
  113. package/dist/templates/prisma.js +724 -0
  114. package/dist/templates/provider-routes.d.ts +21 -0
  115. package/dist/templates/provider-routes.d.ts.map +1 -0
  116. package/dist/templates/provider-routes.js +1203 -0
  117. package/dist/templates/rag.d.ts +48 -0
  118. package/dist/templates/rag.d.ts.map +1 -0
  119. package/dist/templates/rag.js +532 -0
  120. package/dist/templates/widget.d.ts +64 -0
  121. package/dist/templates/widget.d.ts.map +1 -0
  122. package/dist/templates/widget.js +1360 -0
  123. package/dist/utils/provider-db.d.ts +63 -0
  124. package/dist/utils/provider-db.d.ts.map +1 -0
  125. package/dist/utils/provider-db.js +300 -0
  126. package/dist/utils.d.ts +78 -0
  127. package/dist/utils.d.ts.map +1 -0
  128. package/dist/utils.js +330 -0
  129. package/package.json +60 -0
@@ -0,0 +1,1360 @@
1
+ "use strict";
2
+ /**
3
+ * Widget Template Generators
4
+ *
5
+ * Generates the embeddable chat widget (Web Component + Shadow DOM)
6
+ * and the API-Key Management UI for external integrations.
7
+ *
8
+ * SPEC: COMPONENT_PORTABILITY_SPEC.md — Phase 1.5 + 1.6
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.generateWidgetBundle = generateWidgetBundle;
12
+ exports.generateWidgetLoader = generateWidgetLoader;
13
+ exports.generateApiKeyManagementPage = generateApiKeyManagementPage;
14
+ exports.generateApiKeysRoute = generateApiKeysRoute;
15
+ exports.generateApiKeyIdRoute = generateApiKeyIdRoute;
16
+ exports.generateRateLimiter = generateRateLimiter;
17
+ /**
18
+ * Generates the self-contained chat widget bundle.
19
+ * Output: public/widget/chat.js
20
+ *
21
+ * Features:
22
+ * - Web Component with Shadow DOM (no CSS leak)
23
+ * - SSE streaming client
24
+ * - Inline CSS (dark/light/auto theme)
25
+ * - No React, no external deps
26
+ * - ChimerAI.mount() API + data-attribute auto-mount
27
+ */
28
+ function generateWidgetBundle() {
29
+ return `// @chimerai component=ChatWidget version=1.0
30
+ // ChimerAI Embeddable Chat Widget — Self-contained Web Component
31
+ // Usage:
32
+ // <script src="https://your-app.com/widget/chat.js"></script>
33
+ // <div id="chat"></div>
34
+ // <script>ChimerAI.mount('#chat', { apiKey: 'sk_live_...', theme: 'dark' });</script>
35
+
36
+ (function() {
37
+ 'use strict';
38
+
39
+ // ── Inline Styles (Shadow DOM isolated) ──────────────────────────
40
+ const WIDGET_CSS = \`
41
+ :host {
42
+ display: block;
43
+ width: 100%;
44
+ height: 100%;
45
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
46
+ --chi-bg: #ffffff;
47
+ --chi-bg-secondary: #f3f4f6;
48
+ --chi-text: #111827;
49
+ --chi-text-secondary: #6b7280;
50
+ --chi-border: #e5e7eb;
51
+ --chi-primary: #2563eb;
52
+ --chi-primary-hover: #1d4ed8;
53
+ --chi-user-bg: #2563eb;
54
+ --chi-user-text: #ffffff;
55
+ --chi-assistant-bg: #f3f4f6;
56
+ --chi-assistant-text: #111827;
57
+ --chi-error-bg: #fef2f2;
58
+ --chi-error-text: #dc2626;
59
+ --chi-radius: 12px;
60
+ }
61
+
62
+ :host([data-theme="dark"]) {
63
+ --chi-bg: #1f2937;
64
+ --chi-bg-secondary: #374151;
65
+ --chi-text: #f9fafb;
66
+ --chi-text-secondary: #9ca3af;
67
+ --chi-border: #4b5563;
68
+ --chi-primary: #3b82f6;
69
+ --chi-primary-hover: #2563eb;
70
+ --chi-user-bg: #3b82f6;
71
+ --chi-user-text: #ffffff;
72
+ --chi-assistant-bg: #374151;
73
+ --chi-assistant-text: #f9fafb;
74
+ --chi-error-bg: #451a1a;
75
+ --chi-error-text: #fca5a5;
76
+ }
77
+
78
+ @media (prefers-color-scheme: dark) {
79
+ :host([data-theme="auto"]) {
80
+ --chi-bg: #1f2937;
81
+ --chi-bg-secondary: #374151;
82
+ --chi-text: #f9fafb;
83
+ --chi-text-secondary: #9ca3af;
84
+ --chi-border: #4b5563;
85
+ --chi-primary: #3b82f6;
86
+ --chi-primary-hover: #2563eb;
87
+ --chi-user-bg: #3b82f6;
88
+ --chi-user-text: #ffffff;
89
+ --chi-assistant-bg: #374151;
90
+ --chi-assistant-text: #f9fafb;
91
+ --chi-error-bg: #451a1a;
92
+ --chi-error-text: #fca5a5;
93
+ }
94
+ }
95
+
96
+ .chi-container {
97
+ display: flex;
98
+ flex-direction: column;
99
+ height: 100%;
100
+ min-height: 400px;
101
+ background: var(--chi-bg);
102
+ border: 1px solid var(--chi-border);
103
+ border-radius: var(--chi-radius);
104
+ overflow: hidden;
105
+ }
106
+
107
+ .chi-header {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ padding: 12px 16px;
112
+ background: var(--chi-bg-secondary);
113
+ border-bottom: 1px solid var(--chi-border);
114
+ flex-shrink: 0;
115
+ }
116
+
117
+ .chi-header-title {
118
+ font-size: 14px;
119
+ font-weight: 600;
120
+ color: var(--chi-text);
121
+ margin: 0;
122
+ }
123
+
124
+ .chi-header-actions {
125
+ display: flex;
126
+ gap: 8px;
127
+ }
128
+
129
+ .chi-btn-icon {
130
+ background: none;
131
+ border: none;
132
+ color: var(--chi-text-secondary);
133
+ cursor: pointer;
134
+ padding: 4px;
135
+ border-radius: 4px;
136
+ font-size: 16px;
137
+ line-height: 1;
138
+ }
139
+
140
+ .chi-btn-icon:hover {
141
+ color: var(--chi-text);
142
+ background: var(--chi-border);
143
+ }
144
+
145
+ .chi-messages {
146
+ flex: 1;
147
+ overflow-y: auto;
148
+ padding: 16px;
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 12px;
152
+ }
153
+
154
+ .chi-message {
155
+ max-width: 85%;
156
+ padding: 10px 14px;
157
+ border-radius: var(--chi-radius);
158
+ font-size: 14px;
159
+ line-height: 1.5;
160
+ word-wrap: break-word;
161
+ white-space: pre-wrap;
162
+ }
163
+
164
+ .chi-message-user {
165
+ align-self: flex-end;
166
+ background: var(--chi-user-bg);
167
+ color: var(--chi-user-text);
168
+ }
169
+
170
+ .chi-message-assistant {
171
+ align-self: flex-start;
172
+ background: var(--chi-assistant-bg);
173
+ color: var(--chi-assistant-text);
174
+ }
175
+
176
+ .chi-message-error {
177
+ align-self: center;
178
+ background: var(--chi-error-bg);
179
+ color: var(--chi-error-text);
180
+ font-size: 13px;
181
+ text-align: center;
182
+ }
183
+
184
+ .chi-typing {
185
+ align-self: flex-start;
186
+ padding: 10px 14px;
187
+ background: var(--chi-assistant-bg);
188
+ border-radius: var(--chi-radius);
189
+ display: flex;
190
+ gap: 4px;
191
+ align-items: center;
192
+ }
193
+
194
+ .chi-typing-dot {
195
+ width: 6px;
196
+ height: 6px;
197
+ background: var(--chi-text-secondary);
198
+ border-radius: 50%;
199
+ animation: chi-bounce 1.4s infinite ease-in-out both;
200
+ }
201
+
202
+ .chi-typing-dot:nth-child(1) { animation-delay: -0.32s; }
203
+ .chi-typing-dot:nth-child(2) { animation-delay: -0.16s; }
204
+
205
+ @keyframes chi-bounce {
206
+ 0%, 80%, 100% { transform: scale(0); }
207
+ 40% { transform: scale(1); }
208
+ }
209
+
210
+ .chi-input-area {
211
+ display: flex;
212
+ gap: 8px;
213
+ padding: 12px 16px;
214
+ border-top: 1px solid var(--chi-border);
215
+ background: var(--chi-bg);
216
+ flex-shrink: 0;
217
+ }
218
+
219
+ .chi-input {
220
+ flex: 1;
221
+ padding: 10px 14px;
222
+ border: 1px solid var(--chi-border);
223
+ border-radius: 8px;
224
+ background: var(--chi-bg);
225
+ color: var(--chi-text);
226
+ font-size: 14px;
227
+ font-family: inherit;
228
+ resize: none;
229
+ outline: none;
230
+ max-height: 120px;
231
+ min-height: 40px;
232
+ }
233
+
234
+ .chi-input:focus {
235
+ border-color: var(--chi-primary);
236
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
237
+ }
238
+
239
+ .chi-input::placeholder {
240
+ color: var(--chi-text-secondary);
241
+ }
242
+
243
+ .chi-send-btn {
244
+ padding: 10px 16px;
245
+ background: var(--chi-primary);
246
+ color: #ffffff;
247
+ border: none;
248
+ border-radius: 8px;
249
+ cursor: pointer;
250
+ font-size: 14px;
251
+ font-weight: 500;
252
+ flex-shrink: 0;
253
+ transition: background 0.15s;
254
+ }
255
+
256
+ .chi-send-btn:hover:not(:disabled) {
257
+ background: var(--chi-primary-hover);
258
+ }
259
+
260
+ .chi-send-btn:disabled {
261
+ opacity: 0.5;
262
+ cursor: not-allowed;
263
+ }
264
+
265
+ .chi-empty {
266
+ flex: 1;
267
+ display: flex;
268
+ align-items: center;
269
+ justify-content: center;
270
+ color: var(--chi-text-secondary);
271
+ font-size: 14px;
272
+ padding: 32px;
273
+ text-align: center;
274
+ }
275
+
276
+ .chi-powered {
277
+ text-align: center;
278
+ padding: 6px;
279
+ font-size: 11px;
280
+ color: var(--chi-text-secondary);
281
+ border-top: 1px solid var(--chi-border);
282
+ }
283
+
284
+ .chi-powered a {
285
+ color: var(--chi-primary);
286
+ text-decoration: none;
287
+ }
288
+ \`;
289
+
290
+ // ── Web Component ────────────────────────────────────────────────
291
+ class ChimerAIChatWidget extends HTMLElement {
292
+ constructor() {
293
+ super();
294
+ this._shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
295
+ this._messages = [];
296
+ this._conversationId = null;
297
+ this._abortController = null;
298
+ this._isStreaming = false;
299
+ this._config = {};
300
+ }
301
+
302
+ connectedCallback() {
303
+ this._config = this._readConfig();
304
+ this._render();
305
+ this._attachEvents();
306
+
307
+ // Notify ready callback
308
+ if (typeof this._config.onReady === 'function') {
309
+ this._config.onReady();
310
+ }
311
+ }
312
+
313
+ disconnectedCallback() {
314
+ this._abort();
315
+ }
316
+
317
+ // ── Config ───────────────────────────────────────────────────
318
+ _readConfig() {
319
+ const endpoint = this.getAttribute('data-endpoint') || this.getAttribute('data-api-endpoint') || '';
320
+ return {
321
+ apiKey: this.getAttribute('data-api-key') || this.getAttribute('data-apikey') || '',
322
+ endpoint: endpoint.replace(/\\/+$/, ''), // strip trailing slash
323
+ theme: this.getAttribute('data-theme') || 'auto',
324
+ model: this.getAttribute('data-model') || '',
325
+ title: this.getAttribute('data-title') || 'AI Chat',
326
+ placeholder: this.getAttribute('data-placeholder') || 'Type a message...',
327
+ // Callbacks set via mount()
328
+ onReady: this._onReady || null,
329
+ onError: this._onError || null,
330
+ onMessageSent: this._onMessageSent || null,
331
+ onResponseReceived: this._onResponseReceived || null,
332
+ };
333
+ }
334
+
335
+ // ── Render ───────────────────────────────────────────────────
336
+ _render() {
337
+ this.setAttribute('data-theme', this._config.theme);
338
+
339
+ this._shadow.innerHTML = \`
340
+ <style>\${WIDGET_CSS}</style>
341
+ <div class="chi-container">
342
+ <div class="chi-header">
343
+ <span class="chi-header-title">\${this._escapeHtml(this._config.title)}</span>
344
+ <div class="chi-header-actions">
345
+ <button class="chi-btn-icon" id="chi-clear" title="Clear chat">🗑️</button>
346
+ </div>
347
+ </div>
348
+ <div class="chi-messages" id="chi-messages">
349
+ <div class="chi-empty">Start a conversation…</div>
350
+ </div>
351
+ <div class="chi-input-area">
352
+ <textarea
353
+ class="chi-input"
354
+ id="chi-input"
355
+ placeholder="\${this._escapeHtml(this._config.placeholder)}"
356
+ rows="1"
357
+ ></textarea>
358
+ <button class="chi-send-btn" id="chi-send">Send</button>
359
+ </div>
360
+ <div class="chi-powered">Powered by <a href="https://github.com/armbur19-collab/chimerai-kickstart" target="_blank" rel="noopener">ChimerAI</a></div>
361
+ </div>
362
+ \`;
363
+ }
364
+
365
+ // ── Events ───────────────────────────────────────────────────
366
+ _attachEvents() {
367
+ const input = this._shadow.getElementById('chi-input');
368
+ const sendBtn = this._shadow.getElementById('chi-send');
369
+ const clearBtn = this._shadow.getElementById('chi-clear');
370
+
371
+ sendBtn.addEventListener('click', () => this._handleSend());
372
+
373
+ input.addEventListener('keydown', (e) => {
374
+ if (e.key === 'Enter' && !e.shiftKey) {
375
+ e.preventDefault();
376
+ this._handleSend();
377
+ }
378
+ });
379
+
380
+ // Auto-resize textarea
381
+ input.addEventListener('input', () => {
382
+ input.style.height = 'auto';
383
+ input.style.height = Math.min(input.scrollHeight, 120) + 'px';
384
+ });
385
+
386
+ clearBtn.addEventListener('click', () => this.clearMessages());
387
+ }
388
+
389
+ // ── Send Message ─────────────────────────────────────────────
390
+ async _handleSend() {
391
+ const input = this._shadow.getElementById('chi-input');
392
+ const content = input.value.trim();
393
+ if (!content || this._isStreaming) return;
394
+
395
+ input.value = '';
396
+ input.style.height = 'auto';
397
+
398
+ // Add user message
399
+ this._addMessage('user', content);
400
+
401
+ // Callback
402
+ if (typeof this._config.onMessageSent === 'function') {
403
+ this._config.onMessageSent({ role: 'user', content });
404
+ }
405
+
406
+ // Start streaming
407
+ await this._streamResponse(content);
408
+ }
409
+
410
+ // ── SSE Streaming ────────────────────────────────────────────
411
+ async _streamResponse(userMessage) {
412
+ this._isStreaming = true;
413
+ this._setInputEnabled(false);
414
+
415
+ // Show typing indicator
416
+ this._showTyping();
417
+
418
+ this._abortController = new AbortController();
419
+
420
+ const apiUrl = this._config.endpoint
421
+ ? this._config.endpoint + '/api/v1/chat/stream'
422
+ : '/api/v1/chat/stream';
423
+
424
+ const body = {
425
+ messages: this._messages.map(m => ({ role: m.role, content: m.content })),
426
+ };
427
+ if (this._config.model) body.model = this._config.model;
428
+ if (this._conversationId) body.conversationId = this._conversationId;
429
+
430
+ const headers = {
431
+ 'Content-Type': 'application/json',
432
+ };
433
+ if (this._config.apiKey) {
434
+ headers['x-api-key'] = this._config.apiKey;
435
+ }
436
+
437
+ try {
438
+ const response = await fetch(apiUrl, {
439
+ method: 'POST',
440
+ headers,
441
+ body: JSON.stringify(body),
442
+ signal: this._abortController.signal,
443
+ });
444
+
445
+ if (!response.ok) {
446
+ const errorData = await response.json().catch(() => ({}));
447
+ throw new Error(errorData.error || 'Request failed (' + response.status + ')');
448
+ }
449
+
450
+ // Remove typing indicator, add empty assistant message
451
+ this._hideTyping();
452
+ const assistantIdx = this._addMessage('assistant', '');
453
+
454
+ const reader = response.body.getReader();
455
+ const decoder = new TextDecoder();
456
+ let buffer = '';
457
+ let assistantContent = '';
458
+
459
+ while (true) {
460
+ const { done, value } = await reader.read();
461
+ if (done) break;
462
+
463
+ buffer += decoder.decode(value, { stream: true });
464
+ const lines = buffer.split('\\n');
465
+ buffer = lines.pop() || '';
466
+
467
+ for (const line of lines) {
468
+ if (!line.startsWith('data: ')) continue;
469
+ const data = line.slice(6).trim();
470
+ if (data === '[DONE]') continue;
471
+
472
+ try {
473
+ const parsed = JSON.parse(data);
474
+
475
+ if (parsed.type === 'token' && parsed.content) {
476
+ assistantContent += parsed.content;
477
+ this._updateMessage(assistantIdx, assistantContent);
478
+ } else if (parsed.type === 'done') {
479
+ if (parsed.conversationId) {
480
+ this._conversationId = parsed.conversationId;
481
+ }
482
+ } else if (parsed.type === 'error') {
483
+ this._showError(parsed.message || 'Stream error');
484
+ }
485
+ } catch { /* skip unparseable */ }
486
+ }
487
+ }
488
+
489
+ // Callback
490
+ if (typeof this._config.onResponseReceived === 'function') {
491
+ this._config.onResponseReceived({ role: 'assistant', content: assistantContent });
492
+ }
493
+
494
+ } catch (err) {
495
+ this._hideTyping();
496
+ if (err.name !== 'AbortError') {
497
+ this._showError(err.message || 'Connection failed');
498
+ if (typeof this._config.onError === 'function') {
499
+ this._config.onError(err);
500
+ }
501
+ }
502
+ } finally {
503
+ this._isStreaming = false;
504
+ this._abortController = null;
505
+ this._setInputEnabled(true);
506
+ }
507
+ }
508
+
509
+ // ── DOM Helpers ──────────────────────────────────────────────
510
+ _addMessage(role, content) {
511
+ this._messages.push({ role, content });
512
+ const container = this._shadow.getElementById('chi-messages');
513
+
514
+ // Remove empty state
515
+ const empty = container.querySelector('.chi-empty');
516
+ if (empty) empty.remove();
517
+
518
+ const div = document.createElement('div');
519
+ div.className = 'chi-message chi-message-' + role;
520
+ div.textContent = content;
521
+ div.setAttribute('data-idx', String(this._messages.length - 1));
522
+ container.appendChild(div);
523
+ container.scrollTop = container.scrollHeight;
524
+
525
+ return this._messages.length - 1;
526
+ }
527
+
528
+ _updateMessage(idx, content) {
529
+ const container = this._shadow.getElementById('chi-messages');
530
+ const el = container.querySelector('[data-idx="' + idx + '"]');
531
+ if (el) {
532
+ el.textContent = content;
533
+ container.scrollTop = container.scrollHeight;
534
+ }
535
+ if (this._messages[idx]) {
536
+ this._messages[idx].content = content;
537
+ }
538
+ }
539
+
540
+ _showTyping() {
541
+ const container = this._shadow.getElementById('chi-messages');
542
+ const typing = document.createElement('div');
543
+ typing.className = 'chi-typing';
544
+ typing.id = 'chi-typing';
545
+ typing.innerHTML = '<div class="chi-typing-dot"></div><div class="chi-typing-dot"></div><div class="chi-typing-dot"></div>';
546
+ container.appendChild(typing);
547
+ container.scrollTop = container.scrollHeight;
548
+ }
549
+
550
+ _hideTyping() {
551
+ const typing = this._shadow.getElementById('chi-typing');
552
+ if (typing) typing.remove();
553
+ }
554
+
555
+ _showError(message) {
556
+ const container = this._shadow.getElementById('chi-messages');
557
+ const div = document.createElement('div');
558
+ div.className = 'chi-message chi-message-error';
559
+ div.textContent = '⚠ ' + message;
560
+ container.appendChild(div);
561
+ container.scrollTop = container.scrollHeight;
562
+ }
563
+
564
+ _setInputEnabled(enabled) {
565
+ const input = this._shadow.getElementById('chi-input');
566
+ const sendBtn = this._shadow.getElementById('chi-send');
567
+ if (input) input.disabled = !enabled;
568
+ if (sendBtn) {
569
+ sendBtn.disabled = !enabled;
570
+ sendBtn.textContent = enabled ? 'Send' : '...';
571
+ }
572
+ }
573
+
574
+ _escapeHtml(str) {
575
+ const div = document.createElement('div');
576
+ div.textContent = str;
577
+ return div.innerHTML;
578
+ }
579
+
580
+ _abort() {
581
+ if (this._abortController) {
582
+ this._abortController.abort();
583
+ this._abortController = null;
584
+ }
585
+ }
586
+
587
+ // ── Public API (called from mount() return) ──────────────────
588
+ sendMessage(content) {
589
+ const input = this._shadow.getElementById('chi-input');
590
+ if (input) {
591
+ input.value = content;
592
+ this._handleSend();
593
+ }
594
+ }
595
+
596
+ clearMessages() {
597
+ this._messages = [];
598
+ this._conversationId = null;
599
+ this._abort();
600
+ const container = this._shadow.getElementById('chi-messages');
601
+ if (container) {
602
+ container.innerHTML = '<div class="chi-empty">Start a conversation…</div>';
603
+ }
604
+ }
605
+
606
+ setModel(model) {
607
+ this._config.model = model;
608
+ }
609
+ }
610
+
611
+ // Register Custom Element
612
+ if (!customElements.get('chimerai-chat')) {
613
+ customElements.define('chimerai-chat', ChimerAIChatWidget);
614
+ }
615
+
616
+ // ── Auto-mount from data attributes ─────────────────────────────
617
+ function autoMount() {
618
+ document.querySelectorAll('[data-chimerai-chat]').forEach(function(el) {
619
+ if (el._chimeraiMounted) return;
620
+ el._chimeraiMounted = true;
621
+
622
+ var widget = document.createElement('chimerai-chat');
623
+ // Copy data-* attributes
624
+ Array.from(el.attributes).forEach(function(attr) {
625
+ if (attr.name.startsWith('data-') && attr.name !== 'data-chimerai-chat') {
626
+ widget.setAttribute(attr.name, attr.value);
627
+ }
628
+ });
629
+ el.appendChild(widget);
630
+ });
631
+ }
632
+
633
+ // Auto-mount when DOM is ready
634
+ if (document.readyState === 'loading') {
635
+ document.addEventListener('DOMContentLoaded', autoMount);
636
+ } else {
637
+ autoMount();
638
+ }
639
+
640
+ // ── Global API ──────────────────────────────────────────────────
641
+ window.ChimerAI = window.ChimerAI || {};
642
+
643
+ window.ChimerAI.mount = function(selector, config) {
644
+ config = config || {};
645
+ var container = typeof selector === 'string'
646
+ ? document.querySelector(selector)
647
+ : selector;
648
+
649
+ if (!container) {
650
+ throw new Error('ChimerAI: Element not found: ' + selector);
651
+ }
652
+
653
+ var widget = document.createElement('chimerai-chat');
654
+
655
+ // Set attributes from config
656
+ if (config.apiKey) widget.setAttribute('data-api-key', config.apiKey);
657
+ if (config.endpoint) widget.setAttribute('data-endpoint', config.endpoint);
658
+ if (config.theme) widget.setAttribute('data-theme', config.theme);
659
+ if (config.model) widget.setAttribute('data-model', config.model);
660
+ if (config.title) widget.setAttribute('data-title', config.title);
661
+ if (config.placeholder) widget.setAttribute('data-placeholder', config.placeholder);
662
+
663
+ // Set callbacks before appending (connectedCallback reads them)
664
+ if (config.onReady) widget._onReady = config.onReady;
665
+ if (config.onError) widget._onError = config.onError;
666
+ if (config.onMessageSent) widget._onMessageSent = config.onMessageSent;
667
+ if (config.onResponseReceived) widget._onResponseReceived = config.onResponseReceived;
668
+
669
+ // Apply size if given
670
+ if (config.width) widget.style.width = config.width;
671
+ if (config.height) widget.style.height = config.height;
672
+
673
+ container.appendChild(widget);
674
+
675
+ // Return control handle
676
+ return {
677
+ sendMessage: function(msg) { widget.sendMessage(msg); },
678
+ clearMessages: function() { widget.clearMessages(); },
679
+ setModel: function(model) { widget.setModel(model); },
680
+ destroy: function() { widget._abort(); widget.remove(); },
681
+ };
682
+ };
683
+
684
+ window.ChimerAI.version = '1.0.0';
685
+ })();
686
+ `;
687
+ }
688
+ /**
689
+ * Generates the minimal widget loader script.
690
+ * Output: public/widget/loader.js
691
+ *
692
+ * Allows async loading of the widget bundle.
693
+ */
694
+ function generateWidgetLoader() {
695
+ return `// @chimerai component=WidgetLoader version=1.0
696
+ // Async loader for ChimerAI Chat Widget
697
+ // Usage: <script src="https://your-app.com/widget/loader.js" data-api-key="sk_live_..."></script>
698
+ (function() {
699
+ var script = document.currentScript;
700
+ var src = script.src.replace('loader.js', 'chat.js');
701
+ var s = document.createElement('script');
702
+ s.src = src;
703
+ s.onload = function() {
704
+ // Auto-mount if data attributes are on the loader script
705
+ var apiKey = script.getAttribute('data-api-key');
706
+ if (apiKey) {
707
+ var container = document.createElement('div');
708
+ container.style.position = 'fixed';
709
+ container.style.bottom = '20px';
710
+ container.style.right = '20px';
711
+ container.style.width = '380px';
712
+ container.style.height = '520px';
713
+ container.style.zIndex = '99999';
714
+ document.body.appendChild(container);
715
+ window.ChimerAI.mount(container, {
716
+ apiKey: apiKey,
717
+ theme: script.getAttribute('data-theme') || 'auto',
718
+ model: script.getAttribute('data-model') || '',
719
+ title: script.getAttribute('data-title') || 'AI Chat',
720
+ endpoint: script.getAttribute('data-endpoint') || '',
721
+ });
722
+ }
723
+ };
724
+ document.head.appendChild(s);
725
+ })();
726
+ `;
727
+ }
728
+ /**
729
+ * Generates the API-Key Management page.
730
+ * Output: app/(app)/settings/api-keys/page.tsx
731
+ *
732
+ * Features:
733
+ * - List API keys (prefix, name, scopes, last used, created)
734
+ * - Create new key (name, scopes selection, expiration)
735
+ * - Show key once after creation (then never again)
736
+ * - Revoke key (soft-delete)
737
+ * - Embed-code generator: select key → copy-paste HTML snippet
738
+ */
739
+ function generateApiKeyManagementPage() {
740
+ return `// @chimerai component=ApiKeyManagementPage version=2.0
741
+ 'use client';
742
+
743
+ import { useState, useEffect, useCallback } from 'react';
744
+
745
+ interface ApiKeyItem {
746
+ id: string;
747
+ name: string;
748
+ prefix: string;
749
+ scopes: string[];
750
+ revoked: boolean;
751
+ lastUsedAt: string | null;
752
+ expiresAt: string | null;
753
+ createdAt: string;
754
+ }
755
+
756
+ const SCOPE_TEMPLATES: Record<string, string[]> = {
757
+ 'Chat Widget': ['chat'],
758
+ 'Chat + RAG': ['chat', 'rag'],
759
+ 'Read-Only': ['read'],
760
+ 'Full Access': ['*'],
761
+ };
762
+
763
+ export default function ApiKeysPage() {
764
+ const [keys, setKeys] = useState<ApiKeyItem[]>([]);
765
+ const [loading, setLoading] = useState(true);
766
+ const [showCreate, setShowCreate] = useState(false);
767
+ const [newKeyName, setNewKeyName] = useState('');
768
+ const [newKeyScopes, setNewKeyScopes] = useState<string[]>(['chat']);
769
+ const [newKeyExpDays, setNewKeyExpDays] = useState(90);
770
+ const [createdKey, setCreatedKey] = useState<string | null>(null);
771
+ const [showEmbed, setShowEmbed] = useState<string | null>(null);
772
+ const [error, setError] = useState<string | null>(null);
773
+
774
+ const appUrl = typeof window !== 'undefined' ? window.location.origin : '';
775
+
776
+ const fetchKeys = useCallback(async () => {
777
+ try {
778
+ const res = await fetch('/api/v1/api-keys');
779
+ if (!res.ok) throw new Error('Failed to load keys');
780
+ const data = await res.json();
781
+ setKeys(data.keys || []);
782
+ } catch (err: any) {
783
+ setError(err.message);
784
+ } finally {
785
+ setLoading(false);
786
+ }
787
+ }, []);
788
+
789
+ useEffect(() => { fetchKeys(); }, [fetchKeys]);
790
+
791
+ const handleCreate = async () => {
792
+ if (!newKeyName.trim()) return;
793
+ setError(null);
794
+ try {
795
+ const res = await fetch('/api/v1/api-keys', {
796
+ method: 'POST',
797
+ headers: { 'Content-Type': 'application/json' },
798
+ body: JSON.stringify({
799
+ name: newKeyName.trim(),
800
+ scopes: newKeyScopes,
801
+ expiresInDays: newKeyExpDays > 0 ? newKeyExpDays : null,
802
+ }),
803
+ });
804
+ if (!res.ok) {
805
+ const data = await res.json().catch(() => ({}));
806
+ throw new Error(data.error || 'Failed to create key');
807
+ }
808
+ const data = await res.json();
809
+ setCreatedKey(data.key);
810
+ setNewKeyName('');
811
+ setNewKeyScopes(['chat']);
812
+ setNewKeyExpDays(90);
813
+ setShowCreate(false);
814
+ fetchKeys();
815
+ } catch (err: any) {
816
+ setError(err.message);
817
+ }
818
+ };
819
+
820
+ const handleRevoke = async (id: string) => {
821
+ if (!confirm('Revoke this API key? This cannot be undone.')) return;
822
+ try {
823
+ const res = await fetch('/api/v1/api-keys/' + id, { method: 'DELETE' });
824
+ if (!res.ok) throw new Error('Failed to revoke key');
825
+ fetchKeys();
826
+ } catch (err: any) {
827
+ setError(err.message);
828
+ }
829
+ };
830
+
831
+ const copyToClipboard = (text: string) => {
832
+ navigator.clipboard.writeText(text).catch(() => {});
833
+ };
834
+
835
+ const embedCode = (prefix: string) => \`<!-- ChimerAI Chat Widget -->
836
+ <script src="\${appUrl}/widget/chat.js"><\\/script>
837
+ <div id="chimerai-chat" style="width: 400px; height: 600px;"></div>
838
+ <script>
839
+ ChimerAI.mount('#chimerai-chat', {
840
+ apiKey: '\${prefix}...', // Use your full API key
841
+ endpoint: '\${appUrl}',
842
+ theme: 'auto',
843
+ });
844
+ <\\/script>\`;
845
+
846
+ return (
847
+ <div className="max-w-4xl mx-auto p-6">
848
+ {/* Header */}
849
+ <div className="flex items-center justify-between mb-6">
850
+ <div>
851
+ <h1 className="text-2xl font-bold dark:text-white">API Keys</h1>
852
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
853
+ Manage API keys for external integrations and widgets.
854
+ </p>
855
+ </div>
856
+ <button
857
+ onClick={() => setShowCreate(true)}
858
+ className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
859
+ >
860
+ + Create Key
861
+ </button>
862
+ </div>
863
+
864
+ {/* Error Banner */}
865
+ {error && (
866
+ <div className="flex items-center justify-between p-3 mb-4 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg border border-red-200 dark:border-red-800">
867
+ <span>{error}</span>
868
+ <button onClick={() => setError(null)} className="ml-2 hover:opacity-70">✕</button>
869
+ </div>
870
+ )}
871
+
872
+ {/* Created Key Banner — shown ONCE */}
873
+ {createdKey && (
874
+ <div className="p-4 mb-4 bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700 rounded-lg">
875
+ <p className="font-semibold text-green-800 dark:text-green-300 mb-2">
876
+ 🔑 API Key Created — Copy it now! It won't be shown again.
877
+ </p>
878
+ <div className="flex gap-2">
879
+ <code className="flex-1 p-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-sm break-all font-mono dark:text-gray-200">
880
+ {createdKey}
881
+ </code>
882
+ <button
883
+ onClick={() => copyToClipboard(createdKey)}
884
+ className="px-4 py-2 bg-green-700 hover:bg-green-800 text-white rounded transition-colors text-sm"
885
+ >
886
+ Copy
887
+ </button>
888
+ </div>
889
+ <button
890
+ onClick={() => setCreatedKey(null)}
891
+ className="mt-2 text-sm text-green-700 dark:text-green-400 underline hover:opacity-70"
892
+ >
893
+ I've copied it, dismiss
894
+ </button>
895
+ </div>
896
+ )}
897
+
898
+ {/* Create Key Dialog */}
899
+ {showCreate && (
900
+ <div className="p-5 mb-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
901
+ <h3 className="text-base font-semibold mb-3 dark:text-white">Create New API Key</h3>
902
+
903
+ <label className="block mb-1 text-sm font-medium dark:text-gray-300">Name</label>
904
+ <input
905
+ type="text"
906
+ value={newKeyName}
907
+ onChange={(e) => setNewKeyName(e.target.value)}
908
+ placeholder="e.g. My Blog Widget"
909
+ className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded mb-3 bg-white dark:bg-gray-700 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
910
+ />
911
+
912
+ <label className="block mb-1 text-sm font-medium dark:text-gray-300">Scope Template</label>
913
+ <div className="flex gap-2 flex-wrap mb-3">
914
+ {Object.entries(SCOPE_TEMPLATES).map(([label, scopes]) => (
915
+ <button
916
+ key={label}
917
+ onClick={() => setNewKeyScopes(scopes)}
918
+ className={\`px-3 py-1.5 text-sm border rounded transition-colors \${
919
+ JSON.stringify(newKeyScopes) === JSON.stringify(scopes)
920
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
921
+ : 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
922
+ }\`}
923
+ >
924
+ {label}
925
+ </button>
926
+ ))}
927
+ </div>
928
+
929
+ <label className="block mb-1 text-sm font-medium dark:text-gray-300">
930
+ Expiration (days, 0 = never)
931
+ </label>
932
+ <input
933
+ type="number"
934
+ value={newKeyExpDays}
935
+ onChange={(e) => setNewKeyExpDays(Number(e.target.value))}
936
+ min={0}
937
+ className="w-28 p-2 border border-gray-300 dark:border-gray-600 rounded mb-4 bg-white dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
938
+ />
939
+
940
+ <div className="flex gap-2">
941
+ <button
942
+ onClick={handleCreate}
943
+ disabled={!newKeyName.trim()}
944
+ className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded transition-colors font-medium"
945
+ >
946
+ Create
947
+ </button>
948
+ <button
949
+ onClick={() => setShowCreate(false)}
950
+ className="px-5 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 dark:text-gray-300 transition-colors"
951
+ >
952
+ Cancel
953
+ </button>
954
+ </div>
955
+ </div>
956
+ )}
957
+
958
+ {/* Keys Table */}
959
+ {loading ? (
960
+ <p className="text-gray-500 dark:text-gray-400">Loading...</p>
961
+ ) : keys.length === 0 ? (
962
+ <div className="text-center py-12 text-gray-500 dark:text-gray-400">
963
+ <p className="text-base mb-1">No API keys yet.</p>
964
+ <p className="text-sm">Create one to embed ChimerAI chat in external websites.</p>
965
+ </div>
966
+ ) : (
967
+ <div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
968
+ <table className="w-full text-left">
969
+ <thead>
970
+ <tr className="border-b-2 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
971
+ <th className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
972
+ <th className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Key</th>
973
+ <th className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scopes</th>
974
+ <th className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Used</th>
975
+ <th className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Expires</th>
976
+ <th className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
977
+ </tr>
978
+ </thead>
979
+ <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
980
+ {keys.map((k) => (
981
+ <tr key={k.id} className={\`\${k.revoked ? 'opacity-50' : ''} hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors\`}>
982
+ <td className="px-3 py-2.5 text-sm dark:text-gray-200">{k.name}</td>
983
+ <td className="px-3 py-2.5 text-xs font-mono dark:text-gray-300">{k.prefix}...</td>
984
+ <td className="px-3 py-2.5 text-xs dark:text-gray-300">
985
+ {k.scopes.length > 0
986
+ ? k.scopes.map((s) => (
987
+ <span key={s} className="inline-block mr-1 px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">
988
+ {s}
989
+ </span>
990
+ ))
991
+ : <span className="text-gray-400">unrestricted</span>}
992
+ </td>
993
+ <td className="px-3 py-2.5 text-xs text-gray-500 dark:text-gray-400">
994
+ {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never'}
995
+ </td>
996
+ <td className="px-3 py-2.5 text-xs text-gray-500 dark:text-gray-400">
997
+ {k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'}
998
+ </td>
999
+ <td className="px-3 py-2.5">
1000
+ {k.revoked ? (
1001
+ <span className="text-xs text-red-600 dark:text-red-400 font-medium">Revoked</span>
1002
+ ) : (
1003
+ <div className="flex gap-1">
1004
+ <button
1005
+ onClick={() => setShowEmbed(showEmbed === k.id ? null : k.id)}
1006
+ className="px-2 py-1 text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-700 rounded hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
1007
+ title="Embed code"
1008
+ >
1009
+ {'</>'}
1010
+ </button>
1011
+ <button
1012
+ onClick={() => handleRevoke(k.id)}
1013
+ className="px-2 py-1 text-xs bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-700 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
1014
+ >
1015
+ Revoke
1016
+ </button>
1017
+ </div>
1018
+ )}
1019
+ {showEmbed === k.id && (
1020
+ <div className="mt-2 p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md">
1021
+ <p className="text-xs font-medium mb-1.5 dark:text-gray-300">Embed Code:</p>
1022
+ <pre className="text-[11px] whitespace-pre-wrap bg-gray-900 text-gray-200 p-3 rounded overflow-auto">
1023
+ {embedCode(k.prefix)}
1024
+ </pre>
1025
+ <button
1026
+ onClick={() => copyToClipboard(embedCode(k.prefix))}
1027
+ className="mt-1.5 px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
1028
+ >
1029
+ Copy
1030
+ </button>
1031
+ </div>
1032
+ )}
1033
+ </td>
1034
+ </tr>
1035
+ ))}
1036
+ </tbody>
1037
+ </table>
1038
+ </div>
1039
+ )}
1040
+ </div>
1041
+ );
1042
+ }
1043
+ `;
1044
+ }
1045
+ /**
1046
+ * Generates the API-Key CRUD route.
1047
+ * Output: app/api/v1/api-keys/route.ts
1048
+ *
1049
+ * GET — list current user's API keys
1050
+ * POST — create a new key
1051
+ */
1052
+ function generateApiKeysRoute() {
1053
+ return `// @chimerai component=ApiKeysRoute version=1.0
1054
+ import { NextRequest, NextResponse } from 'next/server';
1055
+ import { getServerSession } from 'next-auth';
1056
+ import { authOptions } from '@/lib/auth';
1057
+ import { prisma } from '@/lib/prisma';
1058
+ import { hashApiKey } from '@/lib/api-key-auth';
1059
+ import crypto from 'crypto';
1060
+
1061
+ export async function GET() {
1062
+ const session = await getServerSession(authOptions);
1063
+ if (!session?.user?.id) {
1064
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
1065
+ }
1066
+
1067
+ const keys = await (prisma as any).apiKey.findMany({
1068
+ where: { userId: session.user.id },
1069
+ orderBy: { createdAt: 'desc' },
1070
+ select: {
1071
+ id: true,
1072
+ name: true,
1073
+ keyHash: true,
1074
+ scopes: true,
1075
+ revoked: true,
1076
+ lastUsedAt: true,
1077
+ expiresAt: true,
1078
+ createdAt: true,
1079
+ },
1080
+ });
1081
+
1082
+ // Only return prefix (first 12 chars of hash)
1083
+ // Parse scopes from DB string to array for frontend
1084
+ const safeKeys = keys.map((k: any) => ({
1085
+ ...k,
1086
+ scopes: typeof k.scopes === 'string' ? (k.scopes ? k.scopes.split(',') : []) : (k.scopes || []),
1087
+ prefix: 'sk_...' + k.keyHash.slice(0, 8),
1088
+ keyHash: undefined,
1089
+ }));
1090
+
1091
+ return NextResponse.json({ keys: safeKeys });
1092
+ }
1093
+
1094
+ export async function POST(request: NextRequest) {
1095
+ const session = await getServerSession(authOptions);
1096
+ if (!session?.user?.id) {
1097
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
1098
+ }
1099
+
1100
+ const body = await request.json();
1101
+ const { name, scopes = [], expiresInDays } = body;
1102
+
1103
+ if (!name || typeof name !== 'string') {
1104
+ return NextResponse.json({ error: 'Name is required' }, { status: 400 });
1105
+ }
1106
+
1107
+ // Generate API key
1108
+ const rawKey = 'sk_live_' + crypto.randomBytes(24).toString('hex');
1109
+ const keyHash = hashApiKey(rawKey);
1110
+
1111
+ const expiresAt = expiresInDays
1112
+ ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
1113
+ : null;
1114
+
1115
+ await (prisma as any).apiKey.create({
1116
+ data: {
1117
+ name,
1118
+ keyHash,
1119
+ userId: session.user.id,
1120
+ // SQLite stores scopes as comma-separated String; PostgreSQL as String[]
1121
+ // Detect DB type from DATABASE_URL to pick the right format
1122
+ scopes: process.env.DATABASE_URL?.startsWith('file:')
1123
+ ? (Array.isArray(scopes) ? scopes.join(',') : (scopes || ''))
1124
+ : (Array.isArray(scopes) ? scopes : [scopes || '']),
1125
+ expiresAt,
1126
+ },
1127
+ });
1128
+
1129
+ // Return the full key ONCE — it's never stored/shown again
1130
+ return NextResponse.json({ key: rawKey, message: 'Key created. Copy it now — it will not be shown again.' });
1131
+ }
1132
+ `;
1133
+ }
1134
+ /**
1135
+ * Generates the API-Key [id] route for revocation.
1136
+ * Output: app/api/v1/api-keys/[id]/route.ts
1137
+ */
1138
+ function generateApiKeyIdRoute() {
1139
+ return `// @chimerai component=ApiKeyIdRoute version=1.0
1140
+ import { NextResponse } from 'next/server';
1141
+ import { getServerSession } from 'next-auth';
1142
+ import { authOptions } from '@/lib/auth';
1143
+ import { prisma } from '@/lib/prisma';
1144
+
1145
+ export async function DELETE(
1146
+ _request: Request,
1147
+ { params }: { params: Promise<{ id: string }> }
1148
+ ) {
1149
+ const { id } = await params;
1150
+ const session = await getServerSession(authOptions);
1151
+ if (!session?.user?.id) {
1152
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
1153
+ }
1154
+
1155
+ const key = await (prisma as any).apiKey.findUnique({
1156
+ where: { id: id },
1157
+ });
1158
+
1159
+ if (!key || key.userId !== session.user.id) {
1160
+ return NextResponse.json({ error: 'Not found' }, { status: 404 });
1161
+ }
1162
+
1163
+ await (prisma as any).apiKey.update({
1164
+ where: { id: id },
1165
+ data: { revoked: true },
1166
+ });
1167
+
1168
+ return NextResponse.json({ message: 'Key revoked' });
1169
+ }
1170
+ `;
1171
+ }
1172
+ /**
1173
+ * Generates the rate limiter with API-key-specific tier.
1174
+ * Output: lib/rate-limit.ts
1175
+ *
1176
+ * Features:
1177
+ * - In-memory fallback (single-instance)
1178
+ * - Upstash Redis if configured (multi-instance / serverless)
1179
+ * - Separate limits: session-based (per userId) vs API-key (per apiKeyId)
1180
+ * - API-key default: 60 req/min
1181
+ */
1182
+ function generateRateLimiter() {
1183
+ return `// @chimerai component=RateLimiter version=1.1
1184
+ /**
1185
+ * Rate Limiter with API-Key support
1186
+ *
1187
+ * Supports:
1188
+ * - In-memory fallback (single-instance, dev)
1189
+ * - Upstash Redis (multi-instance, production)
1190
+ * - Per-user rate limits (session auth)
1191
+ * - Per-API-key rate limits (widget/external auth)
1192
+ */
1193
+
1194
+ // ── Rate Limit Tiers ─────────────────────────────────────────────
1195
+ export const RATE_LIMITS = {
1196
+ session: { maxRequests: 100, windowMs: 60_000 }, // 100 req/min for logged-in users
1197
+ apiKey: { maxRequests: 60, windowMs: 60_000 }, // 60 req/min for API keys
1198
+ global: { maxRequests: 200, windowMs: 60_000 }, // 200 req/min global fallback
1199
+ } as const;
1200
+
1201
+ export interface RateLimitResult {
1202
+ allowed: boolean;
1203
+ remaining: number;
1204
+ resetAt: number; // Unix timestamp (ms)
1205
+ retryAfterMs: number; // 0 if allowed
1206
+ }
1207
+
1208
+ // ── In-Memory Store ──────────────────────────────────────────────
1209
+ const store = new Map<string, { count: number; resetAt: number }>();
1210
+
1211
+ // Periodic cleanup to prevent memory leaks
1212
+ if (typeof setInterval !== 'undefined') {
1213
+ setInterval(() => {
1214
+ const now = Date.now();
1215
+ for (const [key, record] of store.entries()) {
1216
+ if (record.resetAt < now) store.delete(key);
1217
+ }
1218
+ }, 60_000);
1219
+ }
1220
+
1221
+ function checkInMemory(
1222
+ key: string,
1223
+ limit: number,
1224
+ windowMs: number
1225
+ ): RateLimitResult {
1226
+ const now = Date.now();
1227
+ let record = store.get(key);
1228
+
1229
+ if (!record || record.resetAt <= now) {
1230
+ record = { count: 1, resetAt: now + windowMs };
1231
+ store.set(key, record);
1232
+ return { allowed: true, remaining: limit - 1, resetAt: record.resetAt, retryAfterMs: 0 };
1233
+ }
1234
+
1235
+ record.count++;
1236
+ store.set(key, record);
1237
+
1238
+ if (record.count > limit) {
1239
+ return {
1240
+ allowed: false,
1241
+ remaining: 0,
1242
+ resetAt: record.resetAt,
1243
+ retryAfterMs: record.resetAt - now,
1244
+ };
1245
+ }
1246
+
1247
+ return {
1248
+ allowed: true,
1249
+ remaining: Math.max(0, limit - record.count),
1250
+ resetAt: record.resetAt,
1251
+ retryAfterMs: 0,
1252
+ };
1253
+ }
1254
+
1255
+ // ── Upstash Redis (optional) ─────────────────────────────────────
1256
+ let upstashLimiter: any = null;
1257
+ let upstashInitDone = false;
1258
+
1259
+ async function initUpstash() {
1260
+ if (upstashInitDone) return;
1261
+ upstashInitDone = true;
1262
+
1263
+ if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
1264
+ try {
1265
+ // @ts-ignore — optional dependency, falls back to in-memory if not installed
1266
+ const { Ratelimit } = await import('@upstash/ratelimit');
1267
+ // @ts-ignore — optional dependency
1268
+ const { Redis } = await import('@upstash/redis');
1269
+
1270
+ const redis = new Redis({
1271
+ url: process.env.UPSTASH_REDIS_REST_URL,
1272
+ token: process.env.UPSTASH_REDIS_REST_TOKEN,
1273
+ });
1274
+
1275
+ upstashLimiter = new Ratelimit({
1276
+ redis,
1277
+ limiter: Ratelimit.slidingWindow(60, '1 m'),
1278
+ analytics: false,
1279
+ prefix: 'chimerai:rl',
1280
+ });
1281
+ } catch {
1282
+ // @upstash packages not installed — use in-memory
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ // ── Public API ───────────────────────────────────────────────────
1288
+
1289
+ /**
1290
+ * Check rate limit for a session-based user.
1291
+ */
1292
+ export async function checkSessionRateLimit(userId: string): Promise<RateLimitResult> {
1293
+ return checkRateLimit('session:' + userId, RATE_LIMITS.session);
1294
+ }
1295
+
1296
+ /**
1297
+ * Check rate limit for an API-key-based request.
1298
+ */
1299
+ export async function checkApiKeyRateLimit(apiKeyId: string): Promise<RateLimitResult> {
1300
+ return checkRateLimit('apikey:' + apiKeyId, RATE_LIMITS.apiKey);
1301
+ }
1302
+
1303
+ /**
1304
+ * Generic rate limit check. Tries Upstash first, falls back to in-memory.
1305
+ */
1306
+ export async function checkRateLimit(
1307
+ identifier: string,
1308
+ config: { maxRequests: number; windowMs: number } = RATE_LIMITS.global
1309
+ ): Promise<RateLimitResult> {
1310
+ await initUpstash();
1311
+
1312
+ // Try Upstash first
1313
+ if (upstashLimiter) {
1314
+ try {
1315
+ const result = await upstashLimiter.limit(identifier);
1316
+ return {
1317
+ allowed: result.success,
1318
+ remaining: result.remaining,
1319
+ resetAt: result.reset,
1320
+ retryAfterMs: result.success ? 0 : Math.max(0, result.reset - Date.now()),
1321
+ };
1322
+ } catch {
1323
+ // Upstash failed — fall through to in-memory
1324
+ }
1325
+ }
1326
+
1327
+ // In-memory fallback
1328
+ return checkInMemory(identifier, config.maxRequests, config.windowMs);
1329
+ }
1330
+
1331
+ /**
1332
+ * Adds rate limit headers to a Response.
1333
+ */
1334
+ export function withRateLimitHeaders(
1335
+ response: Response,
1336
+ result: RateLimitResult
1337
+ ): Response {
1338
+ const headers = new Headers(response.headers);
1339
+ headers.set('X-RateLimit-Remaining', String(result.remaining));
1340
+ headers.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
1341
+ if (!result.allowed) {
1342
+ headers.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
1343
+ }
1344
+ return new Response(response.body, {
1345
+ status: response.status,
1346
+ statusText: response.statusText,
1347
+ headers,
1348
+ });
1349
+ }
1350
+
1351
+ // ── Startup Warning ──────────────────────────────────────────────
1352
+ if (process.env.NODE_ENV === 'production' && !process.env.UPSTASH_REDIS_REST_URL) {
1353
+ console.warn(
1354
+ '⚠️ UPSTASH_REDIS_REST_URL not configured. Rate-limiting uses in-memory fallback. ' +
1355
+ 'This works for single-instance deployments but NOT for serverless (Vercel, AWS Lambda). ' +
1356
+ 'For serverless: configure Upstash Redis → https://upstash.com'
1357
+ );
1358
+ }
1359
+ `;
1360
+ }