@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,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
|
+
}
|