@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.
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +317 -0
- package/dist/commands/add.d.ts +11 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +2126 -0
- package/dist/commands/create.d.ts +12 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +1703 -0
- package/dist/commands/deploy.d.ts +11 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +219 -0
- package/dist/commands/dev.d.ts +17 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +206 -0
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +728 -0
- package/dist/commands/generate.d.ts +19 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +429 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +269 -0
- package/dist/commands/list.d.ts +12 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +328 -0
- package/dist/commands/migrate.d.ts +14 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +197 -0
- package/dist/commands/plugin.d.ts +10 -0
- package/dist/commands/plugin.d.ts.map +1 -0
- package/dist/commands/plugin.js +239 -0
- package/dist/commands/remove.d.ts +11 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/remove.js +472 -0
- package/dist/commands/secret.d.ts +12 -0
- package/dist/commands/secret.d.ts.map +1 -0
- package/dist/commands/secret.js +102 -0
- package/dist/commands/setup.d.ts +9 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +788 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +211 -0
- package/dist/commands/use.d.ts +9 -0
- package/dist/commands/use.d.ts.map +1 -0
- package/dist/commands/use.js +51 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/license.d.ts +55 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +258 -0
- package/dist/scanner.d.ts +31 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +113 -0
- package/dist/schema-manager.d.ts +26 -0
- package/dist/schema-manager.d.ts.map +1 -0
- package/dist/schema-manager.js +132 -0
- package/dist/templates/admin.d.ts +49 -0
- package/dist/templates/admin.d.ts.map +1 -0
- package/dist/templates/admin.js +1358 -0
- package/dist/templates/ai-routes.d.ts +17 -0
- package/dist/templates/ai-routes.d.ts.map +1 -0
- package/dist/templates/ai-routes.js +1130 -0
- package/dist/templates/ai-service-tools.d.ts +22 -0
- package/dist/templates/ai-service-tools.d.ts.map +1 -0
- package/dist/templates/ai-service-tools.js +1424 -0
- package/dist/templates/ai-service.d.ts +66 -0
- package/dist/templates/ai-service.d.ts.map +1 -0
- package/dist/templates/ai-service.js +2202 -0
- package/dist/templates/api-routes.d.ts +108 -0
- package/dist/templates/api-routes.d.ts.map +1 -0
- package/dist/templates/api-routes.js +1219 -0
- package/dist/templates/auth.d.ts +48 -0
- package/dist/templates/auth.d.ts.map +1 -0
- package/dist/templates/auth.js +381 -0
- package/dist/templates/billing.d.ts +44 -0
- package/dist/templates/billing.d.ts.map +1 -0
- package/dist/templates/billing.js +551 -0
- package/dist/templates/chat.d.ts +63 -0
- package/dist/templates/chat.d.ts.map +1 -0
- package/dist/templates/chat.js +1979 -0
- package/dist/templates/components.d.ts +22 -0
- package/dist/templates/components.d.ts.map +1 -0
- package/dist/templates/components.js +672 -0
- package/dist/templates/config.d.ts +6 -0
- package/dist/templates/config.d.ts.map +1 -0
- package/dist/templates/config.js +86 -0
- package/dist/templates/docker.d.ts +25 -0
- package/dist/templates/docker.d.ts.map +1 -0
- package/dist/templates/docker.js +165 -0
- package/dist/templates/gdpr.d.ts +16 -0
- package/dist/templates/gdpr.d.ts.map +1 -0
- package/dist/templates/gdpr.js +259 -0
- package/dist/templates/index.d.ts +77 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +339 -0
- package/dist/templates/layout.d.ts +67 -0
- package/dist/templates/layout.d.ts.map +1 -0
- package/dist/templates/layout.js +670 -0
- package/dist/templates/mfa.d.ts +23 -0
- package/dist/templates/mfa.d.ts.map +1 -0
- package/dist/templates/mfa.js +353 -0
- package/dist/templates/middleware.d.ts +12 -0
- package/dist/templates/middleware.d.ts.map +1 -0
- package/dist/templates/middleware.js +116 -0
- package/dist/templates/prisma.d.ts +35 -0
- package/dist/templates/prisma.d.ts.map +1 -0
- package/dist/templates/prisma.js +724 -0
- package/dist/templates/provider-routes.d.ts +21 -0
- package/dist/templates/provider-routes.d.ts.map +1 -0
- package/dist/templates/provider-routes.js +1203 -0
- package/dist/templates/rag.d.ts +48 -0
- package/dist/templates/rag.d.ts.map +1 -0
- package/dist/templates/rag.js +532 -0
- package/dist/templates/widget.d.ts +64 -0
- package/dist/templates/widget.d.ts.map +1 -0
- package/dist/templates/widget.js +1360 -0
- package/dist/utils/provider-db.d.ts +63 -0
- package/dist/utils/provider-db.d.ts.map +1 -0
- package/dist/utils/provider-db.js +300 -0
- package/dist/utils.d.ts +78 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +330 -0
- 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
|
+
}
|