feat: add social proof features and comprehensive documentation
Priority 2 - Social Proof: - Add ToolRating and ToolReview models to Prisma schema - Add rating aggregates (averageRating, ratingCount, reviewCount) to Tool model - Create /api/tools/[id]/rate endpoint for tool ratings - Create /api/tools/[id]/reviews endpoint for tool reviews - Create /api/tools/trending endpoint for trending tools - Add Rating component with interactive star rating - Add ReviewCard component with user avatars and review display - Add star and starFilled icons to UI package - Update ToolDetailClient to show ratings Priority 3 - Documentation: - Create /docs/api/tools API documentation page - Create /docs/api/agents API documentation page - Create /docs/api/collections API documentation page - Create /docs/api/authentication documentation page - Create /docs/quickstart getting started tutorial - Create /docs/sdk SDK reference documentation
This commit is contained in:
parent
d22d0e6f59
commit
b1dd3371cd
15 changed files with 3736 additions and 5 deletions
341
apps/web/src/app/api/tools/[id]/rate/route.ts
Normal file
341
apps/web/src/app/api/tools/[id]/rate/route.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from '~/lib/auth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const API_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
version: string;
|
||||||
|
timestamp: string;
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tools/[id]/rate
|
||||||
|
* Get the current user's rating for this tool and aggregate stats
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: RouteContext
|
||||||
|
): Promise<NextResponse<ApiResponse>> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get tool rating stats (always available)
|
||||||
|
const tool = await prisma.tool.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
averageRating: true,
|
||||||
|
ratingCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'NOT_FOUND', message: 'Tool not found' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's rating if logged in
|
||||||
|
let userRating: number | null = null;
|
||||||
|
if (session) {
|
||||||
|
const rating = await prisma.toolRating.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_toolId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userRating = rating?.rating ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userRating,
|
||||||
|
averageRating: tool.averageRating ? Number(tool.averageRating) : null,
|
||||||
|
ratingCount: tool.ratingCount,
|
||||||
|
},
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Error] GET /api/tools/[id]/rate:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INTERNAL_ERROR', message: 'Failed to get rating' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tools/[id]/rate
|
||||||
|
* Rate a tool (1-5 stars)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
context: RouteContext
|
||||||
|
): Promise<NextResponse<ApiResponse>> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json();
|
||||||
|
const rating = body.rating;
|
||||||
|
|
||||||
|
// Validate rating
|
||||||
|
if (typeof rating !== 'number' || rating < 1 || rating > 5 || !Number.isInteger(rating)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INVALID_RATING', message: 'Rating must be an integer between 1 and 5' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tool exists
|
||||||
|
const tool = await prisma.tool.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'NOT_FOUND', message: 'Tool not found' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert rating and recalculate aggregates
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Upsert user's rating
|
||||||
|
await tx.toolRating.upsert({
|
||||||
|
where: {
|
||||||
|
userId_toolId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate average rating
|
||||||
|
const aggregates = await tx.toolRating.aggregate({
|
||||||
|
where: { toolId: id },
|
||||||
|
_avg: { rating: true },
|
||||||
|
_count: { rating: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tool with new aggregates
|
||||||
|
await tx.tool.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
averageRating: aggregates._avg.rating,
|
||||||
|
ratingCount: aggregates._count.rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get updated stats
|
||||||
|
const updatedTool = await prisma.tool.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
averageRating: true,
|
||||||
|
ratingCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userRating: rating,
|
||||||
|
averageRating: updatedTool?.averageRating ? Number(updatedTool.averageRating) : null,
|
||||||
|
ratingCount: updatedTool?.ratingCount ?? 0,
|
||||||
|
},
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Error] POST /api/tools/[id]/rate:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INTERNAL_ERROR', message: 'Failed to rate tool' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/tools/[id]/rate
|
||||||
|
* Remove user's rating for a tool
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: RouteContext
|
||||||
|
): Promise<NextResponse<ApiResponse>> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if rating exists
|
||||||
|
const existingRating = await prisma.toolRating.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_toolId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingRating) {
|
||||||
|
const tool = await prisma.tool.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { averageRating: true, ratingCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userRating: null,
|
||||||
|
averageRating: tool?.averageRating ? Number(tool.averageRating) : null,
|
||||||
|
ratingCount: tool?.ratingCount ?? 0,
|
||||||
|
},
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete rating and recalculate aggregates
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.toolRating.delete({
|
||||||
|
where: {
|
||||||
|
userId_toolId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate average rating
|
||||||
|
const aggregates = await tx.toolRating.aggregate({
|
||||||
|
where: { toolId: id },
|
||||||
|
_avg: { rating: true },
|
||||||
|
_count: { rating: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tool with new aggregates
|
||||||
|
await tx.tool.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
averageRating: aggregates._avg.rating,
|
||||||
|
ratingCount: aggregates._count.rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get updated stats
|
||||||
|
const updatedTool = await prisma.tool.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
averageRating: true,
|
||||||
|
ratingCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userRating: null,
|
||||||
|
averageRating: updatedTool?.averageRating ? Number(updatedTool.averageRating) : null,
|
||||||
|
ratingCount: updatedTool?.ratingCount ?? 0,
|
||||||
|
},
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Error] DELETE /api/tools/[id]/rate:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INTERNAL_ERROR', message: 'Failed to remove rating' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
469
apps/web/src/app/api/tools/[id]/reviews/route.ts
Normal file
469
apps/web/src/app/api/tools/[id]/reviews/route.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from '~/lib/auth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const API_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
version: string;
|
||||||
|
timestamp: string;
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
pagination?: {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tools/[id]/reviews
|
||||||
|
* Get reviews for a tool
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
context: RouteContext
|
||||||
|
): Promise<NextResponse<ApiResponse>> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit')) || 20));
|
||||||
|
const offset = Math.max(0, Number(searchParams.get('offset')) || 0);
|
||||||
|
const sort = searchParams.get('sort') || 'recent'; // 'recent', 'helpful', 'highest', 'lowest'
|
||||||
|
|
||||||
|
// Check tool exists
|
||||||
|
const tool = await prisma.tool.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, reviewCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'NOT_FOUND', message: 'Tool not found' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build order by clause
|
||||||
|
let orderBy: Record<string, 'asc' | 'desc'>[];
|
||||||
|
switch (sort) {
|
||||||
|
case 'helpful':
|
||||||
|
orderBy = [{ helpfulCount: 'desc' }, { createdAt: 'desc' }];
|
||||||
|
break;
|
||||||
|
case 'highest':
|
||||||
|
orderBy = [{ rating: 'desc' }, { createdAt: 'desc' }];
|
||||||
|
break;
|
||||||
|
case 'lowest':
|
||||||
|
orderBy = [{ rating: 'asc' }, { createdAt: 'desc' }];
|
||||||
|
break;
|
||||||
|
case 'recent':
|
||||||
|
default:
|
||||||
|
orderBy = [{ createdAt: 'desc' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch reviews with user info
|
||||||
|
const reviews = await prisma.toolReview.findMany({
|
||||||
|
where: {
|
||||||
|
toolId: id,
|
||||||
|
isApproved: true,
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy,
|
||||||
|
take: limit + 1,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMore = reviews.length > limit;
|
||||||
|
const actualReviews = hasMore ? reviews.slice(0, limit) : reviews;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: actualReviews.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
title: review.title,
|
||||||
|
content: review.content,
|
||||||
|
rating: review.rating,
|
||||||
|
helpfulCount: review.helpfulCount,
|
||||||
|
createdAt: review.createdAt.toISOString(),
|
||||||
|
updatedAt: review.updatedAt.toISOString(),
|
||||||
|
user: {
|
||||||
|
id: review.user.id,
|
||||||
|
name: review.user.name,
|
||||||
|
image: review.user.image,
|
||||||
|
username: review.user.username,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Error] GET /api/tools/[id]/reviews:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INTERNAL_ERROR', message: 'Failed to get reviews' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tools/[id]/reviews
|
||||||
|
* Create or update a review for a tool
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
context: RouteContext
|
||||||
|
): Promise<NextResponse<ApiResponse>> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, content, rating } = body;
|
||||||
|
|
||||||
|
// Validate content
|
||||||
|
if (typeof content !== 'string' || content.trim().length < 10) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_CONTENT',
|
||||||
|
message: 'Review content must be at least 10 characters',
|
||||||
|
},
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.length > 5000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'CONTENT_TOO_LONG',
|
||||||
|
message: 'Review content must be less than 5000 characters',
|
||||||
|
},
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate title if provided
|
||||||
|
if (title !== undefined && title !== null) {
|
||||||
|
if (typeof title !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INVALID_TITLE', message: 'Title must be a string' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (title.length > 200) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'TITLE_TOO_LONG', message: 'Title must be less than 200 characters' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rating
|
||||||
|
if (typeof rating !== 'number' || rating < 1 || rating > 5 || !Number.isInteger(rating)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INVALID_RATING', message: 'Rating must be an integer between 1 and 5' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tool exists
|
||||||
|
const tool = await prisma.tool.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'NOT_FOUND', message: 'Tool not found' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already has a review
|
||||||
|
const existingReview = await prisma.toolReview.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_toolId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let review: {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
rating: number;
|
||||||
|
helpfulCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
user: { id: string; name: string | null; image: string | null; username: string | null };
|
||||||
|
} | null = null;
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (existingReview) {
|
||||||
|
// Update existing review
|
||||||
|
review = await tx.toolReview.update({
|
||||||
|
where: { id: existingReview.id },
|
||||||
|
data: {
|
||||||
|
title: title?.trim() || null,
|
||||||
|
content: content.trim(),
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new review
|
||||||
|
review = await tx.toolReview.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
title: title?.trim() || null,
|
||||||
|
content: content.trim(),
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment review count
|
||||||
|
await tx.tool.update({
|
||||||
|
where: { id },
|
||||||
|
data: { reviewCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update rating aggregate (upsert the rating)
|
||||||
|
await tx.toolRating.upsert({
|
||||||
|
where: {
|
||||||
|
userId_toolId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate average rating
|
||||||
|
const aggregates = await tx.toolRating.aggregate({
|
||||||
|
where: { toolId: id },
|
||||||
|
_avg: { rating: true },
|
||||||
|
_count: { rating: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.tool.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
averageRating: aggregates._avg.rating,
|
||||||
|
ratingCount: aggregates._count.rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: review!.id,
|
||||||
|
title: review!.title,
|
||||||
|
content: review!.content,
|
||||||
|
rating: review!.rating,
|
||||||
|
helpfulCount: review!.helpfulCount,
|
||||||
|
createdAt: review!.createdAt.toISOString(),
|
||||||
|
updatedAt: review!.updatedAt.toISOString(),
|
||||||
|
user: {
|
||||||
|
id: review!.user.id,
|
||||||
|
name: review!.user.name,
|
||||||
|
image: review!.user.image,
|
||||||
|
username: review!.user.username,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Error] POST /api/tools/[id]/reviews:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INTERNAL_ERROR', message: 'Failed to create review' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/tools/[id]/reviews
|
||||||
|
* Delete user's review for a tool
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: RouteContext
|
||||||
|
): Promise<NextResponse<ApiResponse>> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if review exists
|
||||||
|
const existingReview = await prisma.toolReview.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_toolId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
toolId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingReview) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { deleted: false, message: 'No review to delete' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete review and update count
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.toolReview.delete({
|
||||||
|
where: { id: existingReview.id },
|
||||||
|
}),
|
||||||
|
prisma.tool.update({
|
||||||
|
where: { id },
|
||||||
|
data: { reviewCount: { decrement: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { deleted: true },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Error] DELETE /api/tools/[id]/reviews:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INTERNAL_ERROR', message: 'Failed to delete review' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
apps/web/src/app/api/tools/trending/route.ts
Normal file
181
apps/web/src/app/api/tools/trending/route.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const API_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
version: string;
|
||||||
|
timestamp: string;
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
pagination?: {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tools/trending
|
||||||
|
* Get trending tools based on a composite score of downloads, ratings, and likes
|
||||||
|
*
|
||||||
|
* Trending score = (normalized_downloads * 0.4) + (average_rating * 0.3) + (like_count * 0.2) + (recency * 0.1)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest): Promise<NextResponse<ApiResponse>> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit')) || 20));
|
||||||
|
const offset = Math.max(0, Number(searchParams.get('offset')) || 0);
|
||||||
|
const category = searchParams.get('category');
|
||||||
|
const period = searchParams.get('period') || 'week'; // 'day', 'week', 'month', 'all'
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
importHealth: 'HEALTHY',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
where.package = { category };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date cutoff for recency boost
|
||||||
|
const now = new Date();
|
||||||
|
let recencyPeriodMs: number;
|
||||||
|
switch (period) {
|
||||||
|
case 'day':
|
||||||
|
recencyPeriodMs = 24 * 60 * 60 * 1000;
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
recencyPeriodMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
recencyPeriodMs = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
recencyPeriodMs = 365 * 24 * 60 * 60 * 1000; // 1 year
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch tools with package data
|
||||||
|
// For trending, we order by a combination of factors
|
||||||
|
const tools = await prisma.tool.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
package: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
npmPackageName: true,
|
||||||
|
npmVersion: true,
|
||||||
|
npmDescription: true,
|
||||||
|
category: true,
|
||||||
|
isOfficial: true,
|
||||||
|
npmDownloadsLastMonth: true,
|
||||||
|
githubStars: true,
|
||||||
|
tier: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
// Primary: quality score (includes downloads)
|
||||||
|
{ qualityScore: 'desc' },
|
||||||
|
// Secondary: average rating
|
||||||
|
{ averageRating: 'desc' },
|
||||||
|
// Tertiary: like count
|
||||||
|
{ likeCount: 'desc' },
|
||||||
|
// Finally: recency
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
],
|
||||||
|
take: limit + 1,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMore = tools.length > limit;
|
||||||
|
const actualTools = hasMore ? tools.slice(0, limit) : tools;
|
||||||
|
|
||||||
|
// Calculate trending scores for display
|
||||||
|
const maxDownloads = Math.max(
|
||||||
|
...actualTools.map((t) => t.package.npmDownloadsLastMonth || 0),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const maxLikes = Math.max(...actualTools.map((t) => t.likeCount), 1);
|
||||||
|
|
||||||
|
const toolsWithScores = actualTools.map((tool) => {
|
||||||
|
const downloads = tool.package.npmDownloadsLastMonth || 0;
|
||||||
|
const rating = tool.averageRating ? Number(tool.averageRating) : 0;
|
||||||
|
const likes = tool.likeCount;
|
||||||
|
const ageMs = now.getTime() - tool.createdAt.getTime();
|
||||||
|
const recencyScore = Math.max(0, 1 - ageMs / recencyPeriodMs); // 0-1, higher for newer
|
||||||
|
|
||||||
|
// Trending score calculation
|
||||||
|
const trendingScore =
|
||||||
|
(downloads / maxDownloads) * 0.4 +
|
||||||
|
(rating / 5) * 0.3 +
|
||||||
|
(likes / maxLikes) * 0.2 +
|
||||||
|
recencyScore * 0.1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
qualityScore: tool.qualityScore ? Number(tool.qualityScore) : null,
|
||||||
|
averageRating: tool.averageRating ? Number(tool.averageRating) : null,
|
||||||
|
ratingCount: tool.ratingCount,
|
||||||
|
reviewCount: tool.reviewCount,
|
||||||
|
likeCount: tool.likeCount,
|
||||||
|
importHealth: tool.importHealth,
|
||||||
|
executionHealth: tool.executionHealth,
|
||||||
|
trendingScore: Math.round(trendingScore * 100) / 100,
|
||||||
|
createdAt: tool.createdAt.toISOString(),
|
||||||
|
package: {
|
||||||
|
id: tool.package.id,
|
||||||
|
npmPackageName: tool.package.npmPackageName,
|
||||||
|
npmVersion: tool.package.npmVersion,
|
||||||
|
npmDescription: tool.package.npmDescription,
|
||||||
|
category: tool.package.category,
|
||||||
|
isOfficial: tool.package.isOfficial,
|
||||||
|
npmDownloadsLastMonth: tool.package.npmDownloadsLastMonth,
|
||||||
|
githubStars: tool.package.githubStars,
|
||||||
|
tier: tool.package.tier,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by trending score
|
||||||
|
toolsWithScores.sort((a, b) => b.trendingScore - a.trendingScore);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: toolsWithScores,
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Error] GET /api/tools/trending:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INTERNAL_ERROR', message: 'Failed to get trending tools' },
|
||||||
|
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
apps/web/src/app/docs/api/agents/page.tsx
Normal file
204
apps/web/src/app/docs/api/agents/page.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||||
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Agents API | TPMJS Docs',
|
||||||
|
description: 'API reference for TPMJS agents endpoints',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentsApiPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Agents API</h1>
|
||||||
|
<p className="text-foreground-secondary text-lg">
|
||||||
|
The Agents API allows you to manage and interact with AI agents that can use TPMJS tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List User Agents */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>List Agents</CardTitle>
|
||||||
|
<CardDescription>GET /api/agents</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Retrieve a list of your agents. Requires authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl -H "Authorization: Bearer YOUR_API_KEY" \\
|
||||||
|
"https://tpmjs.com/api/agents"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Response</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "clx123abc",
|
||||||
|
"uid": "my-assistant",
|
||||||
|
"name": "My Assistant",
|
||||||
|
"description": "A helpful AI assistant",
|
||||||
|
"provider": "ANTHROPIC",
|
||||||
|
"modelId": "claude-3-5-sonnet-20241022",
|
||||||
|
"isPublic": true,
|
||||||
|
"likeCount": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Agent */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Agent</CardTitle>
|
||||||
|
<CardDescription>POST /api/agents</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Create a new AI agent with specified configuration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"name": "My Assistant",
|
||||||
|
"description": "A helpful AI assistant",
|
||||||
|
"uid": "my-assistant",
|
||||||
|
"provider": "ANTHROPIC",
|
||||||
|
"modelId": "claude-3-5-sonnet-20241022",
|
||||||
|
"systemPrompt": "You are a helpful assistant...",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"isPublic": true
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">TypeScript Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`const response = await fetch('/api/agents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_API_KEY'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'My Assistant',
|
||||||
|
uid: 'my-assistant',
|
||||||
|
provider: 'ANTHROPIC',
|
||||||
|
modelId: 'claude-3-5-sonnet-20241022'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: agent } = await response.json();`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Chat with Agent */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Chat with Agent</CardTitle>
|
||||||
|
<CardDescription>POST /api/agents/:uid/chat</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Send a message to an agent and receive a streaming response. The agent can use any tools
|
||||||
|
attached to it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"message": "What's the weather in San Francisco?",
|
||||||
|
"conversationId": "conv-123" // optional
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">TypeScript Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`const response = await fetch('/api/agents/my-assistant/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: 'What is the weather in San Francisco?'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle SSE stream
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const text = decoder.decode(value);
|
||||||
|
console.log(text);
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Tool to Agent */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Tool to Agent</CardTitle>
|
||||||
|
<CardDescription>POST /api/agents/:uid/tools</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Add a tool to an agent's available tools.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"toolId": "clx123abc"
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Collection to Agent */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Collection to Agent</CardTitle>
|
||||||
|
<CardDescription>POST /api/agents/:uid/collections</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">Add all tools from a collection to an agent.</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"collectionId": "clx456def"
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
apps/web/src/app/docs/api/authentication/page.tsx
Normal file
316
apps/web/src/app/docs/api/authentication/page.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||||
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Authentication | TPMJS Docs',
|
||||||
|
description: 'Authentication guide for the TPMJS API',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthenticationPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Authentication</h1>
|
||||||
|
<p className="text-foreground-secondary text-lg">
|
||||||
|
Learn how to authenticate with the TPMJS API to access protected endpoints.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Overview</CardTitle>
|
||||||
|
<CardDescription>Understanding TPMJS authentication</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
TPMJS uses API keys for authentication. Some endpoints are public and don't require
|
||||||
|
authentication, while others require a valid API key to access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Public Endpoints (No Auth Required)</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<code>GET /api/tools</code> - List and search tools
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>GET /api/tools/:id</code> - Get tool details
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>GET /api/tools/trending</code> - Get trending tools
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>GET /api/collections</code> - List public collections
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>GET /api/collections/:uid</code> - Get collection details
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Protected Endpoints (Auth Required)</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<code>POST /api/tools/:id/rate</code> - Rate a tool
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>POST /api/tools/:id/reviews</code> - Write a review
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>POST /api/collections</code> - Create a collection
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>POST /api/agents</code> - Create an agent
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>GET /api/agents</code> - List your agents
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Getting an API Key */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Getting an API Key</CardTitle>
|
||||||
|
<CardDescription>How to obtain your API key</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
To get an API key, you need to create a TPMJS account and generate a key from your
|
||||||
|
dashboard.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-foreground-secondary">
|
||||||
|
<li>Sign up or log in at tpmjs.com</li>
|
||||||
|
<li>Navigate to Settings → API Keys</li>
|
||||||
|
<li>Click "Generate New Key"</li>
|
||||||
|
<li>Copy your key and store it securely</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
|
||||||
|
<p className="text-amber-600 dark:text-amber-400 font-medium">Important</p>
|
||||||
|
<p className="text-foreground-secondary text-sm mt-1">
|
||||||
|
Your API key is displayed only once when created. Make sure to copy and store it
|
||||||
|
securely. If you lose it, you'll need to generate a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Using Your API Key */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Using Your API Key</CardTitle>
|
||||||
|
<CardDescription>How to authenticate requests</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Include your API key in the <code>Authorization</code> header using the Bearer scheme.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Header Format</h4>
|
||||||
|
<CodeBlock code={`Authorization: Bearer YOUR_API_KEY`} language="text" showCopy={true} />
|
||||||
|
|
||||||
|
<h4 className="font-semibold">cURL Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl -H "Authorization: Bearer YOUR_API_KEY" \\
|
||||||
|
"https://tpmjs.com/api/agents"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">JavaScript/TypeScript Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`const API_KEY = process.env.TPMJS_API_KEY;
|
||||||
|
|
||||||
|
const response = await fetch('https://tpmjs.com/api/agents', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': \`Bearer \${API_KEY}\`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Python Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import os
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API_KEY = os.environ.get('TPMJS_API_KEY')
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
'https://tpmjs.com/api/agents',
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {API_KEY}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()`}
|
||||||
|
language="python"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error Responses */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Authentication Errors</CardTitle>
|
||||||
|
<CardDescription>Common authentication error responses</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<h4 className="font-semibold">401 Unauthorized</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mb-2">
|
||||||
|
Returned when no API key is provided or the key is invalid.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "UNAUTHORIZED",
|
||||||
|
"message": "Authentication required. Please provide a valid API key."
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">403 Forbidden</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mb-2">
|
||||||
|
Returned when the API key is valid but lacks permission for the requested action.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "FORBIDDEN",
|
||||||
|
"message": "You don't have permission to access this resource."
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">429 Rate Limited</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mb-2">
|
||||||
|
Returned when you've exceeded the rate limit for your API key.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "RATE_LIMITED",
|
||||||
|
"message": "Too many requests. Please try again in 60 seconds."
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rate Limits */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rate Limits</CardTitle>
|
||||||
|
<CardDescription>API request limits</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
TPMJS enforces rate limits to ensure fair usage and protect the API from abuse.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 pr-4">Tier</th>
|
||||||
|
<th className="text-left py-2 pr-4">Requests/min</th>
|
||||||
|
<th className="text-left py-2">Requests/day</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Free</td>
|
||||||
|
<td className="py-2 pr-4">60</td>
|
||||||
|
<td className="py-2">1,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Pro</td>
|
||||||
|
<td className="py-2 pr-4">300</td>
|
||||||
|
<td className="py-2">10,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4">Enterprise</td>
|
||||||
|
<td className="py-2 pr-4">Custom</td>
|
||||||
|
<td className="py-2">Custom</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Rate Limit Headers</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mb-2">
|
||||||
|
Every response includes rate limit information in headers:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`X-RateLimit-Limit: 60
|
||||||
|
X-RateLimit-Remaining: 45
|
||||||
|
X-RateLimit-Reset: 1704067200`}
|
||||||
|
language="text"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Best Practices */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security Best Practices</CardTitle>
|
||||||
|
<CardDescription>Keep your API key secure</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<strong>Never commit API keys to version control.</strong> Use environment variables
|
||||||
|
instead.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Rotate keys regularly.</strong> Generate new keys periodically and revoke old
|
||||||
|
ones.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Use separate keys for development and production.</strong> This limits the
|
||||||
|
impact if a key is compromised.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Monitor usage.</strong> Check your API usage in the dashboard to detect
|
||||||
|
unusual activity.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Revoke compromised keys immediately.</strong> If you suspect a key has been
|
||||||
|
exposed, revoke it and generate a new one.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Environment Variables Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`# .env.local (add to .gitignore)
|
||||||
|
TPMJS_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# In your code
|
||||||
|
const apiKey = process.env.TPMJS_API_KEY;`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
359
apps/web/src/app/docs/api/collections/page.tsx
Normal file
359
apps/web/src/app/docs/api/collections/page.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||||
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Collections API | TPMJS Docs',
|
||||||
|
description: 'API reference for TPMJS collections endpoints',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CollectionsApiPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Collections API</h1>
|
||||||
|
<p className="text-foreground-secondary text-lg">
|
||||||
|
The Collections API allows you to create and manage curated sets of tools for specific use
|
||||||
|
cases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Collections */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>List Collections</CardTitle>
|
||||||
|
<CardDescription>GET /api/collections</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Retrieve a paginated list of public collections with optional filtering.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Query Parameters</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<code>limit</code> - Number of results (default: 20, max: 50)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>offset</code> - Pagination offset
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>q</code> - Search query
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl "https://tpmjs.com/api/collections?limit=10"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Response</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "clx123abc",
|
||||||
|
"uid": "web-scraping-toolkit",
|
||||||
|
"name": "Web Scraping Toolkit",
|
||||||
|
"description": "Essential tools for web scraping and data extraction",
|
||||||
|
"isPublic": true,
|
||||||
|
"toolCount": 5,
|
||||||
|
"likeCount": 42,
|
||||||
|
"owner": {
|
||||||
|
"id": "user123",
|
||||||
|
"name": "John Doe",
|
||||||
|
"username": "johndoe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 0,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Get Collection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Get Collection</CardTitle>
|
||||||
|
<CardDescription>GET /api/collections/:uid</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Retrieve detailed information about a specific collection, including its tools.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Path Parameters</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<code>uid</code> - Collection unique identifier or slug
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl "https://tpmjs.com/api/collections/web-scraping-toolkit"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Response</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "clx123abc",
|
||||||
|
"uid": "web-scraping-toolkit",
|
||||||
|
"name": "Web Scraping Toolkit",
|
||||||
|
"description": "Essential tools for web scraping and data extraction",
|
||||||
|
"isPublic": true,
|
||||||
|
"toolCount": 5,
|
||||||
|
"likeCount": 42,
|
||||||
|
"createdAt": "2024-01-15T10:30:00Z",
|
||||||
|
"updatedAt": "2024-01-20T14:45:00Z",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"id": "tool123",
|
||||||
|
"name": "webScraperTool",
|
||||||
|
"description": "Scrape content from web pages"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner": {
|
||||||
|
"id": "user123",
|
||||||
|
"name": "John Doe",
|
||||||
|
"username": "johndoe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Collection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Collection</CardTitle>
|
||||||
|
<CardDescription>POST /api/collections</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Create a new collection. Requires authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"name": "My Data Tools",
|
||||||
|
"uid": "my-data-tools",
|
||||||
|
"description": "A collection of data processing tools",
|
||||||
|
"isPublic": true
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">TypeScript Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`const response = await fetch('/api/collections', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_API_KEY'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'My Data Tools',
|
||||||
|
uid: 'my-data-tools',
|
||||||
|
description: 'A collection of data processing tools',
|
||||||
|
isPublic: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: collection } = await response.json();
|
||||||
|
console.log('Created collection:', collection.id);`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Update Collection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Update Collection</CardTitle>
|
||||||
|
<CardDescription>PATCH /api/collections/:uid</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Update an existing collection. Requires authentication and ownership.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"name": "Updated Collection Name",
|
||||||
|
"description": "Updated description",
|
||||||
|
"isPublic": false
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl -X PATCH "https://tpmjs.com/api/collections/my-data-tools" \\
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"name": "Updated Collection Name"}'`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Tool to Collection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Tool to Collection</CardTitle>
|
||||||
|
<CardDescription>POST /api/collections/:uid/tools</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Add a tool to a collection. Requires authentication and ownership.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"toolId": "clx456def"
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">TypeScript Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`const response = await fetch('/api/collections/my-data-tools/tools', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_API_KEY'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
toolId: 'clx456def'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Tool added to collection');
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Remove Tool from Collection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Remove Tool from Collection</CardTitle>
|
||||||
|
<CardDescription>DELETE /api/collections/:uid/tools/:toolId</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Remove a tool from a collection. Requires authentication and ownership.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl -X DELETE "https://tpmjs.com/api/collections/my-data-tools/tools/clx456def" \\
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Response</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": true,
|
||||||
|
"message": "Tool removed from collection"
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Like Collection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Like Collection</CardTitle>
|
||||||
|
<CardDescription>POST /api/collections/:uid/like</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Like or unlike a collection. Requires authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">TypeScript Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`// Like a collection
|
||||||
|
const response = await fetch('/api/collections/web-scraping-toolkit/like', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer YOUR_API_KEY'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await response.json();
|
||||||
|
console.log('Liked:', data.liked); // true if now liked, false if unliked`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Delete Collection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Delete Collection</CardTitle>
|
||||||
|
<CardDescription>DELETE /api/collections/:uid</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Delete a collection. Requires authentication and ownership. This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl -X DELETE "https://tpmjs.com/api/collections/my-data-tools" \\
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Response</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": true,
|
||||||
|
"message": "Collection deleted successfully"
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
apps/web/src/app/docs/api/tools/page.tsx
Normal file
220
apps/web/src/app/docs/api/tools/page.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||||
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Tools API | TPMJS Docs',
|
||||||
|
description: 'API reference for TPMJS tools endpoints',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ToolsApiPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Tools API</h1>
|
||||||
|
<p className="text-foreground-secondary text-lg">
|
||||||
|
The Tools API allows you to search, discover, and execute TPMJS tools programmatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Tools */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>List Tools</CardTitle>
|
||||||
|
<CardDescription>GET /api/tools</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Retrieve a paginated list of tools with optional filtering.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Query Parameters</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<code>limit</code> - Number of results (default: 20, max: 50)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>offset</code> - Pagination offset
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>category</code> - Filter by category
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>q</code> - Search query
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl "https://tpmjs.com/api/tools?category=communication&limit=10"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Response</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "clx123abc",
|
||||||
|
"name": "discordPostTool",
|
||||||
|
"description": "Post messages to Discord channels",
|
||||||
|
"qualityScore": 0.85,
|
||||||
|
"package": {
|
||||||
|
"npmPackageName": "@tpmjs/discord-post",
|
||||||
|
"category": "communication"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 0,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Get Tool by ID */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Get Tool</CardTitle>
|
||||||
|
<CardDescription>GET /api/tools/:id</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Retrieve detailed information about a specific tool.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Path Parameters</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<code>id</code> - Tool ID or package/tool slug
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl "https://tpmjs.com/api/tools/@tpmjs/discord-post/discordPostTool"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Execute Tool */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Execute Tool</CardTitle>
|
||||||
|
<CardDescription>POST /api/tools/execute/:slug</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Execute a tool with an AI agent. Returns streaming SSE response.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"prompt": "Send a hello message to #general channel",
|
||||||
|
"parameters": {
|
||||||
|
"channel": "#general"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">TypeScript Example</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { executeToolCall } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
const result = await executeToolCall({
|
||||||
|
toolId: '@tpmjs/discord-post/discordPostTool',
|
||||||
|
prompt: 'Send hello to #general',
|
||||||
|
apiKey: 'your-api-key',
|
||||||
|
onChunk: (text) => console.log(text),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(result.output);`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Trending Tools */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Trending Tools</CardTitle>
|
||||||
|
<CardDescription>GET /api/tools/trending</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Get trending tools based on downloads, ratings, and activity.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Query Parameters</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
|
||||||
|
<li>
|
||||||
|
<code>period</code> - Time period: day, week, month, all (default: week)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>category</code> - Filter by category
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>limit</code> - Number of results (default: 20)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Request</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`curl "https://tpmjs.com/api/tools/trending?period=week&limit=10"`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rate Tool */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rate Tool</CardTitle>
|
||||||
|
<CardDescription>POST /api/tools/:id/rate</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Rate a tool from 1-5 stars. Requires authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Request Body</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"rating": 5
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Example Response</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"userRating": 5,
|
||||||
|
"averageRating": 4.5,
|
||||||
|
"ratingCount": 42
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
417
apps/web/src/app/docs/quickstart/page.tsx
Normal file
417
apps/web/src/app/docs/quickstart/page.tsx
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||||
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Quickstart Guide | TPMJS Docs',
|
||||||
|
description: 'Get started with TPMJS in under 5 minutes',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QuickstartPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Quickstart Guide</h1>
|
||||||
|
<p className="text-foreground-secondary text-lg">
|
||||||
|
Get your AI agent access to thousands of tools in under 5 minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prerequisites */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Prerequisites</CardTitle>
|
||||||
|
<CardDescription>What you need before starting</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
|
||||||
|
<li>Node.js 18 or later</li>
|
||||||
|
<li>npm, pnpm, or yarn</li>
|
||||||
|
<li>
|
||||||
|
An AI provider API key (OpenAI, Anthropic, or{' '}
|
||||||
|
<a
|
||||||
|
href="https://sdk.vercel.ai/providers/ai-sdk-providers"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
any AI SDK provider
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 1: Installation */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 1: Install the SDK</CardTitle>
|
||||||
|
<CardDescription>Add TPMJS packages to your project</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Install the TPMJS SDK packages alongside the Vercel AI SDK:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">npm</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code="npm install @tpmjs/registry-search @tpmjs/registry-execute ai zod"
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">pnpm</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code="pnpm add @tpmjs/registry-search @tpmjs/registry-execute ai zod"
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">yarn</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code="yarn add @tpmjs/registry-search @tpmjs/registry-execute ai zod"
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-foreground-secondary text-sm mt-4">
|
||||||
|
Also install your preferred AI provider SDK:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`# Anthropic (Claude)
|
||||||
|
npm install @ai-sdk/anthropic
|
||||||
|
|
||||||
|
# OpenAI (GPT-4, etc.)
|
||||||
|
npm install @ai-sdk/openai
|
||||||
|
|
||||||
|
# Google (Gemini)
|
||||||
|
npm install @ai-sdk/google`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 2: Basic Usage */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 2: Add Tools to Your Agent</CardTitle>
|
||||||
|
<CardDescription>Give your agent access to the TPMJS registry</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Import the TPMJS tools and add them to your AI agent:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { streamText } from 'ai';
|
||||||
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||||
|
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
// Create a streaming text generation with tools
|
||||||
|
const result = await streamText({
|
||||||
|
model: anthropic('claude-sonnet-4-20250514'),
|
||||||
|
tools: {
|
||||||
|
registrySearch: registrySearchTool,
|
||||||
|
registryExecute: registryExecuteTool,
|
||||||
|
},
|
||||||
|
system: \`You are a helpful assistant with access to thousands of tools
|
||||||
|
via the TPMJS registry. Use registrySearch to find tools for any task,
|
||||||
|
then registryExecute to run them.\`,
|
||||||
|
prompt: 'Search for weather tools and get the current weather in Tokyo',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle the streaming response
|
||||||
|
for await (const chunk of result.textStream) {
|
||||||
|
process.stdout.write(chunk);
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 3: Understanding the Tools */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 3: Understanding the Tools</CardTitle>
|
||||||
|
<CardDescription>How registrySearch and registryExecute work</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-2">registrySearchTool</h4>
|
||||||
|
<p className="text-foreground-secondary mb-2">
|
||||||
|
Searches the TPMJS registry to find tools for any task. Returns metadata including the
|
||||||
|
toolId needed for execution.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`// The agent will call this tool to search for tools
|
||||||
|
{
|
||||||
|
"query": "web scraping",
|
||||||
|
"category": "web-scraping", // optional
|
||||||
|
"limit": 5 // optional
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-2">registryExecuteTool</h4>
|
||||||
|
<p className="text-foreground-secondary mb-2">
|
||||||
|
Executes any tool from the registry by its toolId. Tools run in a secure sandbox—no
|
||||||
|
local installation required.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`// The agent will call this tool to execute a tool
|
||||||
|
{
|
||||||
|
"toolId": "@firecrawl/ai-sdk::scrapeTool",
|
||||||
|
"params": { "url": "https://example.com" },
|
||||||
|
"env": { "FIRECRAWL_API_KEY": "..." } // optional
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 4: Passing API Keys */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 4: Configure API Keys</CardTitle>
|
||||||
|
<CardDescription>Pre-configure API keys for tools that need them</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Many tools require API keys (e.g., Firecrawl, Exa, Tavily). Create a wrapper to
|
||||||
|
auto-inject your keys:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Create tools.ts</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { tool } from 'ai';
|
||||||
|
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
// Pre-configure your API keys
|
||||||
|
const API_KEYS: Record<string, string> = {
|
||||||
|
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY!,
|
||||||
|
EXA_API_KEY: process.env.EXA_API_KEY!,
|
||||||
|
TAVILY_API_KEY: process.env.TAVILY_API_KEY!,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a wrapped version that auto-injects keys
|
||||||
|
export const registryExecute = tool({
|
||||||
|
description: registryExecuteTool.description,
|
||||||
|
parameters: registryExecuteTool.parameters,
|
||||||
|
execute: async ({ toolId, params }) => {
|
||||||
|
return registryExecuteTool.execute({ toolId, params, env: API_KEYS });
|
||||||
|
},
|
||||||
|
});`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Use the wrapped tool</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { registrySearchTool } from '@tpmjs/registry-search';
|
||||||
|
import { registryExecute } from './tools';
|
||||||
|
|
||||||
|
const result = await streamText({
|
||||||
|
model: anthropic('claude-sonnet-4-20250514'),
|
||||||
|
tools: {
|
||||||
|
registrySearch: registrySearchTool,
|
||||||
|
registryExecute, // Uses your pre-configured keys
|
||||||
|
},
|
||||||
|
prompt: 'Scrape https://example.com',
|
||||||
|
});`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 5: Real-World Example */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 5: Complete Example</CardTitle>
|
||||||
|
<CardDescription>A full working example you can copy</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<h4 className="font-semibold">agent.ts</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { streamText, generateText } from 'ai';
|
||||||
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||||
|
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
// Configure environment variables
|
||||||
|
const API_KEYS: Record<string, string> = {
|
||||||
|
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || '',
|
||||||
|
EXA_API_KEY: process.env.EXA_API_KEY || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the wrapped execute tool
|
||||||
|
const registryExecute = {
|
||||||
|
...registryExecuteTool,
|
||||||
|
execute: async (args: Parameters<typeof registryExecuteTool.execute>[0]) => {
|
||||||
|
return registryExecuteTool.execute({
|
||||||
|
...args,
|
||||||
|
env: { ...API_KEYS, ...args.env },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main agent function
|
||||||
|
async function runAgent(prompt: string) {
|
||||||
|
const result = await generateText({
|
||||||
|
model: anthropic('claude-sonnet-4-20250514'),
|
||||||
|
tools: {
|
||||||
|
registrySearch: registrySearchTool,
|
||||||
|
registryExecute,
|
||||||
|
},
|
||||||
|
maxSteps: 5, // Allow multiple tool calls
|
||||||
|
system: \`You are a helpful assistant with access to thousands of tools.
|
||||||
|
|
||||||
|
Available tool workflow:
|
||||||
|
1. Use registrySearch to find tools for your task
|
||||||
|
2. Use registryExecute to run the tools you find
|
||||||
|
3. Synthesize the results into a helpful response
|
||||||
|
|
||||||
|
Always search for tools first before attempting to execute them.\`,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage
|
||||||
|
const response = await runAgent(
|
||||||
|
'Search for web scraping tools, then scrape https://example.com and summarize it'
|
||||||
|
);
|
||||||
|
console.log(response);`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Next Steps */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Next Steps</CardTitle>
|
||||||
|
<CardDescription>Continue learning about TPMJS</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li>
|
||||||
|
<Link href="/docs/sdk" className="text-primary hover:underline font-medium">
|
||||||
|
SDK Documentation →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">
|
||||||
|
Detailed reference for all SDK functions and options
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/docs/api/tools" className="text-primary hover:underline font-medium">
|
||||||
|
Tools API Reference →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">
|
||||||
|
REST API endpoints for searching and executing tools
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/docs/api/agents" className="text-primary hover:underline font-medium">
|
||||||
|
Agents API Reference →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">
|
||||||
|
Create and manage AI agents with TPMJS tools
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/publish" className="text-primary hover:underline font-medium">
|
||||||
|
Publishing Guide →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">
|
||||||
|
Learn how to publish your own tools to TPMJS
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/" className="text-primary hover:underline font-medium">
|
||||||
|
Browse Tool Registry →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">
|
||||||
|
Explore available tools and find ones for your use case
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Troubleshooting */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Common Issues</CardTitle>
|
||||||
|
<CardDescription>Solutions to frequently encountered problems</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-foreground">
|
||||||
|
Tool execution fails with "missing API key"
|
||||||
|
</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mt-1">
|
||||||
|
Check that you're passing the required environment variables in the{' '}
|
||||||
|
<code className="text-primary">env</code> parameter of registryExecute.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`registryExecute.execute({
|
||||||
|
toolId: '@firecrawl/ai-sdk::scrapeTool',
|
||||||
|
params: { url: 'https://example.com' },
|
||||||
|
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY } // Required!
|
||||||
|
})`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-foreground">Tool not found in search results</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mt-1">
|
||||||
|
Try broader search terms or check the tool category. You can also browse the full
|
||||||
|
registry at{' '}
|
||||||
|
<Link href="/" className="text-primary hover:underline">
|
||||||
|
tpmjs.com
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-foreground">Agent not using the tools</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mt-1">
|
||||||
|
Make sure your system prompt clearly instructs the agent to use registrySearch
|
||||||
|
first, then registryExecute. Also ensure{' '}
|
||||||
|
<code className="text-primary">maxSteps</code> is set high enough (default is 1).
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`const result = await generateText({
|
||||||
|
model: anthropic('claude-sonnet-4-20250514'),
|
||||||
|
tools: { registrySearch, registryExecute },
|
||||||
|
maxSteps: 5, // Allow multiple tool calls
|
||||||
|
system: 'Use registrySearch to find tools, then registryExecute to run them.',
|
||||||
|
prompt: 'Your prompt here',
|
||||||
|
});`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
651
apps/web/src/app/docs/sdk/page.tsx
Normal file
651
apps/web/src/app/docs/sdk/page.tsx
Normal file
|
|
@ -0,0 +1,651 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||||
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'SDK Reference | TPMJS Docs',
|
||||||
|
description: 'Complete reference for the TPMJS SDK packages',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SdkPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-4">SDK Reference</h1>
|
||||||
|
<p className="text-foreground-secondary text-lg">
|
||||||
|
Complete reference for the TPMJS SDK packages: @tpmjs/registry-search and
|
||||||
|
@tpmjs/registry-execute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Overview</CardTitle>
|
||||||
|
<CardDescription>TPMJS SDK packages</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
The TPMJS SDK consists of two packages that give your AI agent access to the tool
|
||||||
|
registry:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-semibold text-foreground">@tpmjs/registry-search</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mt-1">
|
||||||
|
Search the TPMJS registry to find tools for any task
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-semibold text-foreground">@tpmjs/registry-execute</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mt-1">
|
||||||
|
Execute any tool from the registry in a secure sandbox
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-semibold mt-6">Installation</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code="npm install @tpmjs/registry-search @tpmjs/registry-execute"
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Peer Dependencies</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm">
|
||||||
|
Both packages require <code className="text-primary">ai</code> (Vercel AI SDK) and{' '}
|
||||||
|
<code className="text-primary">zod</code> as peer dependencies.
|
||||||
|
</p>
|
||||||
|
<CodeBlock code="npm install ai zod" language="bash" showCopy={true} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* registrySearchTool */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>registrySearchTool</CardTitle>
|
||||||
|
<CardDescription>@tpmjs/registry-search</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
An AI SDK tool that searches the TPMJS registry for tools matching a query.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Import</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { registrySearchTool } from '@tpmjs/registry-search';`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Basic Usage</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { generateText } from 'ai';
|
||||||
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model: anthropic('claude-sonnet-4-20250514'),
|
||||||
|
tools: { registrySearch: registrySearchTool },
|
||||||
|
prompt: 'Find tools for web scraping',
|
||||||
|
});`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Parameters</h4>
|
||||||
|
<div className="overflow-x-auto border border-border rounded-lg">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface">
|
||||||
|
<th className="text-left py-3 px-4">Parameter</th>
|
||||||
|
<th className="text-left py-3 px-4">Type</th>
|
||||||
|
<th className="text-left py-3 px-4">Required</th>
|
||||||
|
<th className="text-left py-3 px-4">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">query</td>
|
||||||
|
<td className="py-3 px-4 font-mono">string</td>
|
||||||
|
<td className="py-3 px-4">Yes</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">
|
||||||
|
Search query (keywords, tool names, descriptions)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">category</td>
|
||||||
|
<td className="py-3 px-4 font-mono">string</td>
|
||||||
|
<td className="py-3 px-4">No</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">Filter by category</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">limit</td>
|
||||||
|
<td className="py-3 px-4 font-mono">number</td>
|
||||||
|
<td className="py-3 px-4">No</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">
|
||||||
|
Max results (1-20, default 5)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Return Value</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`interface SearchResult {
|
||||||
|
query: string;
|
||||||
|
matchCount: number;
|
||||||
|
tools: Array<{
|
||||||
|
toolId: string; // Format: package::name
|
||||||
|
name: string; // Tool export name
|
||||||
|
package: string; // npm package name
|
||||||
|
description: string; // Tool description
|
||||||
|
category: string; // Tool category
|
||||||
|
requiredEnvVars: string[]; // Required API keys
|
||||||
|
healthStatus: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN';
|
||||||
|
qualityScore: number; // 0.00 - 1.00
|
||||||
|
}>;
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Available Categories</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm mb-2">
|
||||||
|
Filter results by category to narrow down your search:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
|
{[
|
||||||
|
'web-scraping',
|
||||||
|
'search-engines',
|
||||||
|
'ai-models',
|
||||||
|
'data-processing',
|
||||||
|
'communication',
|
||||||
|
'file-management',
|
||||||
|
'code-execution',
|
||||||
|
'database',
|
||||||
|
'calendar',
|
||||||
|
'e-commerce',
|
||||||
|
'finance',
|
||||||
|
'social-media',
|
||||||
|
'weather',
|
||||||
|
'maps',
|
||||||
|
'translation',
|
||||||
|
'image-processing',
|
||||||
|
'audio-processing',
|
||||||
|
'video-processing',
|
||||||
|
'utilities',
|
||||||
|
'other',
|
||||||
|
].map((cat) => (
|
||||||
|
<code key={cat} className="px-2 py-1 bg-surface border border-border rounded">
|
||||||
|
{cat}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* registryExecuteTool */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>registryExecuteTool</CardTitle>
|
||||||
|
<CardDescription>@tpmjs/registry-execute</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
An AI SDK tool that executes any tool from the TPMJS registry in a secure sandbox.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Import</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { registryExecuteTool } from '@tpmjs/registry-execute';`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Basic Usage</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { generateText } from 'ai';
|
||||||
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model: anthropic('claude-sonnet-4-20250514'),
|
||||||
|
tools: { registryExecute: registryExecuteTool },
|
||||||
|
prompt: 'Execute the hello world tool',
|
||||||
|
});`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Parameters</h4>
|
||||||
|
<div className="overflow-x-auto border border-border rounded-lg">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface">
|
||||||
|
<th className="text-left py-3 px-4">Parameter</th>
|
||||||
|
<th className="text-left py-3 px-4">Type</th>
|
||||||
|
<th className="text-left py-3 px-4">Required</th>
|
||||||
|
<th className="text-left py-3 px-4">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">toolId</td>
|
||||||
|
<td className="py-3 px-4 font-mono">string</td>
|
||||||
|
<td className="py-3 px-4">Yes</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">
|
||||||
|
Tool identifier in format: package::name
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">params</td>
|
||||||
|
<td className="py-3 px-4 font-mono">object</td>
|
||||||
|
<td className="py-3 px-4">Yes</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">
|
||||||
|
Parameters to pass to the tool
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">env</td>
|
||||||
|
<td className="py-3 px-4 font-mono">object</td>
|
||||||
|
<td className="py-3 px-4">No</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">
|
||||||
|
Environment variables (API keys) to pass to the tool
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Return Value</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`interface ExecuteResult {
|
||||||
|
toolId: string; // The executed tool ID
|
||||||
|
executionTimeMs: number; // Execution time in milliseconds
|
||||||
|
output: unknown; // Tool output (varies by tool)
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Passing API Keys</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`// Direct usage with env parameter
|
||||||
|
const result = await registryExecuteTool.execute({
|
||||||
|
toolId: '@firecrawl/ai-sdk::scrapeTool',
|
||||||
|
params: { url: 'https://example.com' },
|
||||||
|
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY },
|
||||||
|
});`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Creating a Wrapped Execute Tool */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Creating a Wrapped Execute Tool</CardTitle>
|
||||||
|
<CardDescription>Pre-configure API keys for seamless execution</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Create a wrapper around registryExecuteTool to automatically inject your API keys:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { tool } from 'ai';
|
||||||
|
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
// Your pre-configured API keys
|
||||||
|
const API_KEYS: Record<string, string> = {
|
||||||
|
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY!,
|
||||||
|
EXA_API_KEY: process.env.EXA_API_KEY!,
|
||||||
|
TAVILY_API_KEY: process.env.TAVILY_API_KEY!,
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY!,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrapped tool with pre-configured keys
|
||||||
|
export const registryExecute = tool({
|
||||||
|
description: registryExecuteTool.description,
|
||||||
|
parameters: registryExecuteTool.parameters,
|
||||||
|
execute: async ({ toolId, params }) => {
|
||||||
|
return registryExecuteTool.execute({
|
||||||
|
toolId,
|
||||||
|
params,
|
||||||
|
env: API_KEYS,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Direct API Usage */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Direct API Usage</CardTitle>
|
||||||
|
<CardDescription>Use the SDK without AI agent integration</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Both tools can also be used directly without an AI agent:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Direct Search</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { registrySearchTool } from '@tpmjs/registry-search';
|
||||||
|
|
||||||
|
// Search for tools directly
|
||||||
|
const searchResult = await registrySearchTool.execute({
|
||||||
|
query: 'web scraping',
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Found tools:', searchResult.tools);`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Direct Execution</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
// Execute a tool directly
|
||||||
|
const result = await registryExecuteTool.execute({
|
||||||
|
toolId: '@tpmjs/hello::helloWorldTool',
|
||||||
|
params: { name: 'World' },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Result:', result.output);`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Environment Variables */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Environment Variables</CardTitle>
|
||||||
|
<CardDescription>Configure SDK behavior</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
The SDK supports the following environment variables:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border border-border rounded-lg">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface">
|
||||||
|
<th className="text-left py-3 px-4">Variable</th>
|
||||||
|
<th className="text-left py-3 px-4">Default</th>
|
||||||
|
<th className="text-left py-3 px-4">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">TPMJS_API_URL</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary">
|
||||||
|
https://tpmjs.com
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">
|
||||||
|
Base URL for the TPMJS registry API
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary">TPMJS_EXECUTOR_URL</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary">
|
||||||
|
https://executor.tpmjs.com
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-foreground-secondary">
|
||||||
|
URL for the sandbox executor service
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">Self-Hosting</h4>
|
||||||
|
<p className="text-foreground-secondary text-sm">
|
||||||
|
If you're running your own TPMJS registry, set these environment variables:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`# .env
|
||||||
|
TPMJS_API_URL=https://registry.mycompany.com
|
||||||
|
TPMJS_EXECUTOR_URL=https://executor.mycompany.com`}
|
||||||
|
language="bash"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* TypeScript Types */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>TypeScript Types</CardTitle>
|
||||||
|
<CardDescription>Type definitions exported by the SDK</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<h4 className="font-semibold">@tpmjs/registry-search</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import type {
|
||||||
|
SearchToolParams,
|
||||||
|
SearchToolResult,
|
||||||
|
ToolInfo,
|
||||||
|
} from '@tpmjs/registry-search';
|
||||||
|
|
||||||
|
// SearchToolParams
|
||||||
|
interface SearchToolParams {
|
||||||
|
query: string;
|
||||||
|
category?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchToolResult
|
||||||
|
interface SearchToolResult {
|
||||||
|
query: string;
|
||||||
|
matchCount: number;
|
||||||
|
tools: ToolInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolInfo
|
||||||
|
interface ToolInfo {
|
||||||
|
toolId: string;
|
||||||
|
name: string;
|
||||||
|
package: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
requiredEnvVars: string[];
|
||||||
|
healthStatus: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN';
|
||||||
|
qualityScore: number;
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">@tpmjs/registry-execute</h4>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import type {
|
||||||
|
ExecuteToolParams,
|
||||||
|
ExecuteToolResult,
|
||||||
|
} from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
// ExecuteToolParams
|
||||||
|
interface ExecuteToolParams {
|
||||||
|
toolId: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteToolResult
|
||||||
|
interface ExecuteToolResult {
|
||||||
|
toolId: string;
|
||||||
|
executionTimeMs: number;
|
||||||
|
output: unknown;
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error Handling */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Error Handling</CardTitle>
|
||||||
|
<CardDescription>Handle errors from the SDK</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Both SDK tools throw errors that can be caught and handled:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await registryExecuteTool.execute({
|
||||||
|
toolId: '@firecrawl/ai-sdk::scrapeTool',
|
||||||
|
params: { url: 'https://example.com' },
|
||||||
|
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY },
|
||||||
|
});
|
||||||
|
console.log('Success:', result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Tool execution failed:', error.message);
|
||||||
|
|
||||||
|
// Common error types:
|
||||||
|
// - "Tool not found: ..." - Invalid toolId
|
||||||
|
// - "Missing required env var: ..." - API key not provided
|
||||||
|
// - "Execution timeout" - Tool took too long
|
||||||
|
// - "Sandbox error: ..." - Tool runtime error
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Complete Example */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Complete Example</CardTitle>
|
||||||
|
<CardDescription>Full agent implementation with both tools</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { streamText, tool } from 'ai';
|
||||||
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||||
|
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
|
||||||
|
// Pre-configure API keys
|
||||||
|
const API_KEYS: Record<string, string> = {
|
||||||
|
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || '',
|
||||||
|
EXA_API_KEY: process.env.EXA_API_KEY || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrapped execute tool with pre-configured keys
|
||||||
|
const registryExecute = tool({
|
||||||
|
description: registryExecuteTool.description,
|
||||||
|
parameters: registryExecuteTool.parameters,
|
||||||
|
execute: async ({ toolId, params }) => {
|
||||||
|
return registryExecuteTool.execute({ toolId, params, env: API_KEYS });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// System prompt that guides the agent
|
||||||
|
const systemPrompt = \`You are a helpful assistant with access to thousands of tools.
|
||||||
|
|
||||||
|
To complete tasks, follow this workflow:
|
||||||
|
1. Use registrySearch to find relevant tools
|
||||||
|
2. Review the search results and pick the best tool
|
||||||
|
3. Use registryExecute to run the tool with appropriate parameters
|
||||||
|
4. Synthesize the results into a helpful response
|
||||||
|
|
||||||
|
Always explain what you're doing and why.\`;
|
||||||
|
|
||||||
|
// Main function
|
||||||
|
async function runAgent(userPrompt: string) {
|
||||||
|
const result = await streamText({
|
||||||
|
model: anthropic('claude-sonnet-4-20250514'),
|
||||||
|
tools: {
|
||||||
|
registrySearch: registrySearchTool,
|
||||||
|
registryExecute,
|
||||||
|
},
|
||||||
|
maxSteps: 10,
|
||||||
|
system: systemPrompt,
|
||||||
|
prompt: userPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
for await (const chunk of result.textStream) {
|
||||||
|
process.stdout.write(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\\n---');
|
||||||
|
console.log('Tool calls:', (await result.toolCalls).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
await runAgent('Find a web scraping tool and scrape https://example.com');`}
|
||||||
|
language="typescript"
|
||||||
|
showCopy={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Related Resources */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Related Resources</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li>
|
||||||
|
<Link href="/docs/quickstart" className="text-primary hover:underline font-medium">
|
||||||
|
Quickstart Guide →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">Get started in 5 minutes</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/docs/api/tools" className="text-primary hover:underline font-medium">
|
||||||
|
Tools API Reference →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">REST API for the registry</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/docs/api/authentication"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Authentication →
|
||||||
|
</Link>
|
||||||
|
<p className="text-foreground-secondary text-sm">API key management</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/tpmjs/tpmjs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
GitHub Repository →
|
||||||
|
</a>
|
||||||
|
<p className="text-foreground-secondary text-sm">Source code and issues</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,9 @@ import { useState } from 'react';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
import { BundleSize } from '~/components/BundleSize';
|
import { BundleSize } from '~/components/BundleSize';
|
||||||
import { DownloadSparkline } from '~/components/DownloadSparkline';
|
import { DownloadSparkline } from '~/components/DownloadSparkline';
|
||||||
|
import { LikeButton } from '~/components/LikeButton';
|
||||||
import { Markdown } from '~/components/Markdown';
|
import { Markdown } from '~/components/Markdown';
|
||||||
|
import { Rating } from '~/components/Rating';
|
||||||
import { ToolPlayground } from '~/components/ToolPlayground';
|
import { ToolPlayground } from '~/components/ToolPlayground';
|
||||||
|
|
||||||
interface Package {
|
interface Package {
|
||||||
|
|
@ -67,6 +69,10 @@ export interface Tool {
|
||||||
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||||
healthCheckError?: string | null;
|
healthCheckError?: string | null;
|
||||||
lastHealthCheck?: string | null;
|
lastHealthCheck?: string | null;
|
||||||
|
likeCount?: number;
|
||||||
|
averageRating?: string | null;
|
||||||
|
ratingCount?: number;
|
||||||
|
reviewCount?: number;
|
||||||
package: Package;
|
package: Package;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
@ -224,11 +230,30 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pkg.isOfficial && (
|
<div className="flex flex-col items-end gap-2">
|
||||||
<Badge variant="default" size="lg">
|
{pkg.isOfficial && (
|
||||||
Official
|
<Badge variant="default" size="lg">
|
||||||
</Badge>
|
Official
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LikeButton
|
||||||
|
entityType="tool"
|
||||||
|
entityId={tool.id}
|
||||||
|
initialCount={tool.likeCount ?? 0}
|
||||||
|
showCount={true}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Rating
|
||||||
|
toolId={tool.id}
|
||||||
|
initialAverageRating={tool.averageRating ? Number(tool.averageRating) : null}
|
||||||
|
initialRatingCount={tool.ratingCount ?? 0}
|
||||||
|
size="md"
|
||||||
|
showAverage={true}
|
||||||
|
showCount={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="secondary">{pkg.category}</Badge>
|
<Badge variant="secondary">{pkg.category}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ async function getTool(slug: string[]): Promise<Tool | null> {
|
||||||
executionHealth: tool.executionHealth ?? undefined,
|
executionHealth: tool.executionHealth ?? undefined,
|
||||||
healthCheckError: tool.healthCheckError ?? null,
|
healthCheckError: tool.healthCheckError ?? null,
|
||||||
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
|
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
|
||||||
|
likeCount: tool.likeCount,
|
||||||
|
averageRating: tool.averageRating?.toString() ?? null,
|
||||||
|
ratingCount: tool.ratingCount,
|
||||||
|
reviewCount: tool.reviewCount,
|
||||||
createdAt: tool.createdAt.toISOString(),
|
createdAt: tool.createdAt.toISOString(),
|
||||||
updatedAt: tool.updatedAt.toISOString(),
|
updatedAt: tool.updatedAt.toISOString(),
|
||||||
package: {
|
package: {
|
||||||
|
|
|
||||||
275
apps/web/src/components/Rating.tsx
Normal file
275
apps/web/src/components/Rating.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
interface RatingProps {
|
||||||
|
toolId: string;
|
||||||
|
initialRating?: number | null;
|
||||||
|
initialAverageRating?: number | null;
|
||||||
|
initialRatingCount?: number;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showAverage?: boolean;
|
||||||
|
showCount?: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
|
onRatingChange?: (
|
||||||
|
rating: number | null,
|
||||||
|
averageRating: number | null,
|
||||||
|
ratingCount: number
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Rating({
|
||||||
|
toolId,
|
||||||
|
initialRating = null,
|
||||||
|
initialAverageRating = null,
|
||||||
|
initialRatingCount = 0,
|
||||||
|
size = 'md',
|
||||||
|
showAverage = true,
|
||||||
|
showCount = true,
|
||||||
|
interactive = true,
|
||||||
|
onRatingChange,
|
||||||
|
}: RatingProps): React.ReactElement {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [userRating, setUserRating] = useState<number | null>(initialRating);
|
||||||
|
const [averageRating, setAverageRating] = useState<number | null>(initialAverageRating);
|
||||||
|
const [ratingCount, setRatingCount] = useState(initialRatingCount);
|
||||||
|
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasFetched, setHasFetched] = useState(false);
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
|
// Fetch initial rating status when user is logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFetched) return;
|
||||||
|
|
||||||
|
const fetchRatingStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tools/${toolId}/rate`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setUserRating(data.data.userRating);
|
||||||
|
setAverageRating(data.data.averageRating);
|
||||||
|
setRatingCount(data.data.ratingCount);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch rating status:', error);
|
||||||
|
} finally {
|
||||||
|
setHasFetched(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRatingStatus();
|
||||||
|
}, [toolId, hasFetched]);
|
||||||
|
|
||||||
|
// Update from props when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasFetched) {
|
||||||
|
setUserRating(initialRating);
|
||||||
|
setAverageRating(initialAverageRating);
|
||||||
|
setRatingCount(initialRatingCount);
|
||||||
|
}
|
||||||
|
}, [initialRating, initialAverageRating, initialRatingCount, hasFetched]);
|
||||||
|
|
||||||
|
const submitRating = useCallback(
|
||||||
|
async (newRating: number | null, previousRating: number | null) => {
|
||||||
|
const response = await fetch(`/api/tools/${toolId}/rate`, {
|
||||||
|
method: newRating ? 'POST' : 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: newRating ? JSON.stringify({ rating: newRating }) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setUserRating(data.data.userRating);
|
||||||
|
setAverageRating(data.data.averageRating);
|
||||||
|
setRatingCount(data.data.ratingCount);
|
||||||
|
onRatingChange?.(data.data.userRating, data.data.averageRating, data.data.ratingCount);
|
||||||
|
} else {
|
||||||
|
setUserRating(previousRating);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toolId, onRatingChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRate = useCallback(
|
||||||
|
async (rating: number) => {
|
||||||
|
if (!session) {
|
||||||
|
setShowTooltip(true);
|
||||||
|
setTimeout(() => setShowTooltip(false), 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !interactive) return;
|
||||||
|
|
||||||
|
const newRating = userRating === rating ? null : rating;
|
||||||
|
const previousRating = userRating;
|
||||||
|
|
||||||
|
setUserRating(newRating);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitRating(newRating, previousRating);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rate:', error);
|
||||||
|
setUserRating(previousRating);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[session, userRating, isLoading, interactive, submitRating]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-5 h-5',
|
||||||
|
lg: 'w-6 h-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const starSize = sizeClasses[size];
|
||||||
|
|
||||||
|
// Determine which rating to display (hover > user > average)
|
||||||
|
const displayRating = hoverRating ?? userRating ?? averageRating ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const isFilled = star <= displayRating;
|
||||||
|
const isHalfFilled = !isFilled && star - 0.5 <= displayRating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
disabled={isLoading || !interactive}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
onMouseEnter={() => interactive && setHoverRating(star)}
|
||||||
|
onMouseLeave={() => interactive && setHoverRating(null)}
|
||||||
|
className={`
|
||||||
|
${interactive ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
|
||||||
|
transition-transform duration-100
|
||||||
|
${isLoading ? 'opacity-50' : ''}
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-primary/50 rounded
|
||||||
|
`}
|
||||||
|
aria-label={`Rate ${star} stars`}
|
||||||
|
>
|
||||||
|
<div className={`${starSize} relative`}>
|
||||||
|
{isFilled ? (
|
||||||
|
<Icon
|
||||||
|
icon="starFilled"
|
||||||
|
className={`${starSize} ${
|
||||||
|
userRating && star <= userRating ? 'text-yellow-400' : 'text-yellow-400/70'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
) : isHalfFilled ? (
|
||||||
|
<div className="relative">
|
||||||
|
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
|
||||||
|
<div className="absolute inset-0 overflow-hidden w-1/2">
|
||||||
|
<Icon icon="starFilled" className={`${starSize} text-yellow-400/70`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(showAverage || showCount) && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||||
|
{showAverage && averageRating !== null && (
|
||||||
|
<span className="font-medium">{averageRating.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
{showCount && ratingCount > 0 && (
|
||||||
|
<span className="text-foreground-tertiary">
|
||||||
|
({ratingCount} {ratingCount === 1 ? 'rating' : 'ratings'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTooltip && (
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5 bg-surface-secondary border border-border rounded-lg shadow-lg text-xs text-foreground whitespace-nowrap z-50 animate-in fade-in slide-in-from-bottom-1 duration-200">
|
||||||
|
<a href="/sign-in" className="text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</a>{' '}
|
||||||
|
to rate
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px">
|
||||||
|
<div className="border-4 border-transparent border-t-border" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-only rating (non-interactive)
|
||||||
|
*/
|
||||||
|
interface RatingDisplayProps {
|
||||||
|
averageRating: number | null;
|
||||||
|
ratingCount?: number;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showCount?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RatingDisplay({
|
||||||
|
averageRating,
|
||||||
|
ratingCount = 0,
|
||||||
|
size = 'sm',
|
||||||
|
showCount = true,
|
||||||
|
className = '',
|
||||||
|
}: RatingDisplayProps): React.ReactElement {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-3 h-3',
|
||||||
|
md: 'w-4 h-4',
|
||||||
|
lg: 'w-5 h-5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const starSize = sizeClasses[size];
|
||||||
|
const rating = averageRating ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-1 ${className}`}>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const isFilled = star <= rating;
|
||||||
|
const isHalfFilled = !isFilled && star - 0.5 <= rating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={star} className={`${starSize} relative`}>
|
||||||
|
{isFilled ? (
|
||||||
|
<Icon icon="starFilled" className={`${starSize} text-yellow-400`} />
|
||||||
|
) : isHalfFilled ? (
|
||||||
|
<div className="relative">
|
||||||
|
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
|
||||||
|
<div className="absolute inset-0 overflow-hidden w-1/2">
|
||||||
|
<Icon icon="starFilled" className={`${starSize} text-yellow-400`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{averageRating !== null && (
|
||||||
|
<span className="text-sm text-foreground-secondary font-medium ml-1">
|
||||||
|
{averageRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCount && ratingCount > 0 && (
|
||||||
|
<span className="text-sm text-foreground-tertiary">({ratingCount})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
apps/web/src/components/ReviewCard.tsx
Normal file
190
apps/web/src/components/ReviewCard.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
||||||
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { RatingDisplay } from './Rating';
|
||||||
|
|
||||||
|
interface ReviewUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
rating: number;
|
||||||
|
helpfulCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user: ReviewUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewCardProps {
|
||||||
|
review: Review;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return 'Today';
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return 'Yesterday';
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays} days ago`;
|
||||||
|
} else if (diffDays < 30) {
|
||||||
|
const weeks = Math.floor(diffDays / 7);
|
||||||
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
||||||
|
} else if (diffDays < 365) {
|
||||||
|
const months = Math.floor(diffDays / 30);
|
||||||
|
return `${months} ${months === 1 ? 'month' : 'months'} ago`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserAvatar({ user }: { user: ReviewUser }): React.ReactElement {
|
||||||
|
const initials = getInitials(user.name);
|
||||||
|
|
||||||
|
if (user.image) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-medium text-sm">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewCard({ review, className = '' }: ReviewCardProps): React.ReactElement {
|
||||||
|
const userLink = review.user.username ? `/@${review.user.username}` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
{userLink ? (
|
||||||
|
<Link href={userLink} className="flex-shrink-0">
|
||||||
|
<UserAvatar user={review.user} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<UserAvatar user={review.user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User info and rating */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{userLink ? (
|
||||||
|
<Link
|
||||||
|
href={userLink}
|
||||||
|
className="font-medium text-foreground hover:text-primary truncate"
|
||||||
|
>
|
||||||
|
{review.user.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-foreground truncate">{review.user.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-foreground-tertiary text-sm">
|
||||||
|
{formatDate(review.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<RatingDisplay
|
||||||
|
averageRating={review.rating}
|
||||||
|
showCount={false}
|
||||||
|
size="sm"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{review.title && <h4 className="font-semibold text-foreground mb-2">{review.title}</h4>}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<p className="text-foreground-secondary text-sm whitespace-pre-wrap">{review.content}</p>
|
||||||
|
|
||||||
|
{/* Helpful count */}
|
||||||
|
{review.helpfulCount > 0 && (
|
||||||
|
<div className="mt-3 flex items-center gap-1 text-sm text-foreground-tertiary">
|
||||||
|
<Icon icon="heart" size="xs" />
|
||||||
|
<span>
|
||||||
|
{review.helpfulCount} {review.helpfulCount === 1 ? 'person' : 'people'} found this
|
||||||
|
helpful
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviews section with list of reviews and write review form
|
||||||
|
*/
|
||||||
|
interface ReviewsSectionProps {
|
||||||
|
initialReviews?: Review[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewsSection({
|
||||||
|
initialReviews = [],
|
||||||
|
className = '',
|
||||||
|
}: ReviewsSectionProps): React.ReactElement {
|
||||||
|
// For now, just display the reviews statically
|
||||||
|
// Interactive features (write review, load more) can be added later
|
||||||
|
|
||||||
|
if (initialReviews.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`text-center py-8 ${className}`}>
|
||||||
|
<Icon icon="message" size="lg" className="text-foreground-tertiary mx-auto mb-2" />
|
||||||
|
<p className="text-foreground-secondary">No reviews yet</p>
|
||||||
|
<p className="text-sm text-foreground-tertiary mt-1">
|
||||||
|
Be the first to share your experience with this tool
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{initialReviews.map((review) => (
|
||||||
|
<ReviewCard key={review.id} review={review} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -95,16 +95,26 @@ model Tool {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Rating aggregates
|
||||||
|
averageRating Decimal? @map("average_rating") @db.Decimal(2, 1) // 1.0 to 5.0
|
||||||
|
ratingCount Int @default(0) @map("rating_count")
|
||||||
|
reviewCount Int @default(0) @map("review_count")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
simulations Simulation[]
|
simulations Simulation[]
|
||||||
healthChecks HealthCheck[]
|
healthChecks HealthCheck[]
|
||||||
collections CollectionTool[]
|
collections CollectionTool[]
|
||||||
agents AgentTool[]
|
agents AgentTool[]
|
||||||
likes ToolLike[]
|
likes ToolLike[]
|
||||||
|
ratings ToolRating[]
|
||||||
|
reviews ToolReview[]
|
||||||
|
|
||||||
@@unique([packageId, name])
|
@@unique([packageId, name])
|
||||||
@@index([qualityScore])
|
@@index([qualityScore])
|
||||||
@@index([likeCount])
|
@@index([likeCount])
|
||||||
|
@@index([averageRating])
|
||||||
|
@@index([ratingCount])
|
||||||
|
@@index([reviewCount])
|
||||||
@@index([importHealth])
|
@@index([importHealth])
|
||||||
@@index([executionHealth])
|
@@index([executionHealth])
|
||||||
@@index([lastHealthCheck])
|
@@index([lastHealthCheck])
|
||||||
|
|
@ -346,6 +356,8 @@ model User {
|
||||||
agents Agent[]
|
agents Agent[]
|
||||||
apiKeys UserApiKey[]
|
apiKeys UserApiKey[]
|
||||||
toolLikes ToolLike[]
|
toolLikes ToolLike[]
|
||||||
|
toolRatings ToolRating[]
|
||||||
|
toolReviews ToolReview[]
|
||||||
collectionLikes CollectionLike[]
|
collectionLikes CollectionLike[]
|
||||||
agentLikes AgentLike[]
|
agentLikes AgentLike[]
|
||||||
activities UserActivity[]
|
activities UserActivity[]
|
||||||
|
|
@ -761,6 +773,65 @@ model AgentLike {
|
||||||
@@map("agent_likes")
|
@@map("agent_likes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ToolRating - user ratings for tools (1-5 stars)
|
||||||
|
model ToolRating {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
userId String @map("user_id")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
toolId String @map("tool_id")
|
||||||
|
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Rating value (1-5)
|
||||||
|
rating Int @db.SmallInt
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([userId, toolId])
|
||||||
|
@@index([toolId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([rating])
|
||||||
|
@@map("tool_ratings")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ToolReview - user reviews for tools
|
||||||
|
model ToolReview {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
userId String @map("user_id")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
toolId String @map("tool_id")
|
||||||
|
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Review content
|
||||||
|
title String? @db.VarChar(200)
|
||||||
|
content String @db.Text
|
||||||
|
rating Int @db.SmallInt // 1-5, denormalized for convenience
|
||||||
|
|
||||||
|
// Moderation
|
||||||
|
isApproved Boolean @default(true) @map("is_approved")
|
||||||
|
isHidden Boolean @default(false) @map("is_hidden")
|
||||||
|
|
||||||
|
// Helpful votes
|
||||||
|
helpfulCount Int @default(0) @map("helpful_count")
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([userId, toolId])
|
||||||
|
@@index([toolId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([rating])
|
||||||
|
@@index([isApproved, isHidden])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("tool_reviews")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Activity Stream Models
|
// Activity Stream Models
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,14 @@ export const icons = {
|
||||||
viewBox: '0 0 24 24',
|
viewBox: '0 0 24 24',
|
||||||
path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z',
|
path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z',
|
||||||
},
|
},
|
||||||
|
star: {
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
path: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z',
|
||||||
|
},
|
||||||
|
starFilled: {
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
path: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof icons;
|
export type IconName = keyof typeof icons;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue