@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,123 @@
|
|
|
1
|
+
import prisma from '../db/client';
|
|
2
|
+
import { getPlan } from './plans';
|
|
3
|
+
import { logger } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
export interface UsageCheckResult {
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
remaining: number;
|
|
8
|
+
limit: number;
|
|
9
|
+
resetDate: Date;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get current billing period usage for a user.
|
|
14
|
+
*/
|
|
15
|
+
export async function getUsageForPeriod(userId: string): Promise<{
|
|
16
|
+
messages: number;
|
|
17
|
+
toolCalls: number;
|
|
18
|
+
inputTokens: number;
|
|
19
|
+
outputTokens: number;
|
|
20
|
+
}> {
|
|
21
|
+
const subscription = await prisma.subscription.findUnique({
|
|
22
|
+
where: { userId },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const periodStart = subscription?.currentPeriodStart ?? new Date();
|
|
26
|
+
|
|
27
|
+
const usage = await prisma.usage.findMany({
|
|
28
|
+
where: {
|
|
29
|
+
userId,
|
|
30
|
+
createdAt: { gte: periodStart },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
messages: usage.filter((u) => u.type === 'message').reduce((sum, u) => sum + u.quantity, 0),
|
|
36
|
+
toolCalls: usage.filter((u) => u.type === 'tool_call').reduce((sum, u) => sum + u.quantity, 0),
|
|
37
|
+
inputTokens: usage.filter((u) => u.type === 'token_input').reduce((sum, u) => sum + u.quantity, 0),
|
|
38
|
+
outputTokens: usage.filter((u) => u.type === 'token_output').reduce((sum, u) => sum + u.quantity, 0),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if user can perform an action within plan limits.
|
|
44
|
+
*/
|
|
45
|
+
export async function checkUsageLimit(
|
|
46
|
+
userId: string,
|
|
47
|
+
type: 'messages' | 'agents' | 'tokens',
|
|
48
|
+
): Promise<UsageCheckResult> {
|
|
49
|
+
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
|
50
|
+
const planId = subscription?.stripePriceId ? 'pro' : 'free';
|
|
51
|
+
const plan = getPlan(planId);
|
|
52
|
+
|
|
53
|
+
if (type === 'agents') {
|
|
54
|
+
const agentCount = await prisma.agent.count({ where: { userId } });
|
|
55
|
+
return {
|
|
56
|
+
allowed: agentCount < plan.features.maxAgents,
|
|
57
|
+
remaining: Math.max(0, plan.features.maxAgents - agentCount),
|
|
58
|
+
limit: plan.features.maxAgents,
|
|
59
|
+
resetDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const usage = await getUsageForPeriod(userId);
|
|
64
|
+
|
|
65
|
+
if (type === 'messages') {
|
|
66
|
+
const used = usage.messages;
|
|
67
|
+
return {
|
|
68
|
+
allowed: used < plan.features.maxMessages,
|
|
69
|
+
remaining: Math.max(0, plan.features.maxMessages - used),
|
|
70
|
+
limit: plan.features.maxMessages,
|
|
71
|
+
resetDate: subscription?.currentPeriodEnd ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// tokens
|
|
76
|
+
const used = usage.inputTokens + usage.outputTokens;
|
|
77
|
+
return {
|
|
78
|
+
allowed: used < plan.features.maxTokens,
|
|
79
|
+
remaining: Math.max(0, plan.features.maxTokens - used),
|
|
80
|
+
limit: plan.features.maxTokens,
|
|
81
|
+
resetDate: subscription?.currentPeriodEnd ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Record usage for a user action.
|
|
87
|
+
*/
|
|
88
|
+
export async function recordUsage(
|
|
89
|
+
userId: string,
|
|
90
|
+
type: 'message' | 'tool_call' | 'token_input' | 'token_output',
|
|
91
|
+
quantity: number,
|
|
92
|
+
metadata?: Record<string, unknown>,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
await prisma.usage.create({
|
|
95
|
+
data: {
|
|
96
|
+
userId,
|
|
97
|
+
type,
|
|
98
|
+
quantity,
|
|
99
|
+
metadata: (metadata ?? {}) as never,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
logger.debug('Usage recorded', { userId, type, quantity });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get or create the Stripe customer for a user.
|
|
108
|
+
*/
|
|
109
|
+
export async function getOrCreateCustomer(userId: string, email: string): Promise<string> {
|
|
110
|
+
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
|
111
|
+
|
|
112
|
+
if (subscription?.stripeCustomerId) {
|
|
113
|
+
return subscription.stripeCustomerId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { stripe } = await import('./client');
|
|
117
|
+
const customer = await stripe.customers.create({
|
|
118
|
+
email,
|
|
119
|
+
metadata: { userId },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return customer.id;
|
|
123
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type Stripe from 'stripe';
|
|
2
|
+
import prisma from '../db/client';
|
|
3
|
+
import { getPlanByPriceId } from './plans';
|
|
4
|
+
import { logger } from '../utils/logger';
|
|
5
|
+
|
|
6
|
+
export async function handleCheckoutComplete(session: Stripe.Checkout.Session): Promise<void> {
|
|
7
|
+
const userId = session.metadata?.userId;
|
|
8
|
+
if (!userId) {
|
|
9
|
+
logger.error('No userId in checkout session metadata');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const plan = getPlanByPriceId(session.metadata?.priceId ?? '');
|
|
14
|
+
if (!plan) {
|
|
15
|
+
logger.error('Unknown priceId in checkout session', { priceId: session.metadata?.priceId });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await prisma.subscription.upsert({
|
|
20
|
+
where: { userId },
|
|
21
|
+
create: {
|
|
22
|
+
userId,
|
|
23
|
+
stripeCustomerId: session.customer as string,
|
|
24
|
+
stripePriceId: plan.stripePriceId,
|
|
25
|
+
stripeSubscriptionId: session.subscription as string,
|
|
26
|
+
status: 'active',
|
|
27
|
+
currentPeriodStart: new Date(),
|
|
28
|
+
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
29
|
+
},
|
|
30
|
+
update: {
|
|
31
|
+
stripeCustomerId: session.customer as string,
|
|
32
|
+
stripePriceId: plan.stripePriceId,
|
|
33
|
+
stripeSubscriptionId: session.subscription as string,
|
|
34
|
+
status: 'active',
|
|
35
|
+
currentPeriodStart: new Date(),
|
|
36
|
+
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
logger.info('Subscription activated', { userId, plan: plan.id });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
|
|
44
|
+
const sub = await prisma.subscription.findUnique({
|
|
45
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!sub) {
|
|
49
|
+
logger.error('Subscription not found for update', { stripeSubId: subscription.id });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await prisma.subscription.update({
|
|
54
|
+
where: { id: sub.id },
|
|
55
|
+
data: {
|
|
56
|
+
status: subscription.status,
|
|
57
|
+
stripePriceId: subscription.items.data[0]?.price.id ?? sub.stripePriceId,
|
|
58
|
+
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
|
59
|
+
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
60
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
logger.info('Subscription updated', { userId: sub.userId, status: subscription.status });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
|
68
|
+
const sub = await prisma.subscription.findUnique({
|
|
69
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!sub) return;
|
|
73
|
+
|
|
74
|
+
await prisma.subscription.update({
|
|
75
|
+
where: { id: sub.id },
|
|
76
|
+
data: { status: 'canceled' },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
logger.info('Subscription canceled', { userId: sub.userId });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
|
83
|
+
const customerId = invoice.customer as string;
|
|
84
|
+
const sub = await prisma.subscription.findUnique({
|
|
85
|
+
where: { stripeCustomerId: customerId },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!sub) return;
|
|
89
|
+
|
|
90
|
+
await prisma.subscription.update({
|
|
91
|
+
where: { id: sub.id },
|
|
92
|
+
data: { status: 'past_due' },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
logger.warn('Payment failed', { userId: sub.userId, customerId });
|
|
96
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { isAppError, toAppError } from './errors';
|
|
3
|
+
|
|
4
|
+
type SuccessResponse<T> = {
|
|
5
|
+
success: true;
|
|
6
|
+
data: T;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type ErrorResponse = {
|
|
10
|
+
success: false;
|
|
11
|
+
error: {
|
|
12
|
+
message: string;
|
|
13
|
+
code: string;
|
|
14
|
+
details?: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type PaginatedResponse<T> = SuccessResponse<{
|
|
19
|
+
items: T[];
|
|
20
|
+
pagination: {
|
|
21
|
+
total: number;
|
|
22
|
+
page: number;
|
|
23
|
+
pageSize: number;
|
|
24
|
+
hasMore: boolean;
|
|
25
|
+
};
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
export type ApiResponse<T = unknown> = SuccessResponse<T> | ErrorResponse;
|
|
29
|
+
|
|
30
|
+
export function success<T>(data: T, status = 200): NextResponse<SuccessResponse<T>> {
|
|
31
|
+
return NextResponse.json({ success: true, data }, { status });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function error(err: unknown): NextResponse<ErrorResponse> {
|
|
35
|
+
const appError = toAppError(err);
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{
|
|
38
|
+
success: false,
|
|
39
|
+
error: {
|
|
40
|
+
message: appError.message,
|
|
41
|
+
code: appError.code,
|
|
42
|
+
details: appError.details,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{ status: appError.statusCode },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function paginated<T>(
|
|
50
|
+
items: T[],
|
|
51
|
+
total: number,
|
|
52
|
+
page: number,
|
|
53
|
+
pageSize: number,
|
|
54
|
+
): NextResponse<PaginatedResponse<T>> {
|
|
55
|
+
return NextResponse.json({
|
|
56
|
+
success: true,
|
|
57
|
+
data: {
|
|
58
|
+
items,
|
|
59
|
+
pagination: {
|
|
60
|
+
total,
|
|
61
|
+
page,
|
|
62
|
+
pageSize,
|
|
63
|
+
hasMore: page * pageSize < total,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function noContent(): NextResponse {
|
|
70
|
+
return new NextResponse(null, { status: 204 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function handleApiError(handlerOrError: (() => Promise<NextResponse>) | unknown): Promise<NextResponse> | NextResponse<ErrorResponse> {
|
|
74
|
+
if (typeof handlerOrError === 'function') {
|
|
75
|
+
return handlerOrError().catch((err: unknown) => {
|
|
76
|
+
if (isAppError(err)) return error(err);
|
|
77
|
+
console.error('Unhandled API error:', err);
|
|
78
|
+
return error(err);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Direct error value
|
|
82
|
+
if (isAppError(handlerOrError)) return error(handlerOrError);
|
|
83
|
+
console.error('Unhandled API error:', handlerOrError);
|
|
84
|
+
return error(handlerOrError);
|
|
85
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
message: string,
|
|
4
|
+
public readonly statusCode: number = 500,
|
|
5
|
+
public readonly code: string = 'INTERNAL_ERROR',
|
|
6
|
+
public readonly details?: Record<string, unknown>,
|
|
7
|
+
) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'AppError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class NotFoundError extends AppError {
|
|
14
|
+
constructor(resource: string, id?: string) {
|
|
15
|
+
super(
|
|
16
|
+
id ? `${resource} with id '${id}' not found` : `${resource} not found`,
|
|
17
|
+
404,
|
|
18
|
+
'NOT_FOUND',
|
|
19
|
+
);
|
|
20
|
+
this.name = 'NotFoundError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class UnauthorizedError extends AppError {
|
|
25
|
+
constructor(message = 'Authentication required') {
|
|
26
|
+
super(message, 401, 'UNAUTHORIZED');
|
|
27
|
+
this.name = 'UnauthorizedError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ForbiddenError extends AppError {
|
|
32
|
+
constructor(message = 'You do not have permission to perform this action') {
|
|
33
|
+
super(message, 403, 'FORBIDDEN');
|
|
34
|
+
this.name = 'ForbiddenError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ValidationError extends AppError {
|
|
39
|
+
constructor(message: string, public readonly fields?: Record<string, string[]>) {
|
|
40
|
+
super(message, 400, 'VALIDATION_ERROR', fields ? { fields } : undefined);
|
|
41
|
+
this.name = 'ValidationError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class RateLimitError extends AppError {
|
|
46
|
+
constructor(public readonly retryAfter: number = 60) {
|
|
47
|
+
super(
|
|
48
|
+
`Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
|
49
|
+
429,
|
|
50
|
+
'RATE_LIMIT_EXCEEDED',
|
|
51
|
+
);
|
|
52
|
+
this.name = 'RateLimitError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class BillingError extends AppError {
|
|
57
|
+
constructor(message: string) {
|
|
58
|
+
super(message, 402, 'BILLING_ERROR');
|
|
59
|
+
this.name = 'BillingError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isAppError(error: unknown): error is AppError {
|
|
64
|
+
return error instanceof AppError;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function toAppError(error: unknown): AppError {
|
|
68
|
+
if (isAppError(error)) return error;
|
|
69
|
+
if (error instanceof Error) {
|
|
70
|
+
return new AppError(error.message, 500, 'INTERNAL_ERROR');
|
|
71
|
+
}
|
|
72
|
+
return new AppError('An unexpected error occurred', 500, 'INTERNAL_ERROR');
|
|
73
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatCurrency(amount: number, currency = 'USD'): string {
|
|
9
|
+
return new Intl.NumberFormat('en-US', {
|
|
10
|
+
style: 'currency',
|
|
11
|
+
currency,
|
|
12
|
+
}).format(amount);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatNumber(num: number): string {
|
|
16
|
+
return new Intl.NumberFormat('en-US').format(num);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatRelativeTime(date: Date | string): string {
|
|
20
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const diffMs = now.getTime() - d.getTime();
|
|
23
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
24
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
25
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
26
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
27
|
+
|
|
28
|
+
if (diffSecs < 60) return 'just now';
|
|
29
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
30
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
31
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
32
|
+
return d.toLocaleDateString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function truncate(str: string, maxLength: number): string {
|
|
36
|
+
if (str.length <= maxLength) return str;
|
|
37
|
+
return str.slice(0, maxLength - 3) + '...';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function capitalize(str: string): string {
|
|
41
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function slugify(str: string): string {
|
|
45
|
+
return str
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^\w\s-]/g, '')
|
|
48
|
+
.replace(/[\s_-]+/g, '-')
|
|
49
|
+
.replace(/^-+|-+$/g, '');
|
|
50
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
|
|
3
|
+
export function generateId(size = 21): string {
|
|
4
|
+
return nanoid(size);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function generateApiKey(): { key: string; prefix: string; hashed: string } {
|
|
8
|
+
const prefix = 'ak';
|
|
9
|
+
const secret = nanoid(32);
|
|
10
|
+
const key = `${prefix}_${secret}`;
|
|
11
|
+
const displayPrefix = key.slice(0, 8);
|
|
12
|
+
// In production, hash with bcrypt. For starter kit, use the key directly.
|
|
13
|
+
return { key, prefix: displayPrefix, hashed: key };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function generateChatTitle(message: string): string {
|
|
17
|
+
const maxLen = 50;
|
|
18
|
+
const cleaned = message.replace(/\n/g, ' ').trim();
|
|
19
|
+
if (cleaned.length <= maxLen) return cleaned;
|
|
20
|
+
return cleaned.slice(0, maxLen - 3) + '...';
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
4
|
+
debug: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
error: 3,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const currentLevel: LogLevel = (process.env.LOG_LEVEL as LogLevel) ?? 'info';
|
|
11
|
+
|
|
12
|
+
function shouldLog(level: LogLevel): boolean {
|
|
13
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatMessage(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
|
|
17
|
+
const timestamp = new Date().toISOString();
|
|
18
|
+
const base = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
|
19
|
+
if (meta && Object.keys(meta).length > 0) {
|
|
20
|
+
return `${base} ${JSON.stringify(meta)}`;
|
|
21
|
+
}
|
|
22
|
+
return base;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const logger = {
|
|
26
|
+
debug(message: string, meta?: Record<string, unknown>) {
|
|
27
|
+
if (shouldLog('debug')) console.debug(formatMessage('debug', message, meta));
|
|
28
|
+
},
|
|
29
|
+
info(message: string, meta?: Record<string, unknown>) {
|
|
30
|
+
if (shouldLog('info')) console.info(formatMessage('info', message, meta));
|
|
31
|
+
},
|
|
32
|
+
warn(message: string, meta?: Record<string, unknown>) {
|
|
33
|
+
if (shouldLog('warn')) console.warn(formatMessage('warn', message, meta));
|
|
34
|
+
},
|
|
35
|
+
error(message: string, meta?: Record<string, unknown>) {
|
|
36
|
+
if (shouldLog('error')) console.error(formatMessage('error', message, meta));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const createAgentSchema = z.object({
|
|
4
|
+
name: z.string().min(1, 'Name is required').max(100, 'Name too long'),
|
|
5
|
+
description: z.string().max(500, 'Description too long').optional(),
|
|
6
|
+
systemPrompt: z.string().min(1, 'System prompt is required').max(10000, 'System prompt too long'),
|
|
7
|
+
model: z.string().min(1, 'Model is required').default('gpt-4o'),
|
|
8
|
+
temperature: z.number().min(0).max(2).default(0.7),
|
|
9
|
+
maxTokens: z.number().int().min(1).max(128000).default(4096),
|
|
10
|
+
tools: z.array(z.string()).default([]),
|
|
11
|
+
isPublic: z.boolean().default(false),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const updateAgentSchema = z.object({
|
|
15
|
+
name: z.string().min(1).max(100).optional(),
|
|
16
|
+
description: z.string().max(500).optional(),
|
|
17
|
+
systemPrompt: z.string().min(1).max(10000).optional(),
|
|
18
|
+
model: z.string().min(1).optional(),
|
|
19
|
+
temperature: z.number().min(0).max(2).optional(),
|
|
20
|
+
maxTokens: z.number().int().min(1).max(128000).optional(),
|
|
21
|
+
tools: z.array(z.string()).optional(),
|
|
22
|
+
isPublic: z.boolean().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const chatMessageSchema = z.object({
|
|
26
|
+
message: z.string().min(1, 'Message is required').max(50000, 'Message too long'),
|
|
27
|
+
chatId: z.string().optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const createApiKeySchema = z.object({
|
|
31
|
+
name: z.string().min(1, 'Name is required').max(50, 'Name too long'),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const paginationSchema = z.object({
|
|
35
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
36
|
+
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
|
37
|
+
search: z.string().optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type CreateAgentInput = z.infer<typeof createAgentSchema>;
|
|
41
|
+
export type UpdateAgentInput = z.infer<typeof updateAgentSchema>;
|
|
42
|
+
export type ChatMessageInput = z.infer<typeof chatMessageSchema>;
|
|
43
|
+
export type CreateApiKeyInput = z.infer<typeof createApiKeySchema>;
|
|
44
|
+
export type PaginationInput = z.infer<typeof paginationSchema>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
import { authMiddleware } from '@/lib/auth/middleware';
|
|
4
|
+
|
|
5
|
+
export async function middleware(req: NextRequest) {
|
|
6
|
+
return authMiddleware(req);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const config = {
|
|
10
|
+
matcher: [
|
|
11
|
+
'/((?!_next/static|_next/image|favicon.ico|public).*)',
|
|
12
|
+
],
|
|
13
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface Agent {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
systemPrompt: string;
|
|
6
|
+
model: string;
|
|
7
|
+
temperature: number;
|
|
8
|
+
maxTokens: number;
|
|
9
|
+
tools: string[];
|
|
10
|
+
isPublic: boolean;
|
|
11
|
+
userId: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AgentFormData {
|
|
17
|
+
name: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
systemPrompt: string;
|
|
20
|
+
model: string;
|
|
21
|
+
temperature?: number;
|
|
22
|
+
maxTokens?: number;
|
|
23
|
+
tools?: string[];
|
|
24
|
+
isPublic?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AgentWithStats = Agent & {
|
|
28
|
+
chatCount: number;
|
|
29
|
+
messageCount: number;
|
|
30
|
+
lastUsed: string | null;
|
|
31
|
+
};
|
package/src/types/api.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ApiResponse<T = unknown> {
|
|
2
|
+
success: boolean;
|
|
3
|
+
data?: T;
|
|
4
|
+
error?: {
|
|
5
|
+
code: string;
|
|
6
|
+
message: string;
|
|
7
|
+
details?: unknown;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
|
12
|
+
pagination: {
|
|
13
|
+
page: number;
|
|
14
|
+
pageSize: number;
|
|
15
|
+
total: number;
|
|
16
|
+
totalPages: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ApiKey {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
prefix: string;
|
|
24
|
+
key: string; // Only present on creation
|
|
25
|
+
lastUsed: string | null;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HealthCheck {
|
|
30
|
+
status: 'ok' | 'degraded' | 'down';
|
|
31
|
+
timestamp: string;
|
|
32
|
+
version: string;
|
|
33
|
+
services: {
|
|
34
|
+
database: 'ok' | 'down';
|
|
35
|
+
redis: 'ok' | 'down' | 'not_configured';
|
|
36
|
+
stripe: 'ok' | 'down' | 'not_configured';
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface Plan {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
price: number;
|
|
5
|
+
interval: 'month' | 'year';
|
|
6
|
+
stripePriceId: string;
|
|
7
|
+
features: {
|
|
8
|
+
maxAgents: number;
|
|
9
|
+
maxMessages: number;
|
|
10
|
+
maxTokens: number;
|
|
11
|
+
tools: boolean;
|
|
12
|
+
apiAccess: boolean;
|
|
13
|
+
support: string;
|
|
14
|
+
customTools: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Subscription {
|
|
19
|
+
id: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
stripeCustomerId: string;
|
|
22
|
+
stripePriceId: string;
|
|
23
|
+
stripeSubscriptionId: string;
|
|
24
|
+
status: 'active' | 'past_due' | 'canceled' | 'trialing' | 'incomplete';
|
|
25
|
+
currentPeriodStart: string;
|
|
26
|
+
currentPeriodEnd: string;
|
|
27
|
+
cancelAtPeriodEnd: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UsageSummary {
|
|
31
|
+
messages: number;
|
|
32
|
+
toolCalls: number;
|
|
33
|
+
inputTokens: number;
|
|
34
|
+
outputTokens: number;
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface Chat {
|
|
2
|
+
id: string;
|
|
3
|
+
agentId: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
title: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
updatedAt: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Message {
|
|
11
|
+
id: string;
|
|
12
|
+
chatId: string;
|
|
13
|
+
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
14
|
+
content: string;
|
|
15
|
+
metadata?: Record<string, unknown>;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ChatWithMessages extends Chat {
|
|
20
|
+
messages: Message[];
|
|
21
|
+
_count: { messages: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type StreamEventType = 'text' | 'tool_call' | 'tool_result' | 'error' | 'done';
|
|
25
|
+
|
|
26
|
+
export interface StreamEvent {
|
|
27
|
+
type: StreamEventType;
|
|
28
|
+
content: string;
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
}
|