@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,1979 @@
1
+ "use strict";
2
+ /**
3
+ * Chat component and API route templates
4
+ * Generates streaming chat UI, conversation management, and AI model integration
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.generateChatPage = generateChatPage;
8
+ exports.generateChatStreamRouteWithPersistence = generateChatStreamRouteWithPersistence;
9
+ exports.generateConversationsRoute = generateConversationsRoute;
10
+ exports.generateUseChatHook = generateUseChatHook;
11
+ exports.generateChatMessage = generateChatMessage;
12
+ exports.generateChatInput = generateChatInput;
13
+ exports.generateChatSidebar = generateChatSidebar;
14
+ exports.generateModelSelector = generateModelSelector;
15
+ exports.generateConversationDetailRoute = generateConversationDetailRoute;
16
+ /**
17
+ * Generates the main chat page component
18
+ * Displays conversations, allows provider selection, integrates StreamingChat component
19
+ * @returns TypeScript/JSX content for app/dashboard/chat/page.tsx
20
+ */
21
+ function generateChatPage() {
22
+ return `// @chimerai component=ChatPage version=2.0
23
+ 'use client';
24
+
25
+ import { useSession, signOut } from 'next-auth/react';
26
+ import { useRouter } from 'next/navigation';
27
+ import { useEffect, useRef, useState } from 'react';
28
+ import { useAppName } from '@/lib/use-app-name';
29
+ import { useChat } from '@/components/chat/use-chat';
30
+ import { ChatMessage } from '@/components/chat/chat-message';
31
+ import { ChatInput } from '@/components/chat/chat-input';
32
+ import { ChatSidebar } from '@/components/chat/chat-sidebar';
33
+ import { ModelSelector } from '@/components/chat/model-selector';
34
+ import type { MessageActions } from '@/components/chat/chat-message';
35
+
36
+ export default function ChatPage() {
37
+ const { data: session, status } = useSession();
38
+ const router = useRouter();
39
+ const messagesEndRef = useRef<HTMLDivElement>(null);
40
+ const [sidebarOpen, setSidebarOpen] = useState(false);
41
+ const appName = useAppName();
42
+
43
+ const {
44
+ messages,
45
+ isStreaming,
46
+ conversations,
47
+ selectedConversationId,
48
+ models,
49
+ selectedModelId,
50
+ creditBalance,
51
+ isLoadingConversation,
52
+ sendMessage,
53
+ stopStreaming,
54
+ selectConversation,
55
+ startNewChat,
56
+ deleteConversation,
57
+ renameConversation,
58
+ setSelectedModelId,
59
+ regenerateMessage,
60
+ editMessage,
61
+ deleteMessage,
62
+ } = useChat();
63
+
64
+ // Auth redirect
65
+ useEffect(() => {
66
+ if (status === 'loading') return;
67
+ if (status === 'unauthenticated') router.push('/auth/signin');
68
+ }, [status, router]);
69
+
70
+ // Set page title
71
+ useEffect(() => {
72
+ document.title = \`Chat \u2014 \${appName}\`;
73
+ }, [appName]);
74
+
75
+ // Auto-scroll
76
+ useEffect(() => {
77
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
78
+ }, [messages]);
79
+
80
+ // Close sidebar on conversation select (mobile)
81
+ const handleSelectConversation = (id: string) => {
82
+ selectConversation(id);
83
+ setSidebarOpen(false);
84
+ };
85
+
86
+ const handleNewChat = () => {
87
+ startNewChat();
88
+ setSidebarOpen(false);
89
+ };
90
+
91
+ if (status === 'loading' || !session) {
92
+ return (
93
+ <div className="flex h-screen items-center justify-center">
94
+ <div className="text-gray-400">Loading...</div>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ const messageActions: MessageActions = {
100
+ onRegenerate: regenerateMessage,
101
+ onEdit: editMessage,
102
+ onDelete: deleteMessage,
103
+ };
104
+
105
+ return (
106
+ <div className="flex h-screen bg-white dark:bg-gray-950 relative">
107
+ {/* Mobile Overlay */}
108
+ {sidebarOpen && (
109
+ <div
110
+ className="fixed inset-0 bg-black/50 z-30 md:hidden"
111
+ onClick={() => setSidebarOpen(false)}
112
+ />
113
+ )}
114
+
115
+ {/* Sidebar — fixed on mobile, static on desktop */}
116
+ <aside className={\`fixed inset-y-0 left-0 z-40 w-72 border-r border-gray-200 dark:border-gray-800 transform transition-transform duration-200 ease-in-out
117
+ \${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
118
+ md:relative md:translate-x-0 md:z-auto\`}>
119
+ <ChatSidebar
120
+ conversations={conversations}
121
+ selectedId={selectedConversationId}
122
+ onSelect={handleSelectConversation}
123
+ onNewChat={handleNewChat}
124
+ onDelete={deleteConversation}
125
+ onRename={renameConversation}
126
+ />
127
+ </aside>
128
+
129
+ {/* Main Area */}
130
+ <main className="flex-1 flex flex-col min-w-0">
131
+ {/* Header */}
132
+ <div className="flex items-center gap-3 border-b border-gray-200 dark:border-gray-800 px-4 py-2">
133
+ <button
134
+ onClick={() => setSidebarOpen(!sidebarOpen)}
135
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 md:hidden"
136
+ aria-label="Toggle sidebar"
137
+ >
138
+ <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
139
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
140
+ </svg>
141
+ </button>
142
+ <ModelSelector
143
+ models={models}
144
+ value={selectedModelId}
145
+ onValueChange={setSelectedModelId}
146
+ disabled={isStreaming}
147
+ />
148
+ <div className="flex-1" />
149
+ {selectedConversationId && (
150
+ <span className="text-xs text-gray-400">Conversation active</span>
151
+ )}
152
+ <button
153
+ onClick={() => signOut({ callbackUrl: '/auth/signin' })}
154
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
155
+ title="Sign out"
156
+ >
157
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
158
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
159
+ </svg>
160
+ </button>
161
+ </div>
162
+
163
+ {/* Messages Area */}
164
+ <div className="flex-1 overflow-y-auto">
165
+ {isLoadingConversation ? (
166
+ <div className="flex items-center justify-center h-full">
167
+ <div className="text-gray-400">Loading conversation...</div>
168
+ </div>
169
+ ) : messages.length === 0 ? (
170
+ <div className="flex flex-col items-center justify-center h-full text-center px-4">
171
+ <svg className="h-12 w-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
172
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
173
+ </svg>
174
+ <h2 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-1">Start a conversation</h2>
175
+ <p className="text-sm text-gray-400">Send a message to begin chatting with AI</p>
176
+ </div>
177
+ ) : (
178
+ <div className="max-w-3xl mx-auto">
179
+ {messages.map((msg, idx) => (
180
+ <ChatMessage
181
+ key={msg.id || idx}
182
+ message={msg}
183
+ index={idx}
184
+ actions={messageActions}
185
+ />
186
+ ))}
187
+ <div ref={messagesEndRef} />
188
+ </div>
189
+ )}
190
+ </div>
191
+
192
+ {/* Input */}
193
+ <ChatInput
194
+ onSend={sendMessage}
195
+ onStop={stopStreaming}
196
+ isStreaming={isStreaming}
197
+ creditBalance={creditBalance}
198
+ />
199
+ </main>
200
+ </div>
201
+ );
202
+ }
203
+ `;
204
+ }
205
+ /**
206
+ * Generates the streaming Chat API route — Direct LLM provider communication with DB persistence
207
+ * Handles conversation creation, message saving, multi-provider streaming (OpenAI, Anthropic, Ollama, Groq)
208
+ * @returns TypeScript content for app/api/v1/chat/stream/route.ts
209
+ */
210
+ function generateChatStreamRouteWithPersistence() {
211
+ return `// @chimerai component=ChatStreamRoute version=2.0
212
+ /**
213
+ * Streaming Chat API Route — Direct Provider Communication with DB Persistence
214
+ * POST /api/v1/chat/stream
215
+ *
216
+ * Communicates DIRECTLY with LLM providers (OpenAI, Anthropic, Ollama, Groq).
217
+ * Creates/loads conversations, saves messages to DB, streams tokens via SSE.
218
+ * No external AI Service required.
219
+ */
220
+
221
+ import { NextRequest, NextResponse } from 'next/server';
222
+ import { resolveAuth } from '@/lib/auth/resolve-auth';
223
+ import { prisma } from '@/lib/prisma';
224
+ import { decrypt } from '@/lib/encryption';
225
+
226
+ export const dynamic = 'force-dynamic';
227
+
228
+ /** Default base URLs for known provider types */
229
+ function getDefaultBaseUrl(providerType: string): string {
230
+ const defaults: Record<string, string> = {
231
+ openai: 'https://api.openai.com/v1',
232
+ anthropic: 'https://api.anthropic.com/v1',
233
+ ollama: 'http://localhost:11434',
234
+ groq: 'https://api.groq.com/openai/v1',
235
+ google: 'https://generativelanguage.googleapis.com/v1beta',
236
+ };
237
+ return defaults[providerType] || 'https://api.openai.com/v1';
238
+ }
239
+
240
+ /** Content extractor based on provider type */
241
+ function getContentExtractor(providerType: string) {
242
+ switch (providerType) {
243
+ case 'anthropic':
244
+ return (parsed: any): string => {
245
+ if (parsed.type === 'content_block_delta') {
246
+ return parsed.delta?.text || '';
247
+ }
248
+ return '';
249
+ };
250
+ case 'ollama':
251
+ return (parsed: any): string => {
252
+ return parsed.message?.content || '';
253
+ };
254
+ case 'openai':
255
+ case 'groq':
256
+ case 'custom':
257
+ default:
258
+ return (parsed: any): string => {
259
+ return parsed.choices?.[0]?.delta?.content || '';
260
+ };
261
+ }
262
+ }
263
+
264
+ /** Token estimation as fallback when provider doesn't return exact counts */
265
+ function estimateTokens(text: string, providerType: string): number {
266
+ const ratios: Record<string, number> = {
267
+ openai: 4.3,
268
+ groq: 4.3,
269
+ anthropic: 4.1,
270
+ ollama: 4.0,
271
+ google: 4.0,
272
+ };
273
+ const ratio = ratios[providerType] || 4.0;
274
+ return Math.ceil(text.length / ratio);
275
+ }
276
+
277
+ export async function POST(request: NextRequest) {
278
+ try {
279
+ let auth;
280
+ try {
281
+ auth = await resolveAuth(request);
282
+ } catch (error: any) {
283
+ return NextResponse.json({ error: 'Unauthorized' }, { status: error.status || 401 });
284
+ }
285
+
286
+ // Scope check for API-Key auth
287
+ if (auth.authMethod === 'api-key' && auth.scopes) {
288
+ const hasChat = auth.scopes.includes('chat') || auth.scopes.includes('*') || auth.scopes.length === 0;
289
+ if (!hasChat) {
290
+ return NextResponse.json({ error: 'Insufficient scope. Required: chat' }, { status: 403 });
291
+ }
292
+ }
293
+
294
+ const payload = await request.json();
295
+ const { messages, model, providerId, conversationId, promptId } = payload;
296
+
297
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
298
+ return NextResponse.json({ error: 'Messages array is required' }, { status: 400 });
299
+ }
300
+
301
+ // --- 1. Load or create conversation ---
302
+ let conversation;
303
+ if (conversationId) {
304
+ conversation = await (prisma as any).conversation.findUnique({
305
+ where: { id: conversationId, userId: auth.userId },
306
+ });
307
+ }
308
+ if (!conversation) {
309
+ const firstUserMessage = messages.find((m: any) => m.role === 'user');
310
+ const title = firstUserMessage?.content?.slice(0, 50) || 'New Chat';
311
+ conversation = await (prisma as any).conversation.create({
312
+ data: {
313
+ userId: auth.userId,
314
+ title,
315
+ model: model || undefined,
316
+ providerId: providerId || undefined,
317
+ },
318
+ });
319
+ }
320
+
321
+ // --- 2. Save user message BEFORE streaming ---
322
+ const lastUserMessage = [...messages].reverse().find((m: any) => m.role === 'user');
323
+ if (lastUserMessage) {
324
+ await (prisma as any).message.create({
325
+ data: {
326
+ conversationId: conversation.id,
327
+ role: 'user',
328
+ content: lastUserMessage.content,
329
+ },
330
+ });
331
+ }
332
+
333
+ // --- 2.5. Load system prompt from prompt template ---
334
+ let systemPrompt: string | undefined;
335
+ try {
336
+ if (promptId) {
337
+ const tmpl = await (prisma as any).promptTemplate.findFirst({
338
+ where: { id: promptId, isActive: true },
339
+ });
340
+ if (tmpl) systemPrompt = tmpl.content;
341
+ } else {
342
+ const defaultTmpl = await (prisma as any).promptTemplate.findFirst({
343
+ where: { category: 'system', isDefault: true, isActive: true },
344
+ });
345
+ if (defaultTmpl) systemPrompt = defaultTmpl.content;
346
+ }
347
+ } catch { /* promptTemplate table may not exist in this app — continue without */ }
348
+
349
+ // Prepend system message (overrides any system message sent by client)
350
+ const messagesWithSystem = systemPrompt
351
+ ? [{ role: 'system', content: systemPrompt }, ...messages.filter((m: any) => m.role !== 'system')]
352
+ : messages;
353
+
354
+ // --- 3. Load provider and decrypt API key ---
355
+ let provider;
356
+ if (providerId) {
357
+ provider = await (prisma as any).provider.findUnique({ where: { id: providerId } });
358
+ }
359
+ // If a model was specified but no provider, look up which provider owns this model
360
+ if (!provider && model) {
361
+ const modelRecord = await (prisma as any).model.findFirst({
362
+ where: { modelId: model, provider: { status: 'active' } },
363
+ include: { provider: true },
364
+ });
365
+ if (modelRecord?.provider) {
366
+ provider = modelRecord.provider;
367
+ }
368
+ }
369
+ // Fallback: first active provider
370
+ if (!provider) {
371
+ provider = await (prisma as any).provider.findFirst({
372
+ where: { status: 'active' },
373
+ orderBy: { createdAt: 'asc' },
374
+ });
375
+ }
376
+ if (!provider) {
377
+ return NextResponse.json({ error: 'No active provider found. Please configure a provider first.' }, { status: 400 });
378
+ }
379
+
380
+ let apiKey: string | null = null;
381
+ try {
382
+ apiKey = provider.apiKey ? decrypt(provider.apiKey) : null;
383
+ } catch (err) {
384
+ console.error('Failed to decrypt provider API key:', err);
385
+ return NextResponse.json({ error: 'Provider API key decryption failed' }, { status: 500 });
386
+ }
387
+ const baseUrl = provider.baseUrl || getDefaultBaseUrl(provider.type);
388
+ const resolvedKey = apiKey || '';
389
+
390
+ // --- 4. Resolve model: explicit request > provider default > error ---
391
+ // Parse config safely (PostgreSQL returns JSON object, SQLite returns JSON string)
392
+ const rawConfig = provider.config;
393
+ const providerConfig: { defaultModel?: string } | null =
394
+ typeof rawConfig === 'string' ? (() => { try { return JSON.parse(rawConfig); } catch { return null; } })() : (rawConfig as any) || null;
395
+ const modelId = model || providerConfig?.defaultModel;
396
+
397
+ if (!modelId) {
398
+ return NextResponse.json(
399
+ { error: 'No model specified and no default model configured for this provider. Set a default model in provider settings.' },
400
+ { status: 400 }
401
+ );
402
+ }
403
+
404
+ let llmResponse: Response;
405
+
406
+ switch (provider.type) {
407
+ case 'anthropic':
408
+ llmResponse = await fetch(\`\${baseUrl}/messages\`, {
409
+ method: 'POST',
410
+ headers: {
411
+ 'x-api-key': resolvedKey,
412
+ 'anthropic-version': '2023-06-01',
413
+ 'Content-Type': 'application/json',
414
+ },
415
+ body: JSON.stringify({
416
+ model: modelId,
417
+ max_tokens: 4096,
418
+ messages: messagesWithSystem.filter((m: any) => m.role !== 'system'),
419
+ system: messagesWithSystem.find((m: any) => m.role === 'system')?.content || undefined,
420
+ stream: true,
421
+ }),
422
+ });
423
+ break;
424
+
425
+ case 'ollama':
426
+ llmResponse = await fetch(\`\${baseUrl}/api/chat\`, {
427
+ method: 'POST',
428
+ headers: { 'Content-Type': 'application/json' },
429
+ body: JSON.stringify({
430
+ model: modelId,
431
+ messages: messagesWithSystem,
432
+ stream: true,
433
+ }),
434
+ });
435
+ break;
436
+
437
+ case 'openai':
438
+ case 'groq':
439
+ case 'custom':
440
+ default:
441
+ llmResponse = await fetch(\`\${baseUrl}/chat/completions\`, {
442
+ method: 'POST',
443
+ headers: {
444
+ 'Authorization': \`Bearer \${resolvedKey}\`,
445
+ 'Content-Type': 'application/json',
446
+ },
447
+ body: JSON.stringify({
448
+ model: modelId,
449
+ messages: messagesWithSystem,
450
+ stream: true,
451
+ }),
452
+ });
453
+ break;
454
+ }
455
+
456
+ if (!llmResponse.ok) {
457
+ const errorText = await llmResponse.text().catch(() => 'LLM provider error');
458
+ console.error(\`LLM provider error (\${provider.type}):\`, errorText);
459
+ return NextResponse.json(
460
+ { error: \`Provider error: \${errorText.slice(0, 200)}\` },
461
+ { status: llmResponse.status }
462
+ );
463
+ }
464
+
465
+ const llmReader = llmResponse.body?.getReader();
466
+ if (!llmReader) {
467
+ return NextResponse.json({ error: 'No response stream from provider' }, { status: 502 });
468
+ }
469
+
470
+ // --- 5. Stream parsing + forwarding ---
471
+ const extractContent = getContentExtractor(provider.type);
472
+
473
+ return new Response(
474
+ new ReadableStream({
475
+ async start(controller) {
476
+ const encoder = new TextEncoder();
477
+ const decoder = new TextDecoder();
478
+ let fullResponse = '';
479
+ let buffer = '';
480
+ let currentEventType: string | null = null;
481
+ let tokensUsed: number | null = null;
482
+
483
+ const sendToken = (content: string) => {
484
+ fullResponse += content;
485
+ controller.enqueue(
486
+ encoder.encode(\`data: \${JSON.stringify({
487
+ type: 'token',
488
+ content,
489
+ conversationId: conversation.id,
490
+ })}\\n\\n\`)
491
+ );
492
+ };
493
+
494
+ try {
495
+ while (true) {
496
+ const { done, value } = await llmReader.read();
497
+ if (done) break;
498
+
499
+ buffer += decoder.decode(value, { stream: true });
500
+ const lines = buffer.split('\\n');
501
+ buffer = lines.pop() || '';
502
+
503
+ for (const line of lines) {
504
+ if (line.trim() === '') continue;
505
+
506
+ // SSE "event:" lines (Anthropic uses these)
507
+ if (line.startsWith('event: ')) {
508
+ currentEventType = line.slice(7).trim();
509
+ continue;
510
+ }
511
+
512
+ // SSE "data:" lines (OpenAI, Groq, Anthropic)
513
+ if (line.startsWith('data: ')) {
514
+ const data = line.slice(6).trim();
515
+ if (data === '[DONE]') continue;
516
+
517
+ try {
518
+ const parsed = JSON.parse(data);
519
+
520
+ if (provider.type === 'anthropic') {
521
+ if (currentEventType === 'content_block_delta') {
522
+ const text = parsed.delta?.text || '';
523
+ if (text) sendToken(text);
524
+ } else if (currentEventType === 'message_delta') {
525
+ if (parsed.usage?.output_tokens) {
526
+ tokensUsed = parsed.usage.output_tokens;
527
+ }
528
+ } else if (currentEventType === 'message_stop') {
529
+ // Stream ended
530
+ }
531
+ } else {
532
+ const content = extractContent(parsed);
533
+ if (content) sendToken(content);
534
+ }
535
+ } catch { /* skip unparseable lines */ }
536
+
537
+ currentEventType = null;
538
+ }
539
+ // Ollama: raw JSON per line (no "data:" prefix)
540
+ else if (line.trim().startsWith('{')) {
541
+ try {
542
+ const parsed = JSON.parse(line);
543
+ if (parsed.done) continue;
544
+ const content = extractContent(parsed);
545
+ if (content) sendToken(content);
546
+ } catch { /* skip */ }
547
+ }
548
+ }
549
+ }
550
+
551
+ // --- 6. Save assistant message after stream ---
552
+ if (tokensUsed === null) {
553
+ tokensUsed = estimateTokens(fullResponse, provider.type);
554
+ }
555
+ await (prisma as any).message.create({
556
+ data: {
557
+ conversationId: conversation.id,
558
+ role: 'assistant',
559
+ content: fullResponse,
560
+ model: modelId,
561
+ tokens: tokensUsed,
562
+ },
563
+ });
564
+
565
+ // --- 6b. Track API usage for billing/credits ---
566
+ const inputTokens = estimateTokens(
567
+ messagesWithSystem.map((m: any) => m.content).join(' '),
568
+ provider.type
569
+ );
570
+ try {
571
+ await (prisma as any).apiUsage.create({
572
+ data: {
573
+ userId: auth.userId,
574
+ providerId: provider.id,
575
+ model: modelId,
576
+ endpoint: '/api/v1/chat/stream',
577
+ promptTokens: inputTokens,
578
+ completionTokens: tokensUsed,
579
+ totalTokens: inputTokens + tokensUsed,
580
+ tokensUsed: inputTokens + tokensUsed,
581
+ creditsUsed: Math.ceil((inputTokens + tokensUsed) / 1000),
582
+ cost: 0,
583
+ success: true,
584
+ responseTime: 0,
585
+ },
586
+ });
587
+ } catch (usageError) {
588
+ console.error('Failed to track API usage:', usageError);
589
+ }
590
+
591
+ // Update conversation updatedAt
592
+ await (prisma as any).conversation.update({
593
+ where: { id: conversation.id },
594
+ data: { updatedAt: new Date() },
595
+ });
596
+
597
+ // Send done event
598
+ controller.enqueue(
599
+ encoder.encode(\`data: \${JSON.stringify({
600
+ type: 'done',
601
+ conversationId: conversation.id,
602
+ })}\\n\\n\`)
603
+ );
604
+ } catch (error: any) {
605
+ controller.enqueue(
606
+ encoder.encode(\`data: \${JSON.stringify({
607
+ type: 'error',
608
+ message: error.message || 'Stream error',
609
+ })}\\n\\n\`)
610
+ );
611
+ } finally {
612
+ controller.close();
613
+ }
614
+ },
615
+ }),
616
+ {
617
+ headers: {
618
+ 'Content-Type': 'text/event-stream',
619
+ 'Cache-Control': 'no-cache',
620
+ 'Connection': 'keep-alive',
621
+ 'X-Accel-Buffering': 'no',
622
+ },
623
+ }
624
+ );
625
+ } catch (error: any) {
626
+ console.error('Stream route error:', error);
627
+ return NextResponse.json(
628
+ { error: error.message || 'Internal server error' },
629
+ { status: 500 }
630
+ );
631
+ }
632
+ }
633
+ `;
634
+ }
635
+ /**
636
+ * Generates the Conversations API route for listing and creating conversations
637
+ * Handles conversation filtering, message count, and metadata
638
+ * @returns TypeScript content for app/api/conversations/route.ts
639
+ */
640
+ function generateConversationsRoute() {
641
+ return `// @chimerai component=ConversationsRoute version=1.0
642
+ import { NextRequest, NextResponse } from 'next/server';
643
+ import { requireAuth, createErrorResponse } from '@/lib/api-protection';
644
+ import { prisma } from '@/lib/prisma';
645
+
646
+ export const dynamic = 'force-dynamic';
647
+
648
+ export async function GET(request: NextRequest) {
649
+ const authResult = await requireAuth(request);
650
+
651
+ if (!authResult.authorized || !authResult.user) {
652
+ return createErrorResponse(authResult);
653
+ }
654
+
655
+ try {
656
+ const { searchParams } = new URL(request.url);
657
+ const archived = searchParams.get('archived') === 'true';
658
+
659
+ const conversations = await (prisma as any).conversation.findMany({
660
+ where: {
661
+ userId: authResult.user.id,
662
+ archived,
663
+ },
664
+ include: {
665
+ messages: {
666
+ take: 1,
667
+ orderBy: { createdAt: 'desc' },
668
+ },
669
+ _count: {
670
+ select: { messages: true },
671
+ },
672
+ },
673
+ orderBy: {
674
+ updatedAt: 'desc',
675
+ },
676
+ });
677
+
678
+ return NextResponse.json(conversations);
679
+ } catch (error) {
680
+ console.error('Failed to fetch conversations:', error);
681
+ return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 });
682
+ }
683
+ }
684
+
685
+ export async function POST(request: NextRequest) {
686
+ const authResult = await requireAuth(request);
687
+
688
+ if (!authResult.authorized || !authResult.user) {
689
+ return createErrorResponse(authResult);
690
+ }
691
+
692
+ try {
693
+ const body = await request.json();
694
+ const { title, model, metadata } = body;
695
+
696
+ const conversation = await (prisma as any).conversation.create({
697
+ data: {
698
+ userId: authResult.user.id,
699
+ title: title || 'New Chat',
700
+ model,
701
+ metadata,
702
+ },
703
+ });
704
+
705
+ return NextResponse.json(conversation);
706
+ } catch (error) {
707
+ console.error('Failed to create conversation:', error);
708
+ return NextResponse.json({ error: 'Failed to create conversation' }, { status: 500 });
709
+ }
710
+ }
711
+ `;
712
+ }
713
+ // ============================================================================
714
+ // MODULAR CHAT COMPONENTS — Generated by CHAT_TEMPLATE_FIX_SPEC
715
+ // ============================================================================
716
+ /**
717
+ * Generates the useChat custom hook — the core chat logic
718
+ * Handles streaming, conversations, models, message management
719
+ * Based on apps/frontend/components/chat/use-chat.ts
720
+ * @returns TypeScript content for components/chat/use-chat.ts
721
+ */
722
+ function generateUseChatHook() {
723
+ return `// @chimerai component=UseChatHook version=2.0
724
+ 'use client';
725
+
726
+ import { useState, useRef, useCallback, useEffect } from 'react';
727
+
728
+ export interface ChatMessageData {
729
+ id?: string;
730
+ role: 'user' | 'assistant' | 'system';
731
+ content: string;
732
+ model?: string | null;
733
+ tokens?: number | null;
734
+ createdAt?: string;
735
+ streaming?: boolean;
736
+ }
737
+
738
+ export interface ConversationItem {
739
+ id: string;
740
+ title: string;
741
+ updatedAt: string;
742
+ _count: {
743
+ messages: number;
744
+ };
745
+ }
746
+
747
+ export interface ModelOption {
748
+ id: string;
749
+ modelId: string;
750
+ name: string;
751
+ providerId: string;
752
+ providerType: string;
753
+ contextWindow: number;
754
+ inputCost: number;
755
+ outputCost: number;
756
+ capabilities: string[];
757
+ provider: {
758
+ id: string;
759
+ name: string;
760
+ type: string;
761
+ };
762
+ }
763
+
764
+ export interface MessageActions {
765
+ onRegenerate?: (index: number) => void;
766
+ onEdit?: (index: number, newContent: string) => void;
767
+ onDelete?: (index: number) => void;
768
+ }
769
+
770
+ export interface UseChatOptions {
771
+ onConversationCreated?: (id: string) => void;
772
+ onError?: (error: string) => void;
773
+ }
774
+
775
+ export interface UseChatReturn {
776
+ // State
777
+ messages: ChatMessageData[];
778
+ isStreaming: boolean;
779
+ conversations: ConversationItem[];
780
+ models: ModelOption[];
781
+ selectedConversationId: string | null;
782
+ selectedModelId: string;
783
+ isLoadingConversation: boolean;
784
+ isLoadingModels: boolean;
785
+ systemPrompt: string;
786
+ creditBalance: number | null;
787
+
788
+ // Actions
789
+ sendMessage: (content: string) => Promise<void>;
790
+ stopStreaming: () => void;
791
+ selectConversation: (id: string) => Promise<void>;
792
+ startNewChat: () => void;
793
+ deleteConversation: (id: string) => Promise<void>;
794
+ renameConversation: (id: string, title: string) => Promise<void>;
795
+ setSelectedModelId: (id: string) => void;
796
+ setSystemPrompt: (prompt: string) => void;
797
+ regenerateMessage: (index: number) => Promise<void>;
798
+ editMessage: (index: number, newContent: string) => Promise<void>;
799
+ deleteMessage: (index: number) => void;
800
+ refreshConversations: () => Promise<void>;
801
+ }
802
+
803
+ export function useChat(options: UseChatOptions = {}): UseChatReturn {
804
+ const [messages, setMessages] = useState<ChatMessageData[]>([]);
805
+ const [isStreaming, setIsStreaming] = useState(false);
806
+ const [conversations, setConversations] = useState<ConversationItem[]>([]);
807
+ const [models, setModels] = useState<ModelOption[]>([]);
808
+ const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
809
+ const [selectedModelId, setSelectedModelId] = useState('');
810
+ const [isLoadingConversation, setIsLoadingConversation] = useState(false);
811
+ const [isLoadingModels, setIsLoadingModels] = useState(false);
812
+ const [systemPrompt, setSystemPrompt] = useState('');
813
+ const [creditBalance, setCreditBalance] = useState<number | null>(null);
814
+
815
+ const abortControllerRef = useRef<AbortController | null>(null);
816
+ const conversationIdRef = useRef<string | null>(null);
817
+
818
+ // Keep ref in sync with state
819
+ useEffect(() => {
820
+ conversationIdRef.current = selectedConversationId;
821
+ }, [selectedConversationId]);
822
+
823
+ // --- Fetch Conversations ---
824
+ const refreshConversations = useCallback(async () => {
825
+ try {
826
+ const res = await fetch('/api/conversations');
827
+ if (res.ok) {
828
+ const data = await res.json();
829
+ setConversations(data);
830
+ }
831
+ } catch (err) {
832
+ console.error('Failed to fetch conversations:', err);
833
+ }
834
+ }, []);
835
+
836
+ // --- Fetch Models ---
837
+ const fetchModels = useCallback(async () => {
838
+ setIsLoadingModels(true);
839
+ try {
840
+ const res = await fetch('/api/models');
841
+ if (res.ok) {
842
+ const data = await res.json();
843
+ const modelList = Array.isArray(data) ? data : data.models || [];
844
+ setModels(modelList);
845
+ if (modelList.length > 0 && !selectedModelId) {
846
+ setSelectedModelId(modelList[0].id);
847
+ }
848
+ }
849
+ } catch (err) {
850
+ console.error('Failed to fetch models:', err);
851
+ } finally {
852
+ setIsLoadingModels(false);
853
+ }
854
+ }, [selectedModelId]);
855
+
856
+ // --- Fetch Credit Balance ---
857
+ const fetchCreditBalance = useCallback(async () => {
858
+ try {
859
+ const res = await fetch('/api/billing/credits');
860
+ if (res.ok) {
861
+ const data = await res.json();
862
+ setCreditBalance(data.balance ?? data.credits ?? null);
863
+ }
864
+ } catch {
865
+ // Credits endpoint may not exist — that's ok
866
+ }
867
+ }, []);
868
+
869
+ // Load on mount
870
+ useEffect(() => {
871
+ refreshConversations();
872
+ fetchModels();
873
+ fetchCreditBalance();
874
+ // eslint-disable-next-line react-hooks/exhaustive-deps
875
+ }, []);
876
+
877
+ // --- Select Conversation ---
878
+ const selectConversation = useCallback(async (id: string) => {
879
+ conversationIdRef.current = id; // Set ref immediately to prevent race condition
880
+ setSelectedConversationId(id);
881
+ setIsLoadingConversation(true);
882
+ try {
883
+ const res = await fetch(\`/api/conversations/\${id}\`);
884
+ if (res.ok) {
885
+ const data = await res.json();
886
+ setMessages(
887
+ (data.messages || []).map((m: any) => ({
888
+ id: m.id,
889
+ role: m.role,
890
+ content: m.content,
891
+ model: m.model,
892
+ tokens: m.tokens,
893
+ createdAt: m.createdAt,
894
+ }))
895
+ );
896
+ }
897
+ } catch (err) {
898
+ console.error('Failed to load conversation:', err);
899
+ } finally {
900
+ setIsLoadingConversation(false);
901
+ }
902
+ }, []);
903
+
904
+ // --- New Chat ---
905
+ const startNewChat = useCallback(() => {
906
+ setSelectedConversationId(null);
907
+ setMessages([]);
908
+ conversationIdRef.current = null;
909
+ }, []);
910
+
911
+ // --- Send Message (Streaming) ---
912
+ const sendMessage = useCallback(
913
+ async (content: string) => {
914
+ if (isStreaming) return;
915
+
916
+ const userMessage: ChatMessageData = { role: 'user', content };
917
+ setMessages((prev) => [...prev, userMessage]);
918
+ setIsStreaming(true);
919
+
920
+ // Add empty assistant message placeholder
921
+ const assistantMessage: ChatMessageData = {
922
+ role: 'assistant',
923
+ content: '',
924
+ streaming: true,
925
+ };
926
+ setMessages((prev) => [...prev, assistantMessage]);
927
+
928
+ try {
929
+ abortControllerRef.current = new AbortController();
930
+
931
+ // Determine the model to use
932
+ const selectedModel = models.find((m) => m.id === selectedModelId);
933
+ const modelId = selectedModel?.modelId || 'gpt-4o-mini';
934
+ const providerId = selectedModel?.providerId || undefined;
935
+
936
+ // Collect all messages (excluding system — added separately)
937
+ const messagesToSend = [...messages, userMessage].filter((m) => m.role !== 'system');
938
+
939
+ const body: any = {
940
+ model: modelId,
941
+ messages: [
942
+ ...(systemPrompt.trim()
943
+ ? [{ role: 'system' as const, content: systemPrompt.trim() }]
944
+ : []),
945
+ ...messagesToSend.map((m) => ({
946
+ role: m.role,
947
+ content: m.content,
948
+ })),
949
+ ],
950
+ conversationId: conversationIdRef.current || undefined,
951
+ providerId,
952
+ };
953
+
954
+ const res = await fetch('/api/v1/chat/stream', {
955
+ method: 'POST',
956
+ headers: { 'Content-Type': 'application/json' },
957
+ body: JSON.stringify(body),
958
+ signal: abortControllerRef.current.signal,
959
+ });
960
+
961
+ if (!res.ok) {
962
+ const err = await res.json().catch(() => ({ error: { message: 'Request failed' } }));
963
+ throw new Error(err.error?.message || \`HTTP \${res.status}\`);
964
+ }
965
+
966
+ const reader = res.body?.getReader();
967
+ if (!reader) throw new Error('No response body');
968
+
969
+ const decoder = new TextDecoder();
970
+ let buffer = '';
971
+
972
+ while (true) {
973
+ const { done, value } = await reader.read();
974
+ if (done) break;
975
+
976
+ buffer += decoder.decode(value, { stream: true });
977
+ const lines = buffer.split('\\n');
978
+ buffer = lines.pop() || '';
979
+
980
+ for (const line of lines) {
981
+ if (!line.startsWith('data: ')) continue;
982
+ const data = line.slice(6).trim();
983
+ if (!data || data === '[DONE]') continue;
984
+
985
+ try {
986
+ const parsed = JSON.parse(data);
987
+
988
+ if (parsed.type === 'token') {
989
+ // Update conversation ID from first token
990
+ if (parsed.conversationId && !conversationIdRef.current) {
991
+ conversationIdRef.current = parsed.conversationId;
992
+ setSelectedConversationId(parsed.conversationId);
993
+ options.onConversationCreated?.(parsed.conversationId);
994
+ }
995
+
996
+ // Append token to last assistant message
997
+ setMessages((prev) => {
998
+ const lastIdx = prev.length - 1;
999
+ if (lastIdx >= 0 && prev[lastIdx].role === 'assistant') {
1000
+ return [
1001
+ ...prev.slice(0, lastIdx),
1002
+ { ...prev[lastIdx], content: prev[lastIdx].content + parsed.content },
1003
+ ];
1004
+ }
1005
+ return prev;
1006
+ });
1007
+ } else if (parsed.type === 'done') {
1008
+ setMessages((prev) => {
1009
+ const lastIdx = prev.length - 1;
1010
+ if (lastIdx >= 0 && prev[lastIdx].role === 'assistant') {
1011
+ return [
1012
+ ...prev.slice(0, lastIdx),
1013
+ { ...prev[lastIdx], streaming: false, model: modelId },
1014
+ ];
1015
+ }
1016
+ return prev;
1017
+ });
1018
+ } else if (parsed.type === 'error') {
1019
+ throw new Error(parsed.message);
1020
+ }
1021
+ } catch (parseErr: any) {
1022
+ if (parseErr.message && !parseErr.message.includes('JSON')) {
1023
+ throw parseErr;
1024
+ }
1025
+ console.error('Failed to parse SSE data:', parseErr);
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ // Refresh conversations to pick up new/updated titles
1031
+ refreshConversations();
1032
+ fetchCreditBalance();
1033
+ } catch (error: any) {
1034
+ if (error.name !== 'AbortError') {
1035
+ setMessages((prev) => {
1036
+ const lastIdx = prev.length - 1;
1037
+ if (lastIdx >= 0 && prev[lastIdx].role === 'assistant') {
1038
+ return [
1039
+ ...prev.slice(0, lastIdx),
1040
+ { ...prev[lastIdx], content: \`Error: \${error.message}\`, streaming: false },
1041
+ ];
1042
+ }
1043
+ return prev;
1044
+ });
1045
+ }
1046
+ } finally {
1047
+ setIsStreaming(false);
1048
+ abortControllerRef.current = null;
1049
+ }
1050
+ },
1051
+ [isStreaming, messages, models, selectedModelId, systemPrompt, options, refreshConversations, fetchCreditBalance]
1052
+ );
1053
+
1054
+ // --- Stop Streaming ---
1055
+ const stopStreaming = useCallback(() => {
1056
+ if (abortControllerRef.current) {
1057
+ abortControllerRef.current.abort();
1058
+ setIsStreaming(false);
1059
+ }
1060
+ }, []);
1061
+
1062
+ // --- Delete Conversation ---
1063
+ const deleteConversation = useCallback(
1064
+ async (id: string) => {
1065
+ try {
1066
+ const res = await fetch(\`/api/conversations/\${id}\`, { method: 'DELETE' });
1067
+ if (res.ok) {
1068
+ setConversations((prev) => prev.filter((c) => c.id !== id));
1069
+ if (selectedConversationId === id) {
1070
+ startNewChat();
1071
+ }
1072
+ }
1073
+ } catch (err) {
1074
+ console.error('Failed to delete conversation:', err);
1075
+ }
1076
+ },
1077
+ [selectedConversationId, startNewChat]
1078
+ );
1079
+
1080
+ // --- Rename Conversation ---
1081
+ const renameConversation = useCallback(async (id: string, title: string) => {
1082
+ try {
1083
+ const res = await fetch(\`/api/conversations/\${id}\`, {
1084
+ method: 'PATCH',
1085
+ headers: { 'Content-Type': 'application/json' },
1086
+ body: JSON.stringify({ title }),
1087
+ });
1088
+ if (res.ok) {
1089
+ setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
1090
+ }
1091
+ } catch (err) {
1092
+ console.error('Failed to rename conversation:', err);
1093
+ }
1094
+ }, []);
1095
+
1096
+ // --- Regenerate Message ---
1097
+ const regenerateMessage = useCallback(
1098
+ async (index: number) => {
1099
+ if (isStreaming) return;
1100
+ let userMsgIndex = index - 1;
1101
+ while (userMsgIndex >= 0 && messages[userMsgIndex].role !== 'user') {
1102
+ userMsgIndex--;
1103
+ }
1104
+ if (userMsgIndex < 0) return;
1105
+
1106
+ const userContent = messages[userMsgIndex].content;
1107
+ const truncated = messages.slice(0, userMsgIndex);
1108
+ setMessages(truncated);
1109
+ setTimeout(() => sendMessage(userContent), 50);
1110
+ },
1111
+ [isStreaming, messages, sendMessage]
1112
+ );
1113
+
1114
+ // --- Edit Message ---
1115
+ const editMessage = useCallback(
1116
+ async (index: number, newContent: string) => {
1117
+ if (isStreaming) return;
1118
+ const truncated = messages.slice(0, index);
1119
+ setMessages(truncated);
1120
+ setTimeout(() => sendMessage(newContent), 50);
1121
+ },
1122
+ [isStreaming, messages, sendMessage]
1123
+ );
1124
+
1125
+ // --- Delete Message ---
1126
+ const deleteMessage = useCallback((index: number) => {
1127
+ setMessages((prev) => prev.filter((_, i) => i !== index));
1128
+ }, []);
1129
+
1130
+ return {
1131
+ messages,
1132
+ isStreaming,
1133
+ conversations,
1134
+ models,
1135
+ selectedConversationId,
1136
+ selectedModelId,
1137
+ isLoadingConversation,
1138
+ isLoadingModels,
1139
+ systemPrompt,
1140
+ creditBalance,
1141
+ sendMessage,
1142
+ stopStreaming,
1143
+ selectConversation,
1144
+ startNewChat,
1145
+ deleteConversation,
1146
+ renameConversation,
1147
+ setSelectedModelId,
1148
+ setSystemPrompt,
1149
+ regenerateMessage,
1150
+ editMessage,
1151
+ deleteMessage,
1152
+ refreshConversations,
1153
+ };
1154
+ }
1155
+ `;
1156
+ }
1157
+ /**
1158
+ * Generates the ChatMessage component with Markdown rendering, copy, edit, delete
1159
+ * Based on apps/frontend/components/chat/chat-message.tsx
1160
+ * SIMPLIFIED: No shadcn dependency, uses plain Tailwind + react-markdown
1161
+ * @returns TypeScript/JSX content for components/chat/chat-message.tsx
1162
+ */
1163
+ function generateChatMessage() {
1164
+ return `// @chimerai component=ChatMessage version=2.0
1165
+ 'use client';
1166
+
1167
+ import { memo, useState, useRef, useEffect } from 'react';
1168
+ import ReactMarkdown from 'react-markdown';
1169
+ import remarkGfm from 'remark-gfm';
1170
+
1171
+ export interface ChatMessageData {
1172
+ id?: string;
1173
+ role: 'user' | 'assistant' | 'system';
1174
+ content: string;
1175
+ model?: string | null;
1176
+ tokens?: number | null;
1177
+ createdAt?: string;
1178
+ streaming?: boolean;
1179
+ }
1180
+
1181
+ export interface MessageActions {
1182
+ onRegenerate?: (index: number) => void;
1183
+ onEdit?: (index: number, newContent: string) => void;
1184
+ onDelete?: (index: number) => void;
1185
+ }
1186
+
1187
+ interface ChatMessageProps {
1188
+ message: ChatMessageData;
1189
+ index?: number;
1190
+ actions?: MessageActions;
1191
+ }
1192
+
1193
+ function CopyButton({ text }: { text: string }) {
1194
+ const [copied, setCopied] = useState(false);
1195
+
1196
+ const handleCopy = async () => {
1197
+ await navigator.clipboard.writeText(text);
1198
+ setCopied(true);
1199
+ setTimeout(() => setCopied(false), 2000);
1200
+ };
1201
+
1202
+ return (
1203
+ <button
1204
+ onClick={handleCopy}
1205
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
1206
+ title="Copy"
1207
+ >
1208
+ {copied ? (
1209
+ <svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1210
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
1211
+ </svg>
1212
+ ) : (
1213
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1214
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
1215
+ </svg>
1216
+ )}
1217
+ </button>
1218
+ );
1219
+ }
1220
+
1221
+ function CodeBlock({ className, children, ...props }: React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }) {
1222
+ const match = /language-(\\w+)/.exec(className || '');
1223
+ const language = match ? match[1] : '';
1224
+ const code = String(children).replace(/\\n$/, '');
1225
+
1226
+ // Inline code
1227
+ if (!className && !String(children).includes('\\n')) {
1228
+ return (
1229
+ <code className="rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 text-sm font-mono" {...props}>
1230
+ {children}
1231
+ </code>
1232
+ );
1233
+ }
1234
+
1235
+ return (
1236
+ <div className="group/code relative my-3 rounded-lg border bg-gray-50 dark:bg-gray-900 overflow-hidden">
1237
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-gray-100 dark:bg-gray-800">
1238
+ <span className="text-xs text-gray-500 font-mono">{language || 'code'}</span>
1239
+ <CopyButton text={code} />
1240
+ </div>
1241
+ <pre className="overflow-x-auto p-4 text-sm">
1242
+ <code className={className} {...props}>{children}</code>
1243
+ </pre>
1244
+ </div>
1245
+ );
1246
+ }
1247
+
1248
+ export const ChatMessage = memo(function ChatMessage({ message, index, actions }: ChatMessageProps) {
1249
+ const isUser = message.role === 'user';
1250
+ const isSystem = message.role === 'system';
1251
+ const [isEditing, setIsEditing] = useState(false);
1252
+ const [editContent, setEditContent] = useState(message.content);
1253
+ const editRef = useRef<HTMLTextAreaElement>(null);
1254
+
1255
+ useEffect(() => {
1256
+ if (isEditing && editRef.current) {
1257
+ editRef.current.focus();
1258
+ editRef.current.setSelectionRange(editContent.length, editContent.length);
1259
+ }
1260
+ }, [isEditing, editContent.length]);
1261
+
1262
+ const handleEdit = () => {
1263
+ setEditContent(message.content);
1264
+ setIsEditing(true);
1265
+ };
1266
+
1267
+ const handleEditSubmit = () => {
1268
+ if (editContent.trim() && index !== undefined && actions?.onEdit) {
1269
+ actions.onEdit(index, editContent.trim());
1270
+ }
1271
+ setIsEditing(false);
1272
+ };
1273
+
1274
+ const handleEditCancel = () => {
1275
+ setIsEditing(false);
1276
+ setEditContent(message.content);
1277
+ };
1278
+
1279
+ const handleEditKeyDown = (e: React.KeyboardEvent) => {
1280
+ if (e.key === 'Enter' && !e.shiftKey) {
1281
+ e.preventDefault();
1282
+ handleEditSubmit();
1283
+ }
1284
+ if (e.key === 'Escape') {
1285
+ handleEditCancel();
1286
+ }
1287
+ };
1288
+
1289
+ const hasActions = actions && index !== undefined && !message.streaming;
1290
+
1291
+ if (isSystem) {
1292
+ return (
1293
+ <div className="flex justify-center py-2">
1294
+ <div className="rounded-full bg-gray-100 dark:bg-gray-800 px-4 py-1.5 text-xs text-gray-500">
1295
+ {message.content}
1296
+ </div>
1297
+ </div>
1298
+ );
1299
+ }
1300
+
1301
+ return (
1302
+ <div className={\`group flex gap-3 py-4 px-4 \${isUser ? 'flex-row-reverse' : 'flex-row'}\`}>
1303
+ {/* Avatar */}
1304
+ <div className={\`flex h-8 w-8 shrink-0 items-center justify-center rounded-full \${
1305
+ isUser ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
1306
+ }\`}>
1307
+ {isUser ? (
1308
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1309
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
1310
+ </svg>
1311
+ ) : (
1312
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1313
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
1314
+ </svg>
1315
+ )}
1316
+ </div>
1317
+
1318
+ {/* Message Content */}
1319
+ <div className={\`flex flex-col max-w-[80%] min-w-0 \${isUser ? 'items-end' : 'items-start'}\`}>
1320
+ <div className={\`rounded-2xl px-4 py-2.5 \${
1321
+ isUser ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border dark:border-gray-700'
1322
+ }\`}>
1323
+ {isUser ? (
1324
+ isEditing ? (
1325
+ <div className="space-y-2">
1326
+ <textarea
1327
+ ref={editRef}
1328
+ value={editContent}
1329
+ onChange={(e) => setEditContent(e.target.value)}
1330
+ onKeyDown={handleEditKeyDown}
1331
+ rows={3}
1332
+ className="w-full text-sm bg-white text-gray-900 rounded p-2 resize-none"
1333
+ />
1334
+ <div className="flex gap-1.5 justify-end">
1335
+ <button
1336
+ onClick={handleEditCancel}
1337
+ className="px-2 py-1 text-xs text-white/80 hover:text-white"
1338
+ >
1339
+ Cancel
1340
+ </button>
1341
+ <button
1342
+ onClick={handleEditSubmit}
1343
+ disabled={!editContent.trim()}
1344
+ className="px-2 py-1 text-xs bg-white text-indigo-600 rounded hover:bg-white/90 disabled:opacity-50"
1345
+ >
1346
+ Send
1347
+ </button>
1348
+ </div>
1349
+ </div>
1350
+ ) : (
1351
+ <p className="text-sm whitespace-pre-wrap break-words text-white">{message.content}</p>
1352
+ )
1353
+ ) : (
1354
+ <div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words [&>*:first-child]:mt-0">
1355
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: CodeBlock }}>
1356
+ {message.content}
1357
+ </ReactMarkdown>
1358
+ {message.streaming && (
1359
+ <span className="inline-block w-2 h-4 ml-0.5 bg-gray-500 animate-pulse rounded-sm" />
1360
+ )}
1361
+ </div>
1362
+ )}
1363
+ </div>
1364
+
1365
+ {/* Action buttons */}
1366
+ {hasActions && !isEditing && (
1367
+ <div className={\`flex items-center gap-1 mt-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity \${
1368
+ isUser ? 'flex-row-reverse' : 'flex-row'
1369
+ }\`}>
1370
+ {/* Meta info for assistant */}
1371
+ {message.role === 'assistant' && (
1372
+ <>
1373
+ {message.model && <span className="text-[10px] text-gray-400 mr-1">{message.model}</span>}
1374
+ {message.tokens && <span className="text-[10px] text-gray-400 mr-1">{message.tokens} tokens</span>}
1375
+ </>
1376
+ )}
1377
+
1378
+ {/* Copy */}
1379
+ <CopyButton text={message.content} />
1380
+
1381
+ {/* Regenerate (assistant only) */}
1382
+ {message.role === 'assistant' && actions.onRegenerate && (
1383
+ <button
1384
+ onClick={() => actions.onRegenerate!(index!)}
1385
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
1386
+ title="Regenerate"
1387
+ >
1388
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1389
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
1390
+ </svg>
1391
+ </button>
1392
+ )}
1393
+
1394
+ {/* Edit (user only) */}
1395
+ {message.role === 'user' && actions.onEdit && (
1396
+ <button
1397
+ onClick={handleEdit}
1398
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
1399
+ title="Edit"
1400
+ >
1401
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1402
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
1403
+ </svg>
1404
+ </button>
1405
+ )}
1406
+
1407
+ {/* Delete */}
1408
+ {actions.onDelete && (
1409
+ <button
1410
+ onClick={() => actions.onDelete!(index!)}
1411
+ className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
1412
+ title="Delete"
1413
+ >
1414
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1415
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
1416
+ </svg>
1417
+ </button>
1418
+ )}
1419
+ </div>
1420
+ )}
1421
+ </div>
1422
+ </div>
1423
+ );
1424
+ });
1425
+ `;
1426
+ }
1427
+ /**
1428
+ * Generates the ChatInput component with auto-expanding textarea
1429
+ * Based on apps/frontend/components/chat/chat-input.tsx
1430
+ * SIMPLIFIED: No shadcn dependency, uses plain Tailwind
1431
+ * @returns TypeScript/JSX content for components/chat/chat-input.tsx
1432
+ */
1433
+ function generateChatInput() {
1434
+ return `// @chimerai component=ChatInput version=2.0
1435
+ 'use client';
1436
+
1437
+ import { useState, useRef, useEffect, useCallback } from 'react';
1438
+
1439
+ /** Rough token estimation: ~4 chars per token */
1440
+ function estimateTokens(text: string): number {
1441
+ return Math.ceil(text.length / 4);
1442
+ }
1443
+
1444
+ interface ChatInputProps {
1445
+ onSend: (message: string) => void;
1446
+ onStop: () => void;
1447
+ isStreaming: boolean;
1448
+ disabled?: boolean;
1449
+ placeholder?: string;
1450
+ creditBalance?: number | null;
1451
+ }
1452
+
1453
+ export function ChatInput({
1454
+ onSend,
1455
+ onStop,
1456
+ isStreaming,
1457
+ disabled = false,
1458
+ placeholder = 'Type your message...',
1459
+ creditBalance,
1460
+ }: ChatInputProps) {
1461
+ const [input, setInput] = useState('');
1462
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
1463
+
1464
+ // Auto-resize textarea
1465
+ useEffect(() => {
1466
+ const textarea = textareaRef.current;
1467
+ if (textarea) {
1468
+ textarea.style.height = 'auto';
1469
+ textarea.style.height = \`\${Math.min(textarea.scrollHeight, 200)}px\`;
1470
+ }
1471
+ }, [input]);
1472
+
1473
+ // Focus textarea when streaming ends
1474
+ useEffect(() => {
1475
+ if (!isStreaming) {
1476
+ textareaRef.current?.focus();
1477
+ }
1478
+ }, [isStreaming]);
1479
+
1480
+ const handleSend = useCallback(() => {
1481
+ const trimmed = input.trim();
1482
+ if (!trimmed || isStreaming || disabled) return;
1483
+ onSend(trimmed);
1484
+ setInput('');
1485
+ if (textareaRef.current) {
1486
+ textareaRef.current.style.height = 'auto';
1487
+ }
1488
+ }, [input, isStreaming, disabled, onSend]);
1489
+
1490
+ const handleKeyDown = useCallback(
1491
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
1492
+ if (e.key === 'Enter' && !e.shiftKey) {
1493
+ e.preventDefault();
1494
+ handleSend();
1495
+ }
1496
+ },
1497
+ [handleSend]
1498
+ );
1499
+
1500
+ return (
1501
+ <div className="border-t bg-white dark:bg-gray-900 p-4">
1502
+ <div className="mx-auto max-w-3xl">
1503
+ <div className="flex items-end gap-2">
1504
+ <textarea
1505
+ ref={textareaRef}
1506
+ value={input}
1507
+ onChange={(e) => setInput(e.target.value)}
1508
+ onKeyDown={handleKeyDown}
1509
+ placeholder={isStreaming ? 'AI is responding...' : placeholder}
1510
+ disabled={isStreaming || disabled}
1511
+ rows={1}
1512
+ className="flex-1 min-h-[44px] max-h-[200px] resize-none rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
1513
+ />
1514
+ {isStreaming ? (
1515
+ <button
1516
+ onClick={onStop}
1517
+ className="h-[44px] w-[44px] shrink-0 flex items-center justify-center rounded-lg bg-red-600 text-white hover:bg-red-700"
1518
+ >
1519
+ <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
1520
+ <rect x="6" y="6" width="12" height="12" rx="1" />
1521
+ </svg>
1522
+ </button>
1523
+ ) : (
1524
+ <button
1525
+ onClick={handleSend}
1526
+ disabled={!input.trim() || disabled}
1527
+ className="h-[44px] w-[44px] shrink-0 flex items-center justify-center rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
1528
+ >
1529
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1530
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
1531
+ </svg>
1532
+ </button>
1533
+ )}
1534
+ </div>
1535
+ <div className="flex items-center justify-between mt-1.5 px-1">
1536
+ <p className="text-[11px] text-gray-400">
1537
+ Enter to send · Shift+Enter for new line
1538
+ </p>
1539
+ <div className="flex items-center gap-2 text-[11px] text-gray-400">
1540
+ {input.trim() && <span>~{estimateTokens(input)} tokens</span>}
1541
+ {creditBalance !== null && creditBalance !== undefined && (
1542
+ <span className={\`font-medium \${creditBalance < 100 ? 'text-red-500' : ''}\`}>
1543
+ Balance: {creditBalance.toLocaleString()} credits
1544
+ </span>
1545
+ )}
1546
+ </div>
1547
+ </div>
1548
+ </div>
1549
+ </div>
1550
+ );
1551
+ }
1552
+ `;
1553
+ }
1554
+ /**
1555
+ * Generates the ChatSidebar component with conversation list, search, grouping
1556
+ * Based on apps/frontend/components/chat/chat-sidebar.tsx
1557
+ * SIMPLIFIED: No shadcn dependency, uses plain Tailwind
1558
+ * @returns TypeScript/JSX content for components/chat/chat-sidebar.tsx
1559
+ */
1560
+ function generateChatSidebar() {
1561
+ return `// @chimerai component=ChatSidebar version=2.0
1562
+ 'use client';
1563
+
1564
+ import { useState } from 'react';
1565
+
1566
+ export interface ConversationItem {
1567
+ id: string;
1568
+ title: string;
1569
+ updatedAt: string;
1570
+ _count: {
1571
+ messages: number;
1572
+ };
1573
+ }
1574
+
1575
+ interface ChatSidebarProps {
1576
+ conversations: ConversationItem[];
1577
+ selectedId: string | null;
1578
+ onSelect: (id: string) => void;
1579
+ onNewChat: () => void;
1580
+ onDelete: (id: string) => void;
1581
+ onRename: (id: string, title: string) => void;
1582
+ }
1583
+
1584
+ function groupByDate(conversations: ConversationItem[]) {
1585
+ const groups: Record<string, ConversationItem[]> = {};
1586
+ const now = new Date();
1587
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1588
+ const yesterday = new Date(today);
1589
+ yesterday.setDate(yesterday.getDate() - 1);
1590
+
1591
+ // Monday-based week: calculate start of current week (Monday 00:00)
1592
+ const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ...
1593
+ const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
1594
+ const startOfWeek = new Date(today);
1595
+ startOfWeek.setDate(startOfWeek.getDate() - diffToMonday);
1596
+
1597
+ // Start of current month
1598
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
1599
+
1600
+ for (const conv of conversations) {
1601
+ const date = new Date(conv.updatedAt);
1602
+ let label: string;
1603
+
1604
+ if (date >= today) {
1605
+ label = 'Today';
1606
+ } else if (date >= yesterday) {
1607
+ label = 'Yesterday';
1608
+ } else if (date >= startOfWeek) {
1609
+ label = 'This Week';
1610
+ } else if (date >= startOfMonth) {
1611
+ label = 'This Month';
1612
+ } else {
1613
+ label = 'Older';
1614
+ }
1615
+
1616
+ if (!groups[label]) groups[label] = [];
1617
+ groups[label].push(conv);
1618
+ }
1619
+
1620
+ return groups;
1621
+ }
1622
+
1623
+ export function ChatSidebar({
1624
+ conversations,
1625
+ selectedId,
1626
+ onSelect,
1627
+ onNewChat,
1628
+ onDelete,
1629
+ onRename,
1630
+ }: ChatSidebarProps) {
1631
+ const [search, setSearch] = useState('');
1632
+ const [editingId, setEditingId] = useState<string | null>(null);
1633
+ const [editTitle, setEditTitle] = useState('');
1634
+ const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
1635
+
1636
+ const filtered = conversations.filter((c) =>
1637
+ c.title.toLowerCase().includes(search.toLowerCase())
1638
+ );
1639
+
1640
+ const grouped = groupByDate(filtered);
1641
+
1642
+ const handleStartRename = (conv: ConversationItem) => {
1643
+ setEditingId(conv.id);
1644
+ setEditTitle(conv.title);
1645
+ setMenuOpenId(null);
1646
+ };
1647
+
1648
+ const handleFinishRename = (id: string) => {
1649
+ if (editTitle.trim()) {
1650
+ onRename(id, editTitle.trim());
1651
+ }
1652
+ setEditingId(null);
1653
+ };
1654
+
1655
+ const handleDelete = (id: string) => {
1656
+ setMenuOpenId(null);
1657
+ onDelete(id);
1658
+ };
1659
+
1660
+ return (
1661
+ <div className="flex h-full flex-col bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
1662
+ {/* New Chat Button */}
1663
+ <div className="p-3">
1664
+ <button
1665
+ onClick={onNewChat}
1666
+ className="w-full flex items-center justify-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
1667
+ >
1668
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1669
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
1670
+ </svg>
1671
+ New Chat
1672
+ </button>
1673
+ </div>
1674
+
1675
+ {/* Search */}
1676
+ {conversations.length > 3 && (
1677
+ <div className="px-3 pb-2">
1678
+ <div className="relative">
1679
+ <svg className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1680
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
1681
+ </svg>
1682
+ <input
1683
+ value={search}
1684
+ onChange={(e) => setSearch(e.target.value)}
1685
+ placeholder="Search chats..."
1686
+ className="w-full h-8 pl-8 pr-3 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
1687
+ />
1688
+ </div>
1689
+ </div>
1690
+ )}
1691
+
1692
+ {/* Conversation List */}
1693
+ <div className="flex-1 overflow-y-auto px-2">
1694
+ {Object.entries(grouped).map(([label, convs]) => (
1695
+ <div key={label} className="mb-3">
1696
+ <p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
1697
+ {label}
1698
+ </p>
1699
+ <div className="space-y-0.5">
1700
+ {convs.map((conv) => (
1701
+ <div
1702
+ key={conv.id}
1703
+ className={\`group flex items-center rounded-lg px-2 py-2 text-sm cursor-pointer transition-colors \${
1704
+ selectedId === conv.id
1705
+ ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
1706
+ : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-900 dark:text-gray-100'
1707
+ }\`}
1708
+ onClick={() => onSelect(conv.id)}
1709
+ >
1710
+ <svg className="mr-2 h-4 w-4 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1711
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
1712
+ </svg>
1713
+
1714
+ {editingId === conv.id ? (
1715
+ <input
1716
+ value={editTitle}
1717
+ onChange={(e) => setEditTitle(e.target.value)}
1718
+ onBlur={() => handleFinishRename(conv.id)}
1719
+ onKeyDown={(e) => {
1720
+ if (e.key === 'Enter') handleFinishRename(conv.id);
1721
+ if (e.key === 'Escape') setEditingId(null);
1722
+ }}
1723
+ onClick={(e) => e.stopPropagation()}
1724
+ autoFocus
1725
+ className="flex-1 h-6 text-xs px-1 border rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 dark:border-gray-600"
1726
+ />
1727
+ ) : (
1728
+ <>
1729
+ <div className="flex-1 min-w-0">
1730
+ <p className="truncate text-sm text-gray-900 dark:text-gray-100">{conv.title}</p>
1731
+ <p className="text-[10px] text-gray-400">{conv._count.messages} messages</p>
1732
+ </div>
1733
+
1734
+ {/* Actions dropdown */}
1735
+ <div className="relative">
1736
+ <button
1737
+ onClick={(e) => {
1738
+ e.stopPropagation();
1739
+ setMenuOpenId(menuOpenId === conv.id ? null : conv.id);
1740
+ }}
1741
+ className="h-6 w-6 shrink-0 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400"
1742
+ >
1743
+ <svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1744
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01" />
1745
+ </svg>
1746
+ </button>
1747
+ {menuOpenId === conv.id && (
1748
+ <div className="absolute right-0 top-7 z-50 w-36 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg py-1">
1749
+ <button
1750
+ onClick={(e) => {
1751
+ e.stopPropagation();
1752
+ handleStartRename(conv);
1753
+ }}
1754
+ className="w-full flex items-center px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
1755
+ >
1756
+ <svg className="mr-2 h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1757
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
1758
+ </svg>
1759
+ Rename
1760
+ </button>
1761
+ <button
1762
+ onClick={(e) => {
1763
+ e.stopPropagation();
1764
+ handleDelete(conv.id);
1765
+ }}
1766
+ className="w-full flex items-center px-3 py-1.5 text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
1767
+ >
1768
+ <svg className="mr-2 h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1769
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
1770
+ </svg>
1771
+ Delete
1772
+ </button>
1773
+ </div>
1774
+ )}
1775
+ </div>
1776
+ </>
1777
+ )}
1778
+ </div>
1779
+ ))}
1780
+ </div>
1781
+ </div>
1782
+ ))}
1783
+
1784
+ {filtered.length === 0 && (
1785
+ <div className="px-3 py-8 text-center">
1786
+ <svg className="mx-auto h-8 w-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1787
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
1788
+ </svg>
1789
+ <p className="mt-2 text-sm text-gray-400">
1790
+ {search ? 'No matching chats' : 'No conversations yet'}
1791
+ </p>
1792
+ </div>
1793
+ )}
1794
+ </div>
1795
+ </div>
1796
+ );
1797
+ }
1798
+ `;
1799
+ }
1800
+ /**
1801
+ * Generates the ModelSelector component for model selection grouped by provider
1802
+ * Based on apps/frontend/components/chat/model-selector.tsx
1803
+ * SIMPLIFIED: No shadcn dependency, uses plain Tailwind select
1804
+ * @returns TypeScript/JSX content for components/chat/model-selector.tsx
1805
+ */
1806
+ function generateModelSelector() {
1807
+ return `// @chimerai component=ModelSelector version=2.0
1808
+ 'use client';
1809
+
1810
+ import { useMemo } from 'react';
1811
+
1812
+ export interface ModelOption {
1813
+ id: string;
1814
+ modelId: string;
1815
+ name: string;
1816
+ providerId: string;
1817
+ providerType: string;
1818
+ contextWindow: number;
1819
+ inputCost: number;
1820
+ outputCost: number;
1821
+ capabilities: string[];
1822
+ provider: {
1823
+ id: string;
1824
+ name: string;
1825
+ type: string;
1826
+ };
1827
+ }
1828
+
1829
+ interface ModelSelectorProps {
1830
+ models: ModelOption[];
1831
+ value: string;
1832
+ onValueChange: (modelId: string) => void;
1833
+ disabled?: boolean;
1834
+ }
1835
+
1836
+ export function ModelSelector({ models, value, onValueChange, disabled }: ModelSelectorProps) {
1837
+ // Group models by provider
1838
+ const grouped = useMemo(() => {
1839
+ const groups: Record<string, ModelOption[]> = {};
1840
+ for (const model of models) {
1841
+ const providerName = model.provider?.name || 'Unknown';
1842
+ if (!groups[providerName]) groups[providerName] = [];
1843
+ groups[providerName].push(model);
1844
+ }
1845
+ return groups;
1846
+ }, [models]);
1847
+
1848
+ return (
1849
+ <select
1850
+ value={value}
1851
+ onChange={(e) => onValueChange(e.target.value)}
1852
+ disabled={disabled}
1853
+ className="h-8 text-xs w-[220px] rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
1854
+ >
1855
+ <option value="" disabled className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">Select a model</option>
1856
+ {Object.entries(grouped).map(([providerName, providerModels]) => (
1857
+ <optgroup key={providerName} label={providerName} className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-semibold">
1858
+ {providerModels.map((model) => (
1859
+ <option key={model.id} value={model.id} className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
1860
+ {model.name}{model.contextWindow >= 100000 ? \` (\${Math.round(model.contextWindow / 1000)}k)\` : ''}
1861
+ </option>
1862
+ ))}
1863
+ </optgroup>
1864
+ ))}
1865
+ </select>
1866
+ );
1867
+ }
1868
+ `;
1869
+ }
1870
+ /**
1871
+ * Generates the Conversation Detail API route (GET/PATCH/DELETE for /api/conversations/[id])
1872
+ * @returns TypeScript content for app/api/conversations/[id]/route.ts
1873
+ */
1874
+ function generateConversationDetailRoute() {
1875
+ return `// @chimerai component=ConversationDetailRoute version=1.0
1876
+ import { NextRequest, NextResponse } from 'next/server';
1877
+ import { requireAuth, createErrorResponse } from '@/lib/api-protection';
1878
+ import { prisma } from '@/lib/prisma';
1879
+
1880
+ export const dynamic = 'force-dynamic';
1881
+
1882
+ export async function GET(
1883
+ request: NextRequest,
1884
+ { params }: { params: Promise<{ id: string }> }
1885
+ ) {
1886
+ const { id } = await params;
1887
+ const authResult = await requireAuth(request);
1888
+ if (!authResult.authorized || !authResult.user) {
1889
+ return createErrorResponse(authResult);
1890
+ }
1891
+
1892
+ try {
1893
+ const conversation = await (prisma as any).conversation.findUnique({
1894
+ where: { id: id, userId: authResult.user.id },
1895
+ include: {
1896
+ messages: {
1897
+ orderBy: { createdAt: 'asc' },
1898
+ },
1899
+ },
1900
+ });
1901
+
1902
+ if (!conversation) {
1903
+ return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
1904
+ }
1905
+
1906
+ return NextResponse.json(conversation);
1907
+ } catch (error) {
1908
+ console.error('Failed to fetch conversation:', error);
1909
+ return NextResponse.json({ error: 'Failed to fetch conversation' }, { status: 500 });
1910
+ }
1911
+ }
1912
+
1913
+ export async function PATCH(
1914
+ request: NextRequest,
1915
+ { params }: { params: Promise<{ id: string }> }
1916
+ ) {
1917
+ const { id } = await params;
1918
+ const authResult = await requireAuth(request);
1919
+ if (!authResult.authorized || !authResult.user) {
1920
+ return createErrorResponse(authResult);
1921
+ }
1922
+
1923
+ try {
1924
+ const body = await request.json();
1925
+
1926
+ // Verify ownership
1927
+ const existing = await (prisma as any).conversation.findUnique({
1928
+ where: { id: id },
1929
+ });
1930
+ if (!existing || existing.userId !== authResult.user.id) {
1931
+ return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
1932
+ }
1933
+
1934
+ const updated = await (prisma as any).conversation.update({
1935
+ where: { id: id },
1936
+ data: {
1937
+ ...(body.title !== undefined && { title: body.title }),
1938
+ ...(body.archived !== undefined && { archived: body.archived }),
1939
+ },
1940
+ });
1941
+
1942
+ return NextResponse.json(updated);
1943
+ } catch (error) {
1944
+ console.error('Failed to update conversation:', error);
1945
+ return NextResponse.json({ error: 'Failed to update conversation' }, { status: 500 });
1946
+ }
1947
+ }
1948
+
1949
+ export async function DELETE(
1950
+ request: NextRequest,
1951
+ { params }: { params: Promise<{ id: string }> }
1952
+ ) {
1953
+ const { id } = await params;
1954
+ const authResult = await requireAuth(request);
1955
+ if (!authResult.authorized || !authResult.user) {
1956
+ return createErrorResponse(authResult);
1957
+ }
1958
+
1959
+ try {
1960
+ // Verify ownership
1961
+ const existing = await (prisma as any).conversation.findUnique({
1962
+ where: { id: id },
1963
+ });
1964
+ if (!existing || existing.userId !== authResult.user.id) {
1965
+ return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
1966
+ }
1967
+
1968
+ await (prisma as any).conversation.delete({
1969
+ where: { id: id },
1970
+ });
1971
+
1972
+ return NextResponse.json({ success: true });
1973
+ } catch (error) {
1974
+ console.error('Failed to delete conversation:', error);
1975
+ return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
1976
+ }
1977
+ }
1978
+ `;
1979
+ }