@theihtisham/ai-agent-starter-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +33 -0
- package/Dockerfile +35 -0
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/docker-compose.yml +28 -0
- package/next-env.d.ts +5 -0
- package/next.config.mjs +17 -0
- package/package.json +85 -0
- package/postcss.config.js +6 -0
- package/prisma/schema.prisma +157 -0
- package/prisma/seed.ts +46 -0
- package/src/app/(auth)/forgot-password/page.tsx +56 -0
- package/src/app/(auth)/layout.tsx +7 -0
- package/src/app/(auth)/login/page.tsx +83 -0
- package/src/app/(auth)/signup/page.tsx +108 -0
- package/src/app/(dashboard)/agents/[id]/edit/page.tsx +68 -0
- package/src/app/(dashboard)/agents/[id]/page.tsx +114 -0
- package/src/app/(dashboard)/agents/new/page.tsx +43 -0
- package/src/app/(dashboard)/agents/page.tsx +63 -0
- package/src/app/(dashboard)/api-keys/page.tsx +139 -0
- package/src/app/(dashboard)/dashboard/page.tsx +79 -0
- package/src/app/(dashboard)/layout.tsx +16 -0
- package/src/app/(dashboard)/settings/billing/page.tsx +59 -0
- package/src/app/(dashboard)/settings/page.tsx +45 -0
- package/src/app/(dashboard)/usage/page.tsx +46 -0
- package/src/app/api/agents/[id]/chat/route.ts +100 -0
- package/src/app/api/agents/[id]/chats/route.ts +36 -0
- package/src/app/api/agents/[id]/route.ts +97 -0
- package/src/app/api/agents/route.ts +84 -0
- package/src/app/api/api-keys/[id]/route.ts +25 -0
- package/src/app/api/api-keys/route.ts +72 -0
- package/src/app/api/auth/[...nextauth]/route.ts +5 -0
- package/src/app/api/auth/register/route.ts +53 -0
- package/src/app/api/health/route.ts +26 -0
- package/src/app/api/stripe/checkout/route.ts +37 -0
- package/src/app/api/stripe/plans/route.ts +16 -0
- package/src/app/api/stripe/portal/route.ts +29 -0
- package/src/app/api/stripe/webhook/route.ts +45 -0
- package/src/app/api/usage/route.ts +43 -0
- package/src/app/globals.css +59 -0
- package/src/app/layout.tsx +22 -0
- package/src/app/page.tsx +32 -0
- package/src/app/pricing/page.tsx +25 -0
- package/src/components/agents/agent-form.tsx +137 -0
- package/src/components/agents/model-selector.tsx +35 -0
- package/src/components/agents/tool-selector.tsx +48 -0
- package/src/components/auth-provider.tsx +17 -0
- package/src/components/billing/plan-badge.tsx +23 -0
- package/src/components/billing/pricing-table.tsx +95 -0
- package/src/components/billing/usage-meter.tsx +39 -0
- package/src/components/chat/chat-input.tsx +68 -0
- package/src/components/chat/chat-interface.tsx +152 -0
- package/src/components/chat/chat-message.tsx +50 -0
- package/src/components/chat/chat-sidebar.tsx +49 -0
- package/src/components/chat/code-block.tsx +38 -0
- package/src/components/chat/markdown-renderer.tsx +56 -0
- package/src/components/chat/streaming-text.tsx +46 -0
- package/src/components/dashboard/agent-card.tsx +52 -0
- package/src/components/dashboard/header.tsx +75 -0
- package/src/components/dashboard/sidebar.tsx +52 -0
- package/src/components/dashboard/stat-card.tsx +42 -0
- package/src/components/dashboard/usage-chart.tsx +42 -0
- package/src/components/landing/cta.tsx +30 -0
- package/src/components/landing/features.tsx +75 -0
- package/src/components/landing/hero.tsx +42 -0
- package/src/components/landing/pricing.tsx +28 -0
- package/src/components/ui/avatar.tsx +24 -0
- package/src/components/ui/badge.tsx +24 -0
- package/src/components/ui/button.tsx +39 -0
- package/src/components/ui/card.tsx +50 -0
- package/src/components/ui/dialog.tsx +73 -0
- package/src/components/ui/dropdown.tsx +77 -0
- package/src/components/ui/input.tsx +23 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +48 -0
- package/src/components/ui/tabs.tsx +66 -0
- package/src/components/ui/textarea.tsx +20 -0
- package/src/hooks/use-agent.ts +44 -0
- package/src/hooks/use-streaming.ts +82 -0
- package/src/hooks/use-subscription.ts +40 -0
- package/src/hooks/use-usage.ts +43 -0
- package/src/hooks/use-user.ts +13 -0
- package/src/lib/agents/index.ts +60 -0
- package/src/lib/agents/memory/long-term.ts +241 -0
- package/src/lib/agents/memory/manager.ts +154 -0
- package/src/lib/agents/memory/short-term.ts +155 -0
- package/src/lib/agents/memory/types.ts +68 -0
- package/src/lib/agents/orchestration/debate.ts +170 -0
- package/src/lib/agents/orchestration/index.ts +103 -0
- package/src/lib/agents/orchestration/parallel.ts +143 -0
- package/src/lib/agents/orchestration/router.ts +199 -0
- package/src/lib/agents/orchestration/sequential.ts +127 -0
- package/src/lib/agents/orchestration/types.ts +68 -0
- package/src/lib/agents/tools/calculator.ts +131 -0
- package/src/lib/agents/tools/code-executor.ts +191 -0
- package/src/lib/agents/tools/file-reader.ts +129 -0
- package/src/lib/agents/tools/index.ts +48 -0
- package/src/lib/agents/tools/registry.ts +182 -0
- package/src/lib/agents/tools/web-search.ts +83 -0
- package/src/lib/ai/agent.ts +275 -0
- package/src/lib/ai/context.ts +68 -0
- package/src/lib/ai/memory.ts +98 -0
- package/src/lib/ai/models.ts +80 -0
- package/src/lib/ai/streaming.ts +80 -0
- package/src/lib/ai/tools.ts +149 -0
- package/src/lib/auth/middleware.ts +41 -0
- package/src/lib/auth/nextauth.ts +69 -0
- package/src/lib/db/client.ts +15 -0
- package/src/lib/rate-limit/limiter.ts +93 -0
- package/src/lib/rate-limit/rules.ts +38 -0
- package/src/lib/stripe/client.ts +25 -0
- package/src/lib/stripe/plans.ts +75 -0
- package/src/lib/stripe/usage.ts +123 -0
- package/src/lib/stripe/webhooks.ts +96 -0
- package/src/lib/utils/api-response.ts +85 -0
- package/src/lib/utils/errors.ts +73 -0
- package/src/lib/utils/helpers.ts +50 -0
- package/src/lib/utils/id.ts +21 -0
- package/src/lib/utils/logger.ts +38 -0
- package/src/lib/utils/validation.ts +44 -0
- package/src/middleware.ts +13 -0
- package/src/types/agent.ts +31 -0
- package/src/types/api.ts +38 -0
- package/src/types/billing.ts +35 -0
- package/src/types/chat.ts +30 -0
- package/src/types/next-auth.d.ts +19 -0
- package/tailwind.config.ts +72 -0
- package/tsconfig.json +28 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
|
5
|
+
import { Badge } from '@/components/ui/badge';
|
|
6
|
+
import { cn } from '@/lib/utils/helpers';
|
|
7
|
+
import { PLANS, type Plan } from '@/lib/stripe/plans';
|
|
8
|
+
|
|
9
|
+
interface PricingTableProps {
|
|
10
|
+
currentPlanId?: string;
|
|
11
|
+
onSelectPlan: (plan: Plan) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PricingTable({ currentPlanId, onSelectPlan }: PricingTableProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
17
|
+
{PLANS.map((plan) => {
|
|
18
|
+
const isCurrent = plan.id === currentPlanId;
|
|
19
|
+
const isPopular = plan.id === 'pro';
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Card
|
|
23
|
+
key={plan.id}
|
|
24
|
+
className={cn(
|
|
25
|
+
'relative flex flex-col',
|
|
26
|
+
isPopular && 'border-primary shadow-lg scale-105',
|
|
27
|
+
isCurrent && 'ring-2 ring-primary',
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
{isPopular && (
|
|
31
|
+
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
32
|
+
<Badge>Most Popular</Badge>
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
<CardHeader className="text-center">
|
|
36
|
+
<CardTitle className="text-xl">{plan.name}</CardTitle>
|
|
37
|
+
<CardDescription>
|
|
38
|
+
<span className="text-3xl font-bold text-foreground">${plan.price}</span>
|
|
39
|
+
<span className="text-muted-foreground">/mo</span>
|
|
40
|
+
</CardDescription>
|
|
41
|
+
</CardHeader>
|
|
42
|
+
<CardContent className="flex-1">
|
|
43
|
+
<ul className="space-y-2 text-sm">
|
|
44
|
+
<li className="flex items-center gap-2">
|
|
45
|
+
<span className="text-green-500">✓</span>
|
|
46
|
+
Up to {plan.features.maxAgents} agents
|
|
47
|
+
</li>
|
|
48
|
+
<li className="flex items-center gap-2">
|
|
49
|
+
<span className="text-green-500">✓</span>
|
|
50
|
+
{plan.features.maxMessages.toLocaleString()} messages/mo
|
|
51
|
+
</li>
|
|
52
|
+
<li className="flex items-center gap-2">
|
|
53
|
+
<span className="text-green-500">✓</span>
|
|
54
|
+
{(plan.features.maxTokens / 1000000).toFixed(0)}M tokens
|
|
55
|
+
</li>
|
|
56
|
+
<li className="flex items-center gap-2">
|
|
57
|
+
<span className={plan.features.tools ? 'text-green-500' : 'text-muted-foreground'}>
|
|
58
|
+
{plan.features.tools ? '✓' : '✗'}
|
|
59
|
+
</span>
|
|
60
|
+
Tool calling
|
|
61
|
+
</li>
|
|
62
|
+
<li className="flex items-center gap-2">
|
|
63
|
+
<span className={plan.features.apiAccess ? 'text-green-500' : 'text-muted-foreground'}>
|
|
64
|
+
{plan.features.apiAccess ? '✓' : '✗'}
|
|
65
|
+
</span>
|
|
66
|
+
API access
|
|
67
|
+
</li>
|
|
68
|
+
<li className="flex items-center gap-2">
|
|
69
|
+
<span className={plan.features.customTools ? 'text-green-500' : 'text-muted-foreground'}>
|
|
70
|
+
{plan.features.customTools ? '✓' : '✗'}
|
|
71
|
+
</span>
|
|
72
|
+
Custom tools
|
|
73
|
+
</li>
|
|
74
|
+
<li className="flex items-center gap-2">
|
|
75
|
+
<span className="text-green-500">✓</span>
|
|
76
|
+
{plan.features.support} support
|
|
77
|
+
</li>
|
|
78
|
+
</ul>
|
|
79
|
+
</CardContent>
|
|
80
|
+
<CardFooter>
|
|
81
|
+
<Button
|
|
82
|
+
className="w-full"
|
|
83
|
+
variant={isCurrent ? 'outline' : isPopular ? 'default' : 'secondary'}
|
|
84
|
+
disabled={isCurrent}
|
|
85
|
+
onClick={() => onSelectPlan(plan)}
|
|
86
|
+
>
|
|
87
|
+
{isCurrent ? 'Current Plan' : plan.price === 0 ? 'Get Started Free' : 'Upgrade Now'}
|
|
88
|
+
</Button>
|
|
89
|
+
</CardFooter>
|
|
90
|
+
</Card>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils/helpers';
|
|
2
|
+
|
|
3
|
+
interface UsageMeterProps {
|
|
4
|
+
label: string;
|
|
5
|
+
used: number;
|
|
6
|
+
limit: number;
|
|
7
|
+
unit?: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function UsageMeter({ label, used, limit, unit = '', className }: UsageMeterProps) {
|
|
12
|
+
const percentage = limit > 0 ? Math.min((used / limit) * 100, 100) : 0;
|
|
13
|
+
const isNearLimit = percentage > 80;
|
|
14
|
+
const isAtLimit = percentage >= 100;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className={cn('space-y-2', className)}>
|
|
18
|
+
<div className="flex items-center justify-between text-sm">
|
|
19
|
+
<span className="font-medium">{label}</span>
|
|
20
|
+
<span className={cn(
|
|
21
|
+
'text-muted-foreground',
|
|
22
|
+
isAtLimit && 'text-destructive font-medium',
|
|
23
|
+
isNearLimit && !isAtLimit && 'text-yellow-600',
|
|
24
|
+
)}>
|
|
25
|
+
{used.toLocaleString()}{unit} / {limit.toLocaleString()}{unit}
|
|
26
|
+
</span>
|
|
27
|
+
</div>
|
|
28
|
+
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
|
29
|
+
<div
|
|
30
|
+
className={cn(
|
|
31
|
+
'h-full rounded-full transition-all duration-500',
|
|
32
|
+
isAtLimit ? 'bg-destructive' : isNearLimit ? 'bg-yellow-500' : 'bg-primary',
|
|
33
|
+
)}
|
|
34
|
+
style={{ width: `${percentage}%` }}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { cn } from '@/lib/utils/helpers';
|
|
6
|
+
|
|
7
|
+
interface ChatInputProps {
|
|
8
|
+
onSend: (message: string) => void;
|
|
9
|
+
onStop?: () => void;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
isLoading?: boolean;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ChatInput({ onSend, onStop, disabled, isLoading, placeholder }: ChatInputProps) {
|
|
16
|
+
const [message, setMessage] = useState('');
|
|
17
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (textareaRef.current) {
|
|
21
|
+
textareaRef.current.style.height = 'auto';
|
|
22
|
+
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px';
|
|
23
|
+
}
|
|
24
|
+
}, [message]);
|
|
25
|
+
|
|
26
|
+
const handleSubmit = (e: FormEvent) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
const trimmed = message.trim();
|
|
29
|
+
if (!trimmed || disabled) return;
|
|
30
|
+
onSend(trimmed);
|
|
31
|
+
setMessage('');
|
|
32
|
+
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
36
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
handleSubmit(e);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<form onSubmit={handleSubmit} className="flex items-end gap-2 p-4 border-t bg-background">
|
|
44
|
+
<textarea
|
|
45
|
+
ref={textareaRef}
|
|
46
|
+
value={message}
|
|
47
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
48
|
+
onKeyDown={handleKeyDown}
|
|
49
|
+
placeholder={placeholder ?? 'Type your message...'}
|
|
50
|
+
disabled={disabled}
|
|
51
|
+
rows={1}
|
|
52
|
+
className={cn(
|
|
53
|
+
'flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
54
|
+
'max-h-[200px]',
|
|
55
|
+
)}
|
|
56
|
+
/>
|
|
57
|
+
{isLoading ? (
|
|
58
|
+
<Button type="button" variant="destructive" size="sm" onClick={onStop}>
|
|
59
|
+
Stop
|
|
60
|
+
</Button>
|
|
61
|
+
) : (
|
|
62
|
+
<Button type="submit" size="sm" disabled={disabled || !message.trim()}>
|
|
63
|
+
Send
|
|
64
|
+
</Button>
|
|
65
|
+
)}
|
|
66
|
+
</form>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { ChatMessage } from './chat-message';
|
|
5
|
+
import { ChatInput } from './chat-input';
|
|
6
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
7
|
+
|
|
8
|
+
interface Message {
|
|
9
|
+
id: string;
|
|
10
|
+
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ChatInterfaceProps {
|
|
15
|
+
agentId: string;
|
|
16
|
+
chatId?: string;
|
|
17
|
+
initialMessages?: Message[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ChatInterface({ agentId, chatId, initialMessages = [] }: ChatInterfaceProps) {
|
|
21
|
+
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
|
22
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
23
|
+
const [streamingContent, setStreamingContent] = useState('');
|
|
24
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
25
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
|
|
27
|
+
const scrollToBottom = useCallback(() => {
|
|
28
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
scrollToBottom();
|
|
33
|
+
}, [messages, streamingContent, scrollToBottom]);
|
|
34
|
+
|
|
35
|
+
const handleSend = async (content: string) => {
|
|
36
|
+
const userMessage: Message = {
|
|
37
|
+
id: crypto.randomUUID(),
|
|
38
|
+
role: 'user',
|
|
39
|
+
content,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
43
|
+
setIsLoading(true);
|
|
44
|
+
setStreamingContent('');
|
|
45
|
+
|
|
46
|
+
const abortController = new AbortController();
|
|
47
|
+
abortRef.current = abortController;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(`/api/agents/${agentId}/chat`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ message: content, chatId }),
|
|
54
|
+
signal: abortController.signal,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!response.ok) throw new Error('Failed to send message');
|
|
58
|
+
if (!response.body) throw new Error('No response body');
|
|
59
|
+
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let assistantContent = '';
|
|
63
|
+
|
|
64
|
+
while (true) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done) break;
|
|
67
|
+
|
|
68
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
69
|
+
const lines = chunk.split('\n');
|
|
70
|
+
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (!line.startsWith('data: ')) continue;
|
|
73
|
+
const data = line.slice(6).trim();
|
|
74
|
+
if (data === '[DONE]') continue;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(data);
|
|
78
|
+
if (parsed.type === 'text') {
|
|
79
|
+
assistantContent += parsed.content;
|
|
80
|
+
setStreamingContent(assistantContent);
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Skip malformed JSON
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const assistantMessage: Message = {
|
|
89
|
+
id: crypto.randomUUID(),
|
|
90
|
+
role: 'assistant',
|
|
91
|
+
content: assistantContent,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
setMessages((prev) => [...prev, assistantMessage]);
|
|
95
|
+
setStreamingContent('');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if ((err as Error).name !== 'AbortError') {
|
|
98
|
+
const errorMessage: Message = {
|
|
99
|
+
id: crypto.randomUUID(),
|
|
100
|
+
role: 'assistant',
|
|
101
|
+
content: 'Sorry, an error occurred. Please try again.',
|
|
102
|
+
};
|
|
103
|
+
setMessages((prev) => [...prev, errorMessage]);
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
setIsLoading(false);
|
|
107
|
+
abortRef.current = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleStop = () => {
|
|
112
|
+
abortRef.current?.abort();
|
|
113
|
+
setIsLoading(false);
|
|
114
|
+
if (streamingContent) {
|
|
115
|
+
setMessages((prev) => [
|
|
116
|
+
...prev,
|
|
117
|
+
{ id: crypto.randomUUID(), role: 'assistant', content: streamingContent },
|
|
118
|
+
]);
|
|
119
|
+
setStreamingContent('');
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-col h-full">
|
|
125
|
+
<div className="flex-1 overflow-y-auto">
|
|
126
|
+
{messages.length === 0 && !isLoading && (
|
|
127
|
+
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
128
|
+
<p>Start a conversation with this agent</p>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{messages.map((msg) => (
|
|
132
|
+
<ChatMessage key={msg.id} role={msg.role} content={msg.content} />
|
|
133
|
+
))}
|
|
134
|
+
{streamingContent && (
|
|
135
|
+
<ChatMessage role="assistant" content={streamingContent} isStreaming />
|
|
136
|
+
)}
|
|
137
|
+
{isLoading && !streamingContent && (
|
|
138
|
+
<div className="px-4 py-3">
|
|
139
|
+
<Skeleton className="h-4 w-3/4" />
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
<div ref={messagesEndRef} />
|
|
143
|
+
</div>
|
|
144
|
+
<ChatInput
|
|
145
|
+
onSend={handleSend}
|
|
146
|
+
onStop={handleStop}
|
|
147
|
+
isLoading={isLoading}
|
|
148
|
+
disabled={isLoading}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils/helpers';
|
|
4
|
+
import { Avatar } from '@/components/ui/avatar';
|
|
5
|
+
import { MarkdownRenderer } from './markdown-renderer';
|
|
6
|
+
|
|
7
|
+
interface ChatMessageProps {
|
|
8
|
+
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
9
|
+
content: string;
|
|
10
|
+
isStreaming?: boolean;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ChatMessage({ role, content, isStreaming }: ChatMessageProps) {
|
|
15
|
+
const isUser = role === 'user';
|
|
16
|
+
const isTool = role === 'tool';
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={cn('flex gap-3 px-4 py-3', isUser ? 'justify-end' : 'justify-start')}>
|
|
20
|
+
{!isUser && (
|
|
21
|
+
<Avatar
|
|
22
|
+
className="h-8 w-8 shrink-0"
|
|
23
|
+
fallback={isTool ? '🔧' : 'AI'}
|
|
24
|
+
/>
|
|
25
|
+
)}
|
|
26
|
+
<div
|
|
27
|
+
className={cn(
|
|
28
|
+
'max-w-[80%] rounded-lg px-4 py-2.5 text-sm',
|
|
29
|
+
isUser
|
|
30
|
+
? 'bg-primary text-primary-foreground'
|
|
31
|
+
: isTool
|
|
32
|
+
? 'bg-muted/50 border'
|
|
33
|
+
: 'bg-muted',
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{isUser ? (
|
|
37
|
+
<p className="whitespace-pre-wrap">{content}</p>
|
|
38
|
+
) : (
|
|
39
|
+
<MarkdownRenderer content={content} />
|
|
40
|
+
)}
|
|
41
|
+
{isStreaming && (
|
|
42
|
+
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
{isUser && (
|
|
46
|
+
<Avatar className="h-8 w-8 shrink-0" fallback="You" />
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { cn } from '@/lib/utils/helpers';
|
|
5
|
+
|
|
6
|
+
interface ChatSidebarProps {
|
|
7
|
+
chats: { id: string; title: string; updatedAt: string; _count: { messages: number } }[];
|
|
8
|
+
activeChatId?: string;
|
|
9
|
+
agentId: string;
|
|
10
|
+
onNewChat?: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ChatSidebar({ chats, activeChatId, agentId, onNewChat }: ChatSidebarProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-col h-full w-64 border-r bg-background">
|
|
16
|
+
<div className="p-3 border-b">
|
|
17
|
+
<button
|
|
18
|
+
onClick={onNewChat}
|
|
19
|
+
className="w-full rounded-md border border-dashed border-input p-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
20
|
+
>
|
|
21
|
+
+ New Chat
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex-1 overflow-y-auto">
|
|
25
|
+
{chats.length === 0 ? (
|
|
26
|
+
<div className="p-4 text-sm text-muted-foreground text-center">
|
|
27
|
+
No conversations yet
|
|
28
|
+
</div>
|
|
29
|
+
) : (
|
|
30
|
+
<div className="p-2 space-y-1">
|
|
31
|
+
{chats.map((chat) => (
|
|
32
|
+
<Link
|
|
33
|
+
key={chat.id}
|
|
34
|
+
href={`/agents/${agentId}?chat=${chat.id}`}
|
|
35
|
+
className={cn(
|
|
36
|
+
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent',
|
|
37
|
+
activeChatId === chat.id && 'bg-accent text-accent-foreground',
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
<span className="truncate flex-1">{chat.title}</span>
|
|
41
|
+
<span className="text-xs text-muted-foreground">{chat._count.messages}</span>
|
|
42
|
+
</Link>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { cn } from '@/lib/utils/helpers';
|
|
5
|
+
|
|
6
|
+
interface CodeBlockProps {
|
|
7
|
+
code: string;
|
|
8
|
+
language: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CodeBlock({ code, language }: CodeBlockProps) {
|
|
12
|
+
const [copied, setCopied] = useState(false);
|
|
13
|
+
|
|
14
|
+
const handleCopy = async () => {
|
|
15
|
+
await navigator.clipboard.writeText(code);
|
|
16
|
+
setCopied(true);
|
|
17
|
+
setTimeout(() => setCopied(false), 2000);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="my-2 rounded-lg border bg-zinc-950 dark:bg-zinc-900 overflow-hidden">
|
|
22
|
+
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 dark:bg-zinc-800">
|
|
23
|
+
<span className="text-xs text-zinc-400 font-mono">{language}</span>
|
|
24
|
+
<button
|
|
25
|
+
onClick={handleCopy}
|
|
26
|
+
className="text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
|
27
|
+
>
|
|
28
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
<pre className="p-4 overflow-x-auto">
|
|
32
|
+
<code className={cn('text-sm text-zinc-100 font-mono', `language-${language}`)}>
|
|
33
|
+
{code}
|
|
34
|
+
</code>
|
|
35
|
+
</pre>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import ReactMarkdown from 'react-markdown';
|
|
5
|
+
import remarkGfm from 'remark-gfm';
|
|
6
|
+
import { CodeBlock } from './code-block';
|
|
7
|
+
|
|
8
|
+
interface MarkdownRendererProps {
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|
13
|
+
const components = useMemo(() => ({
|
|
14
|
+
code({ className, children, ...props }: React.ComponentPropsWithoutRef<'code'> & { className?: string }) {
|
|
15
|
+
const match = /language-(\w+)/.exec(className ?? '');
|
|
16
|
+
const isInline = !match;
|
|
17
|
+
|
|
18
|
+
if (isInline) {
|
|
19
|
+
return (
|
|
20
|
+
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono" {...props}>
|
|
21
|
+
{children}
|
|
22
|
+
</code>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<CodeBlock language={match[1] ?? 'text'} code={String(children).replace(/\n$/, '')} />
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
pre({ children }: React.ComponentPropsWithoutRef<'pre'>) {
|
|
31
|
+
return <>{children}</>;
|
|
32
|
+
},
|
|
33
|
+
a({ href, children }: React.ComponentPropsWithoutRef<'a'>) {
|
|
34
|
+
return (
|
|
35
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:text-primary/80">
|
|
36
|
+
{children}
|
|
37
|
+
</a>
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
table({ children }: React.ComponentPropsWithoutRef<'table'>) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="my-2 overflow-auto rounded border">
|
|
43
|
+
<table className="w-full text-sm">{children}</table>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
}), []);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
|
51
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
|
52
|
+
{content}
|
|
53
|
+
</ReactMarkdown>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface StreamingTextProps {
|
|
6
|
+
text: string;
|
|
7
|
+
speed?: number;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function StreamingText({ text, speed = 10, className }: StreamingTextProps) {
|
|
12
|
+
const [displayedText, setDisplayedText] = useState('');
|
|
13
|
+
const indexRef = useRef(0);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (indexRef.current >= text.length) return;
|
|
17
|
+
|
|
18
|
+
const interval = setInterval(() => {
|
|
19
|
+
if (indexRef.current < text.length) {
|
|
20
|
+
setDisplayedText(text.slice(0, indexRef.current + 1));
|
|
21
|
+
indexRef.current += 1;
|
|
22
|
+
} else {
|
|
23
|
+
clearInterval(interval);
|
|
24
|
+
}
|
|
25
|
+
}, speed);
|
|
26
|
+
|
|
27
|
+
return () => clearInterval(interval);
|
|
28
|
+
}, [text, speed]);
|
|
29
|
+
|
|
30
|
+
// Reset when text changes completely
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!text.startsWith(displayedText)) {
|
|
33
|
+
setDisplayedText(text);
|
|
34
|
+
indexRef.current = text.length;
|
|
35
|
+
}
|
|
36
|
+
}, [text, displayedText]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<span className={className}>
|
|
40
|
+
{displayedText}
|
|
41
|
+
{indexRef.current < text.length && (
|
|
42
|
+
<span className="inline-block w-1.5 h-4 ml-0.5 bg-current animate-pulse" />
|
|
43
|
+
)}
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
3
|
+
import { Badge } from '@/components/ui/badge';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
|
|
6
|
+
interface AgentCardProps {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
model: string;
|
|
11
|
+
isPublic: boolean;
|
|
12
|
+
messageCount: number;
|
|
13
|
+
lastUsed?: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AgentCard({ id, name, description, model, isPublic, messageCount, lastUsed }: AgentCardProps) {
|
|
17
|
+
return (
|
|
18
|
+
<Card className="group hover:shadow-md transition-shadow">
|
|
19
|
+
<CardHeader className="pb-3">
|
|
20
|
+
<div className="flex items-start justify-between">
|
|
21
|
+
<div>
|
|
22
|
+
<CardTitle className="text-lg">
|
|
23
|
+
<Link href={`/agents/${id}`} className="hover:text-primary transition-colors">
|
|
24
|
+
{name}
|
|
25
|
+
</Link>
|
|
26
|
+
</CardTitle>
|
|
27
|
+
{description && (
|
|
28
|
+
<CardDescription className="mt-1 line-clamp-2">{description}</CardDescription>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
{isPublic && <Badge variant="secondary">Public</Badge>}
|
|
32
|
+
</div>
|
|
33
|
+
</CardHeader>
|
|
34
|
+
<CardContent>
|
|
35
|
+
<div className="flex items-center justify-between">
|
|
36
|
+
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
37
|
+
<span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{model}</span>
|
|
38
|
+
<span>{messageCount} messages</span>
|
|
39
|
+
</div>
|
|
40
|
+
<Link href={`/agents/${id}`}>
|
|
41
|
+
<Button variant="outline" size="sm">Chat</Button>
|
|
42
|
+
</Link>
|
|
43
|
+
</div>
|
|
44
|
+
{lastUsed && (
|
|
45
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
46
|
+
Last used {lastUsed}
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
49
|
+
</CardContent>
|
|
50
|
+
</Card>
|
|
51
|
+
);
|
|
52
|
+
}
|