feat: implement multi-tool package architecture with manual tool registry
BREAKING CHANGE: Complete refactoring from single-tool to multi-tool package support Database Schema: - Split Tool model into Package (1) and Tool (many) with one-to-many relationship - Package stores npm metadata and package-level tpmjs fields (category, env, frameworks, tier) - Tool stores individual tool exports with tool-level metadata (exportName, description, parameters, returns, aiAgent) - Unique constraint on (packageId, exportName) to prevent duplicate tools - Cascade deletes when packages are removed Type System: - Updated tpmjs field schema to support tools array - Each tool has exportName, description, parameters, returns, aiAgent - Package-level fields: category, env, frameworks shared across all tools - Backward compatible with legacy single-tool format (auto-migrates to exportName: "default") API Updates: - Updated all /api/tools routes to query Tool model with Package relations - Updated /api/tools/[slug] to accept package/export path segments - Updated tool-executor-agent to use actual exportName instead of hardcoded "default" - Updated metrics sync to calculate quality scores per Tool Frontend Updates: - Updated tool search page to display exportName as primary heading - Updated tool detail pages to show package name as secondary info - Removed tag-based filtering (tags moved to package level) Manual Tool Registry: - Added manual-tools.ts with 23 curated tools from major providers - Created sync-manual-tools.ts script to sync manual tools to database - Added MANUAL_TOOLS.md documentation for manual tool system - Added GitHub workflow for automated daily sync - Includes tools from: Vercel, Exa, Firecrawl, AWS Bedrock, Perplexity, Tavily, Superagent, Valyu Playground Updates: - Updated tool loader to load multiple tools per package - Added sanitizeToolName for OpenAI API compatibility Sync System Updates: - Updated changes feed sync to handle multi-tool packages - Updated keyword sync to upsert multiple tools per package - Added orphaned tool deletion when tools removed from package.json Migration Strategy: - Database uses same Neon instance for dev and prod - Schema updated via prisma db push (no migration files yet) - All data repopulates from npm via sync system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f59c2c5123
commit
141a64d888
31 changed files with 3783 additions and 786 deletions
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -18,25 +18,14 @@ export const dynamic = 'force-dynamic';
|
|||
export async function GET() {
|
||||
try {
|
||||
// Run all aggregations in parallel
|
||||
const [totalTools, officialTools, categoryStats, recentCount, downloadSum] = await Promise.all([
|
||||
const [totalTools, officialTools, recentCount, packages] = await Promise.all([
|
||||
// Total tools count
|
||||
prisma.tool.count(),
|
||||
|
||||
// Official tools count
|
||||
// Official tools count (isOfficial is at package level)
|
||||
prisma.tool.count({
|
||||
where: { isOfficial: true },
|
||||
}),
|
||||
|
||||
// Group by category
|
||||
prisma.tool.groupBy({
|
||||
by: ['category'],
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: 'desc',
|
||||
},
|
||||
where: {
|
||||
package: { isOfficial: true }
|
||||
},
|
||||
}),
|
||||
|
||||
|
|
@ -49,21 +38,31 @@ export async function GET() {
|
|||
},
|
||||
}),
|
||||
|
||||
// Sum of all downloads
|
||||
prisma.tool.aggregate({
|
||||
_sum: {
|
||||
// Get all packages with their tool counts and download stats
|
||||
prisma.package.findMany({
|
||||
select: {
|
||||
category: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
_count: {
|
||||
select: { tools: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Format category stats
|
||||
const categories = categoryStats.reduce<Record<string, number>>((acc, stat) => {
|
||||
if (stat.category) {
|
||||
acc[stat.category] = stat._count.id;
|
||||
// Calculate stats from packages
|
||||
const categories: Record<string, number> = {};
|
||||
let totalDownloads = 0;
|
||||
|
||||
for (const pkg of packages) {
|
||||
// Count tools by category
|
||||
if (pkg.category) {
|
||||
categories[pkg.category] = (categories[pkg.category] || 0) + pkg._count.tools;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Sum downloads
|
||||
totalDownloads += pkg.npmDownloadsLastMonth || 0;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
|
@ -72,7 +71,7 @@ export async function GET() {
|
|||
officialTools,
|
||||
categories,
|
||||
recentTools: recentCount,
|
||||
totalDownloads: downloadSum._sum.npmDownloadsLastMonth || 0,
|
||||
totalDownloads,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -66,74 +66,119 @@ export async function POST(request: NextRequest) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Validate tpmjs field
|
||||
// Validate tpmjs field (supports both new multi-tool and legacy formats)
|
||||
const validation = validateTpmjsField(pkg.tpmjs);
|
||||
if (!validation.valid || !validation.data) {
|
||||
if (!validation.valid || !validation.packageData || !validation.tools) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log auto-migration from legacy format
|
||||
if (validation.wasLegacyFormat) {
|
||||
console.log(`Auto-migrated legacy package: ${pkg.name}`);
|
||||
}
|
||||
|
||||
// Extract repository URL and GitHub stars
|
||||
const githubStars: number | null = null;
|
||||
|
||||
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
|
||||
const tpmjsData = validation.data as {
|
||||
category: string;
|
||||
description: string;
|
||||
example: string;
|
||||
parameters?: unknown;
|
||||
returns?: unknown;
|
||||
authentication?: unknown;
|
||||
pricing?: unknown;
|
||||
frameworks?: string[];
|
||||
links?: unknown;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
aiAgent?: unknown;
|
||||
};
|
||||
|
||||
// Prepare data for upsert
|
||||
const toolData = {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: tpmjsData.category,
|
||||
description: tpmjsData.description,
|
||||
example: tpmjsData.example,
|
||||
parameters: tpmjsData.parameters ?? undefined,
|
||||
returns: tpmjsData.returns ?? undefined,
|
||||
authentication: tpmjsData.authentication ?? undefined,
|
||||
pricing: tpmjsData.pricing ?? undefined,
|
||||
frameworks: tpmjsData.frameworks || [],
|
||||
links: tpmjsData.links ?? undefined,
|
||||
tags: tpmjsData.tags || [],
|
||||
status: tpmjsData.status ?? undefined,
|
||||
aiAgent: tpmjsData.aiAgent ?? undefined,
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
tier: validation.tier || 'minimal',
|
||||
};
|
||||
|
||||
// Upsert tool to database
|
||||
await prisma.tool.upsert({
|
||||
// Upsert Package record
|
||||
const packageRecord = await prisma.package.upsert({
|
||||
where: { npmPackageName: pkg.name },
|
||||
create: {
|
||||
npmPackageName: pkg.name,
|
||||
...toolData,
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
discoveryMethod: 'changes-feed',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
|
||||
githubStars: githubStars,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: toolData,
|
||||
update: {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
},
|
||||
});
|
||||
|
||||
// Get existing tools for this package
|
||||
const existingTools = await prisma.tool.findMany({
|
||||
where: { packageId: packageRecord.id },
|
||||
});
|
||||
|
||||
// Upsert each tool in the tools array
|
||||
for (const toolDef of validation.tools) {
|
||||
await prisma.tool.upsert({
|
||||
where: {
|
||||
packageId_exportName: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: {
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned tools (tools removed from package.json)
|
||||
const orphanedTools = existingTools.filter(
|
||||
(existingTool) =>
|
||||
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
|
||||
);
|
||||
|
||||
if (orphanedTools.length > 0) {
|
||||
await prisma.tool.deleteMany({
|
||||
where: {
|
||||
id: { in: orphanedTools.map((t) => t.id) },
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`
|
||||
);
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ export async function POST(request: NextRequest) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Validate tpmjs field
|
||||
// Validate tpmjs field (supports both new multi-tool and legacy formats)
|
||||
const validation = validateTpmjsField(pkg.tpmjs);
|
||||
if (!validation.valid || !validation.data) {
|
||||
if (!validation.valid || !validation.packageData || !validation.tools) {
|
||||
skipped++;
|
||||
skippedPackages.push({
|
||||
name: pkg.name,
|
||||
|
|
@ -87,67 +87,112 @@ export async function POST(request: NextRequest) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Log auto-migration from legacy format
|
||||
if (validation.wasLegacyFormat) {
|
||||
console.log(`Auto-migrated legacy package: ${pkg.name}`);
|
||||
}
|
||||
|
||||
// Extract repository URL and GitHub stars
|
||||
const githubStars: number | null = null;
|
||||
|
||||
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
|
||||
const tpmjsData = validation.data as {
|
||||
category: string;
|
||||
description: string;
|
||||
example: string;
|
||||
parameters?: unknown;
|
||||
returns?: unknown;
|
||||
authentication?: unknown;
|
||||
pricing?: unknown;
|
||||
frameworks?: string[];
|
||||
links?: unknown;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
aiAgent?: unknown;
|
||||
};
|
||||
|
||||
// Prepare data for upsert
|
||||
const toolData = {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: tpmjsData.category,
|
||||
description: tpmjsData.description,
|
||||
example: tpmjsData.example,
|
||||
parameters: tpmjsData.parameters ?? undefined,
|
||||
returns: tpmjsData.returns ?? undefined,
|
||||
authentication: tpmjsData.authentication ?? undefined,
|
||||
pricing: tpmjsData.pricing ?? undefined,
|
||||
frameworks: tpmjsData.frameworks || [],
|
||||
links: tpmjsData.links ?? undefined,
|
||||
tags: tpmjsData.tags || [],
|
||||
status: tpmjsData.status ?? undefined,
|
||||
aiAgent: tpmjsData.aiAgent ?? undefined,
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
tier: validation.tier || 'minimal',
|
||||
};
|
||||
|
||||
// Upsert tool to database
|
||||
await prisma.tool.upsert({
|
||||
// Upsert Package record
|
||||
const packageRecord = await prisma.package.upsert({
|
||||
where: { npmPackageName: pkg.name },
|
||||
create: {
|
||||
npmPackageName: pkg.name,
|
||||
...toolData,
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
discoveryMethod: 'keyword',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
|
||||
githubStars: githubStars,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: toolData,
|
||||
update: {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
},
|
||||
});
|
||||
|
||||
// Get existing tools for this package
|
||||
const existingTools = await prisma.tool.findMany({
|
||||
where: { packageId: packageRecord.id },
|
||||
});
|
||||
|
||||
// Upsert each tool in the tools array
|
||||
for (const toolDef of validation.tools) {
|
||||
await prisma.tool.upsert({
|
||||
where: {
|
||||
packageId_exportName: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: {
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned tools (tools removed from package.json)
|
||||
const orphanedTools = existingTools.filter(
|
||||
(existingTool) =>
|
||||
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
|
||||
);
|
||||
|
||||
if (orphanedTools.length > 0) {
|
||||
await prisma.tool.deleteMany({
|
||||
where: {
|
||||
id: { in: orphanedTools.map((t) => t.id) },
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`
|
||||
);
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const maxDuration = 300; // 5 minutes max for cron jobs
|
|||
|
||||
/**
|
||||
* POST /api/sync/metrics
|
||||
* Update download stats and quality scores for all tools
|
||||
* Update download stats and quality scores for all packages and tools
|
||||
*
|
||||
* This endpoint is called by Vercel Cron (every hour)
|
||||
* Requires Authorization: Bearer <CRON_SECRET>
|
||||
|
|
@ -31,43 +31,51 @@ export async function POST(request: NextRequest) {
|
|||
const errorMessages: string[] = [];
|
||||
|
||||
try {
|
||||
// Get all tools from database
|
||||
const tools = await prisma.tool.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
npmPackageName: true,
|
||||
tier: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
githubStars: true,
|
||||
// Get all packages with their tools from database
|
||||
const packages = await prisma.package.findMany({
|
||||
include: {
|
||||
tools: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Process each tool
|
||||
for (const tool of tools) {
|
||||
// Process each package
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
// Fetch download stats from NPM
|
||||
const downloads = await fetchDownloadStats(tool.npmPackageName);
|
||||
// Fetch download stats from NPM (package-level metric)
|
||||
const downloads = await fetchDownloadStats(pkg.npmPackageName);
|
||||
|
||||
// Calculate quality score (0.00 to 1.00)
|
||||
const qualityScore = calculateQualityScore({
|
||||
tier: tool.tier,
|
||||
downloads,
|
||||
githubStars: tool.githubStars || 0,
|
||||
});
|
||||
|
||||
// Update tool metrics
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
// Update package metrics
|
||||
await prisma.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: {
|
||||
npmDownloadsLastMonth: downloads,
|
||||
qualityScore,
|
||||
// githubStars would be updated here if we had GitHub API integration
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate and update quality score for each tool in this package
|
||||
for (const tool of pkg.tools) {
|
||||
const qualityScore = calculateQualityScore({
|
||||
tier: pkg.tier, // Tier is at package level
|
||||
downloads, // Package downloads
|
||||
githubStars: pkg.githubStars || 0, // Package stars
|
||||
hasParameters: !!tool.parameters,
|
||||
hasReturns: !!tool.returns,
|
||||
hasAiAgent: !!tool.aiAgent,
|
||||
});
|
||||
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
qualityScore,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
const errorMsg = `Failed to process ${tool.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
const errorMsg = `Failed to process ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errorMessages.push(errorMsg);
|
||||
console.error(errorMsg);
|
||||
}
|
||||
|
|
@ -80,13 +88,15 @@ export async function POST(request: NextRequest) {
|
|||
source: 'metrics',
|
||||
checkpoint: {
|
||||
lastRun: new Date().toISOString(),
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
checkpoint: {
|
||||
lastRun: new Date().toISOString(),
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -102,10 +112,11 @@ export async function POST(request: NextRequest) {
|
|||
message:
|
||||
errors > 0
|
||||
? `Processed with errors: ${errorMessages.slice(0, 3).join('; ')}`
|
||||
: `Successfully updated metrics for ${processed} tools`,
|
||||
: `Successfully updated metrics for ${processed} packages`,
|
||||
metadata: {
|
||||
durationMs: Date.now() - startTime,
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -116,7 +127,8 @@ export async function POST(request: NextRequest) {
|
|||
processed,
|
||||
skipped,
|
||||
errors,
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
});
|
||||
|
|
@ -152,25 +164,40 @@ export async function POST(request: NextRequest) {
|
|||
/**
|
||||
* Calculate quality score based on multiple factors
|
||||
* Returns a value between 0.00 and 1.00
|
||||
*
|
||||
* Score components:
|
||||
* - Tier (0.4 minimal, 0.6 rich)
|
||||
* - Downloads (logarithmic, max 0.2)
|
||||
* - GitHub stars (logarithmic, max 0.1)
|
||||
* - Tool metadata richness (0.1 for each: parameters, returns, aiAgent)
|
||||
*/
|
||||
function calculateQualityScore(params: {
|
||||
tier: string;
|
||||
downloads: number;
|
||||
githubStars: number;
|
||||
hasParameters: boolean;
|
||||
hasReturns: boolean;
|
||||
hasAiAgent: boolean;
|
||||
}): number {
|
||||
const { tier, downloads, githubStars } = params;
|
||||
const { tier, downloads, githubStars, hasParameters, hasReturns, hasAiAgent } = params;
|
||||
|
||||
// Base score from tier
|
||||
const tierScore = tier === 'rich' ? 0.6 : 0.4;
|
||||
|
||||
// Downloads score (logarithmic scale, max 0.3)
|
||||
const downloadsScore = Math.min(0.3, Math.log10(downloads + 1) / 10);
|
||||
// Downloads score (logarithmic scale, max 0.2)
|
||||
const downloadsScore = Math.min(0.2, Math.log10(downloads + 1) / 15);
|
||||
|
||||
// GitHub stars score (logarithmic scale, max 0.1)
|
||||
const starsScore = Math.min(0.1, Math.log10(githubStars + 1) / 10);
|
||||
|
||||
// Tool metadata richness score (max 0.1)
|
||||
let richnessScore = 0;
|
||||
if (hasParameters) richnessScore += 0.04;
|
||||
if (hasReturns) richnessScore += 0.03;
|
||||
if (hasAiAgent) richnessScore += 0.03;
|
||||
|
||||
// Total score (capped at 1.00)
|
||||
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore);
|
||||
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore + richnessScore);
|
||||
|
||||
// Round to 2 decimal places
|
||||
return Math.round(totalScore * 100) / 100;
|
||||
|
|
|
|||
|
|
@ -18,31 +18,83 @@ export async function GET(
|
|||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer')
|
||||
const packageName = slug.join('/');
|
||||
// Slug can be:
|
||||
// - ['@scope', 'package'] -> Get all tools for @scope/package
|
||||
// - ['@scope', 'package', 'exportName'] -> Get specific tool @scope/package/exportName
|
||||
// - ['package'] -> Get all tools for package
|
||||
// - ['package', 'exportName'] -> Get specific tool package/exportName
|
||||
|
||||
// Find the tool by npmPackageName
|
||||
const tool = await prisma.tool.findUnique({
|
||||
where: {
|
||||
npmPackageName: packageName,
|
||||
},
|
||||
});
|
||||
let packageName: string;
|
||||
let exportName: string | undefined;
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
if (slug.length === 1) {
|
||||
// Single slug - package name without scope
|
||||
packageName = slug[0] || '';
|
||||
} else if (slug.length === 2) {
|
||||
// Could be: @scope/package OR package/exportName
|
||||
if (slug[0]?.startsWith('@')) {
|
||||
// @scope/package
|
||||
packageName = slug.join('/');
|
||||
} else {
|
||||
// package + exportName
|
||||
packageName = slug[0] || '';
|
||||
exportName = slug[1];
|
||||
}
|
||||
} else {
|
||||
// 3+ slugs: @scope/package/exportName
|
||||
packageName = slug.slice(0, slug[0]?.startsWith('@') ? 2 : 1).join('/');
|
||||
exportName = slug[slug.length - 1];
|
||||
}
|
||||
|
||||
// Return the tool data
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tool,
|
||||
});
|
||||
if (exportName) {
|
||||
// Find specific tool by package name and export name
|
||||
const tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
include: { package: true },
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tool,
|
||||
});
|
||||
} else {
|
||||
// Find all tools for the package
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { npmPackageName: packageName },
|
||||
include: { tools: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Package not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
package: pkg,
|
||||
tools: pkg.tools,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tool:', error);
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -18,15 +18,19 @@ interface ExecuteRequest {
|
|||
}
|
||||
|
||||
/**
|
||||
* POST /api/tools/[...slug]/execute
|
||||
* POST /api/tools/execute/[...slug]
|
||||
* Executes a tool with an AI agent and streams the response via SSE
|
||||
*
|
||||
* Slug format: [toolId] or [packageName, exportName]
|
||||
* Examples:
|
||||
* /api/tools/execute/clx123abc (by tool ID)
|
||||
* /api/tools/execute/@tpmjs/hello/helloWorldTool (by package and export name)
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const packageName = decodeURIComponent(slug.join('/'));
|
||||
|
||||
try {
|
||||
// Parse request body
|
||||
|
|
@ -63,10 +67,29 @@ export async function POST(
|
|||
);
|
||||
}
|
||||
|
||||
// Fetch tool from database
|
||||
const tool = await prisma.tool.findUnique({
|
||||
where: { npmPackageName: packageName },
|
||||
});
|
||||
// Fetch tool from database with package relation
|
||||
// Support both ID-based lookup and packageName/exportName lookup
|
||||
let tool;
|
||||
|
||||
if (slug.length === 1) {
|
||||
// Single slug - treat as tool ID
|
||||
tool = await prisma.tool.findUnique({
|
||||
where: { id: slug[0] || '' },
|
||||
include: { package: true },
|
||||
});
|
||||
} else {
|
||||
// Multiple slugs - treat as packageName/exportName
|
||||
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
|
||||
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
|
||||
|
||||
tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
include: { package: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export const maxDuration = 60;
|
|||
* Search and list tools with filtering, sorting, and pagination
|
||||
*
|
||||
* Query params:
|
||||
* - q: Search query (searches name, description, tags)
|
||||
* - q: Search query (searches package name, tool description)
|
||||
* - category: Filter by category
|
||||
* - official: Filter by official status (true/false)
|
||||
* - limit: Results per page (default: 20, max: 100)
|
||||
* - limit: Results per page (default: 20, max: 50)
|
||||
* - offset: Pagination offset (default: 0)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
@ -27,44 +27,52 @@ export async function GET(request: NextRequest) {
|
|||
const limitParam = searchParams.get('limit');
|
||||
const offsetParam = searchParams.get('offset');
|
||||
|
||||
// Validate and set defaults (reduced max from 100 to 50 for faster queries)
|
||||
// Validate and set defaults
|
||||
const limit = Math.min(
|
||||
Number.parseInt(limitParam || '20', 10),
|
||||
50 // Reduced from 100 for better performance
|
||||
50 // Max 50 for better performance
|
||||
);
|
||||
const offset = Math.max(Number.parseInt(offsetParam || '0', 10), 0);
|
||||
|
||||
// Build where clause
|
||||
// Build where clause for Tool table
|
||||
const where: Prisma.ToolWhereInput = {};
|
||||
|
||||
// Search filter (case-insensitive partial match)
|
||||
// Build package filter separately
|
||||
const packageFilter: Prisma.PackageWhereInput = {};
|
||||
|
||||
// Category filter (category is at package level)
|
||||
if (category) {
|
||||
packageFilter.category = category;
|
||||
}
|
||||
|
||||
// Official filter (isOfficial is at package level)
|
||||
if (officialParam !== null) {
|
||||
packageFilter.isOfficial = officialParam === 'true';
|
||||
}
|
||||
|
||||
// Search filter (searches tool description and package name)
|
||||
if (query) {
|
||||
where.OR = [
|
||||
{ npmPackageName: { contains: query, mode: 'insensitive' } },
|
||||
{ description: { contains: query, mode: 'insensitive' } },
|
||||
{
|
||||
tags: {
|
||||
hasSome: [query],
|
||||
},
|
||||
},
|
||||
{ package: { npmPackageName: { contains: query, mode: 'insensitive' }, ...packageFilter } },
|
||||
];
|
||||
} else if (Object.keys(packageFilter).length > 0) {
|
||||
// Apply package filter if no search query
|
||||
where.package = packageFilter;
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
// Official filter
|
||||
if (officialParam !== null) {
|
||||
where.isOfficial = officialParam === 'true';
|
||||
}
|
||||
|
||||
// Execute queries - run count separately only if needed for pagination
|
||||
// For first page, we can skip count if we don't need total pages
|
||||
// Execute query - fetch tools with package relation
|
||||
// We fetch limit+1 to check if there are more results (avoid expensive count)
|
||||
const tools = await prisma.tool.findMany({
|
||||
where,
|
||||
orderBy: [{ qualityScore: 'desc' }, { npmDownloadsLastMonth: 'desc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
package: true, // Include package data for each tool
|
||||
},
|
||||
orderBy: [
|
||||
{ qualityScore: 'desc' }, // Tool quality score
|
||||
{ package: { npmDownloadsLastMonth: 'desc' } }, // Package downloads
|
||||
{ createdAt: 'desc' }, // Tool creation time
|
||||
],
|
||||
take: limit + 1, // Fetch one extra to check if there are more
|
||||
skip: offset,
|
||||
});
|
||||
|
|
@ -81,7 +89,6 @@ export async function GET(request: NextRequest) {
|
|||
offset,
|
||||
hasMore,
|
||||
// Note: total count omitted for performance (can be expensive)
|
||||
// Only return count if explicitly requested
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -7,22 +7,41 @@ import { prisma } from '@tpmjs/db';
|
|||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/tools/[...slug]/simulations
|
||||
* GET /api/tools/simulations/[...slug]
|
||||
* Returns the last 10 simulations for a tool
|
||||
*
|
||||
* Slug can be:
|
||||
* - Tool ID (single slug)
|
||||
* - Package name + export name (multiple slugs)
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const packageName = decodeURIComponent(slug.join('/'));
|
||||
|
||||
try {
|
||||
// Fetch tool
|
||||
const tool = await prisma.tool.findUnique({
|
||||
where: { npmPackageName: packageName },
|
||||
select: { id: true },
|
||||
});
|
||||
let tool;
|
||||
|
||||
if (slug.length === 1) {
|
||||
// Single slug - treat as tool ID
|
||||
tool = await prisma.tool.findUnique({
|
||||
where: { id: slug[0] || '' },
|
||||
select: { id: true },
|
||||
});
|
||||
} else {
|
||||
// Multiple slugs - treat as packageName/exportName
|
||||
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
|
||||
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
|
||||
|
||||
tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
|
||||
|
|
|
|||
|
|
@ -20,24 +20,33 @@ async function getHomePageData() {
|
|||
|
||||
// Top 6 featured tools by quality score
|
||||
prisma.tool.findMany({
|
||||
orderBy: [{ qualityScore: 'desc' }, { npmDownloadsLastMonth: 'desc' }],
|
||||
orderBy: [
|
||||
{ qualityScore: 'desc' },
|
||||
{ package: { npmDownloadsLastMonth: 'desc' } },
|
||||
],
|
||||
take: 6,
|
||||
select: {
|
||||
id: true,
|
||||
npmPackageName: true,
|
||||
exportName: true,
|
||||
description: true,
|
||||
category: true,
|
||||
tags: true,
|
||||
qualityScore: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
isOfficial: true,
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
category: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
isOfficial: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Category distribution for stats
|
||||
prisma.tool.groupBy({
|
||||
// Category distribution for stats (group by package category)
|
||||
prisma.package.groupBy({
|
||||
by: ['category'],
|
||||
_count: true,
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
@ -70,7 +79,7 @@ async function getHomePageData() {
|
|||
featuredTools,
|
||||
categories: categoryStats.slice(0, 5).map((c) => ({
|
||||
name: c.category,
|
||||
count: c._count,
|
||||
count: c._count._all,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -113,13 +122,20 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
{data.featuredTools.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
{data.featuredTools.map((tool) => (
|
||||
<Link key={tool.id} href={`/tool/${tool.npmPackageName}`} className="group">
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="p-6 border border-border rounded-lg bg-surface hover:border-foreground transition-colors h-full flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-foreground group-hover:text-brutalist-accent transition-colors">
|
||||
{tool.npmPackageName}
|
||||
{tool.package.npmPackageName}
|
||||
<span className="text-xs text-foreground-tertiary ml-2">
|
||||
({tool.exportName})
|
||||
</span>
|
||||
</h3>
|
||||
{tool.isOfficial && (
|
||||
{tool.package.isOfficial && (
|
||||
<Badge variant="default" size="sm">
|
||||
Official
|
||||
</Badge>
|
||||
|
|
@ -132,13 +148,8 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" size="sm">
|
||||
{tool.category}
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
{tool.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" size="sm">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
|
||||
|
|
@ -147,7 +158,7 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
{tool.qualityScore ? Number(tool.qualityScore).toFixed(2) : 'N/A'}
|
||||
</span>
|
||||
<span>
|
||||
{tool.npmDownloadsLastMonth?.toLocaleString() || '0'} downloads/mo
|
||||
{tool.package.npmDownloadsLastMonth?.toLocaleString() || '0'} downloads/mo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,13 @@ export default function PublishPage(): React.ReactElement {
|
|||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "A concise description of what your tool does"
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "myTool",
|
||||
"description": "A concise description of what your tool does"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
|
@ -118,26 +124,32 @@ export default function PublishPage(): React.ReactElement {
|
|||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Analyzes sentiment in text",
|
||||
"parameters": [
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code (e.g., 'en')",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Analyzes sentiment in text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code (e.g., 'en')",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object with score and label"
|
||||
}
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object with score and label"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
|
@ -159,9 +171,7 @@ export default function PublishPage(): React.ReactElement {
|
|||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Advanced sentiment analysis with emotion detection",
|
||||
"parameters": [...],
|
||||
"returns": {...},
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"env": [
|
||||
{
|
||||
"name": "SENTIMENT_API_KEY",
|
||||
|
|
@ -169,15 +179,22 @@ export default function PublishPage(): React.ReactElement {
|
|||
"required": true
|
||||
}
|
||||
],
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"aiAgent": {
|
||||
"useCase": "Use when users need to analyze sentiment or detect emotions",
|
||||
"limitations": "English and Spanish only. Max 10,000 characters",
|
||||
"examples": [
|
||||
"Analyze customer review sentiment",
|
||||
"Detect emotions in feedback"
|
||||
]
|
||||
}
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Advanced sentiment analysis with emotion detection",
|
||||
"parameters": [...],
|
||||
"returns": {...},
|
||||
"aiAgent": {
|
||||
"useCase": "Use when users need to analyze sentiment or detect emotions",
|
||||
"limitations": "English and Spanish only. Max 10,000 characters",
|
||||
"examples": [
|
||||
"Analyze customer review sentiment",
|
||||
"Detect emotions in feedback"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
|
@ -279,26 +296,31 @@ npm publish --access public
|
|||
"keywords": ["tpmjs-tool", "blog", "content"],
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Creates structured blog posts with frontmatter and SEO metadata",
|
||||
"parameters": [
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"tools": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"description": "The title of the blog post",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "content",
|
||||
"type": "string",
|
||||
"description": "The main content",
|
||||
"required": true
|
||||
"exportName": "createBlogPostTool",
|
||||
"description": "Creates structured blog posts with frontmatter and SEO metadata",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"description": "The title of the blog post",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "content",
|
||||
"type": "string",
|
||||
"description": "The main content",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "BlogPost",
|
||||
"description": "Structured blog post with frontmatter"
|
||||
}
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "BlogPost",
|
||||
"description": "Structured blog post with frontmatter"
|
||||
},
|
||||
"frameworks": ["vercel-ai", "langchain"]
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -163,7 +163,13 @@ export default function SpecPage(): React.ReactElement {
|
|||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
|
@ -237,26 +243,32 @@ export default function SpecPage(): React.ReactElement {
|
|||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Analyzes sentiment in text",
|
||||
"parameters": [
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code (e.g., 'en', 'es')",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Analyzes sentiment in text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code (e.g., 'en', 'es')",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object with score (-1 to 1) and label (positive/negative/neutral)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object with score (-1 to 1) and label (positive/negative/neutral)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
|
@ -361,9 +373,7 @@ export default function SpecPage(): React.ReactElement {
|
|||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Advanced sentiment analysis with emotion detection",
|
||||
"parameters": [...],
|
||||
"returns": {...},
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"env": [
|
||||
{
|
||||
"name": "SENTIMENT_API_KEY",
|
||||
|
|
@ -371,15 +381,22 @@ export default function SpecPage(): React.ReactElement {
|
|||
"required": true
|
||||
}
|
||||
],
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"aiAgent": {
|
||||
"useCase": "Use when users need to analyze sentiment or detect emotions in text",
|
||||
"limitations": "English and Spanish only. Max 10,000 characters per request.",
|
||||
"examples": [
|
||||
"Analyze customer review sentiment",
|
||||
"Detect emotions in user feedback"
|
||||
]
|
||||
}
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Advanced sentiment analysis with emotion detection",
|
||||
"parameters": [...],
|
||||
"returns": {...},
|
||||
"aiAgent": {
|
||||
"useCase": "Use when users need to analyze sentiment or detect emotions in text",
|
||||
"limitations": "English and Spanish only. Max 10,000 characters per request.",
|
||||
"examples": [
|
||||
"Analyze customer review sentiment",
|
||||
"Detect emotions in user feedback"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -23,15 +23,17 @@ import { AppHeader } from '~/components/AppHeader';
|
|||
|
||||
interface Tool {
|
||||
id: string;
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
exportName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
qualityScore: string;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
category: string;
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,12 +45,10 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
|
||||
// Fetch tools from API
|
||||
useEffect(() => {
|
||||
|
|
@ -77,21 +77,16 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
setTools(fetchedTools);
|
||||
setError(null);
|
||||
|
||||
// Extract unique categories and tags from all tools
|
||||
// Extract unique categories from all tools
|
||||
const categories = new Set<string>();
|
||||
const tags = new Set<string>();
|
||||
|
||||
for (const tool of fetchedTools) {
|
||||
if (tool.category) {
|
||||
categories.add(tool.category);
|
||||
}
|
||||
for (const tag of tool.tags) {
|
||||
tags.add(tag);
|
||||
if (tool.package.category) {
|
||||
categories.add(tool.package.category);
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableCategories(Array.from(categories).sort());
|
||||
setAvailableTags(Array.from(tags).sort());
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch tools');
|
||||
}
|
||||
|
|
@ -105,12 +100,6 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
fetchTools();
|
||||
}, [searchQuery, activeTab, categoryFilter]);
|
||||
|
||||
// Filter tools by selected tags (client-side)
|
||||
const displayedTools =
|
||||
selectedTags.length > 0
|
||||
? tools.filter((tool) => selectedTags.some((tag) => tool.tags.includes(tag)))
|
||||
: tools;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
|
@ -154,43 +143,18 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</div>
|
||||
|
||||
{/* Clear filters button */}
|
||||
{(categoryFilter !== 'all' || selectedTags.length > 0) && (
|
||||
{categoryFilter !== 'all' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCategoryFilter('all');
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Popular tags */}
|
||||
{availableTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm font-medium text-foreground-secondary mr-2">
|
||||
Filter by tag:
|
||||
</span>
|
||||
{availableTags.slice(0, 10).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="cursor-pointer hover:bg-foreground/10 transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -200,7 +164,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
{
|
||||
id: 'featured',
|
||||
label: 'Official',
|
||||
count: tools.filter((t) => t.isOfficial).length,
|
||||
count: tools.filter((t) => t.package.isOfficial).length,
|
||||
},
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
|
|
@ -220,15 +184,22 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
{/* Tool grid */}
|
||||
{!loading && !error && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayedTools.length > 0 ? (
|
||||
displayedTools.map((tool) => (
|
||||
{tools.length > 0 ? (
|
||||
tools.map((tool) => (
|
||||
<Card key={tool.id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle>{tool.npmPackageName}</CardTitle>
|
||||
{tool.npmRepository && (
|
||||
<div className="flex-1">
|
||||
<CardTitle>
|
||||
{tool.exportName !== 'default' ? tool.exportName : tool.package.npmPackageName}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-foreground-secondary mt-1">
|
||||
{tool.package.npmPackageName}
|
||||
</div>
|
||||
</div>
|
||||
{tool.package.npmRepository && (
|
||||
<a
|
||||
href={tool.npmRepository.url.replace('git+', '').replace('.git', '')}
|
||||
href={tool.package.npmRepository.url.replace('git+', '').replace('.git', '')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
|
|
@ -244,33 +215,22 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
{/* Category badge and version */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{tool.category}
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">v{tool.npmVersion}</span>
|
||||
{tool.isOfficial && (
|
||||
<span className="text-xs text-foreground-tertiary">v{tool.package.npmVersion}</span>
|
||||
{tool.package.isOfficial && (
|
||||
<Badge variant="default" size="sm">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{tool.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.tags.slice(0, 5).map((tag) => (
|
||||
<Badge key={tag} variant="outline" size="sm">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality score and downloads */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground-secondary">Quality Score</span>
|
||||
<span className="text-foreground-tertiary">
|
||||
{tool.npmDownloadsLastMonth.toLocaleString()} downloads/mo
|
||||
{tool.package.npmDownloadsLastMonth.toLocaleString()} downloads/mo
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
|
|
@ -289,7 +249,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
|
||||
{/* Install command */}
|
||||
<CodeBlock
|
||||
code={`npm install ${tool.npmPackageName}`}
|
||||
code={`npm install ${tool.package.npmPackageName}`}
|
||||
language="bash"
|
||||
size="sm"
|
||||
showCopy={true}
|
||||
|
|
@ -297,7 +257,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Link href={`/tool/${tool.npmPackageName}`}>
|
||||
<Link href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
View Details
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@
|
|||
*/
|
||||
|
||||
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
|
||||
import type { Tool } from '@tpmjs/db';
|
||||
import type { Package, Tool } from '@tpmjs/db';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { TokenBreakdown } from './TokenBreakdown';
|
||||
|
||||
interface ToolPlaygroundProps {
|
||||
tool: Tool;
|
||||
tool: Tool & { package: Package };
|
||||
}
|
||||
|
||||
type Tab = 'input' | 'output' | 'logs' | 'tokens';
|
||||
|
|
@ -47,7 +47,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
|
|||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tools/execute/${encodeURIComponent(tool.npmPackageName)}`,
|
||||
`/api/tools/execute/${encodeURIComponent(tool.package.npmPackageName)}/${encodeURIComponent(tool.exportName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -194,7 +194,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
|
|||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Interactive Playground</h2>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
Test {tool.npmPackageName} with AI-powered execution
|
||||
Test {tool.package.npmPackageName} ({tool.exportName}) with AI-powered execution
|
||||
</p>
|
||||
</div>
|
||||
{rateLimitInfo && (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import type { Tool } from '@tpmjs/db';
|
||||
import type { Package, Tool } from '@tpmjs/db';
|
||||
import { executePackage } from '@tpmjs/package-executor';
|
||||
import { type CoreMessage, generateText } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -90,13 +90,14 @@ export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObjec
|
|||
|
||||
/**
|
||||
* Create AI SDK v6 tool definition from TPMJS Tool
|
||||
* Requires Tool with Package relation
|
||||
*/
|
||||
export function createToolDefinition(tool: Tool) {
|
||||
export function createToolDefinition(tool: Tool & { package: Package }) {
|
||||
const parameters = Array.isArray(tool.parameters)
|
||||
? (tool.parameters as unknown as TPMJSParameter[])
|
||||
: [];
|
||||
|
||||
console.log('[createToolDefinition] Tool:', tool.npmPackageName);
|
||||
console.log('[createToolDefinition] Tool:', tool.package.npmPackageName, '/', tool.exportName);
|
||||
console.log('[createToolDefinition] Parameters array:', JSON.stringify(parameters));
|
||||
console.log('[createToolDefinition] Parameters length:', parameters.length);
|
||||
|
||||
|
|
@ -108,7 +109,9 @@ export function createToolDefinition(tool: Tool) {
|
|||
|
||||
console.log('[createToolDefinition] Created Zod schema:', inputSchema);
|
||||
|
||||
const sanitizedName = sanitizeToolName(tool.npmPackageName);
|
||||
const sanitizedName = sanitizeToolName(
|
||||
`${tool.package.npmPackageName}-${tool.exportName}`
|
||||
);
|
||||
|
||||
// AI SDK v6 tool definition
|
||||
return {
|
||||
|
|
@ -118,9 +121,10 @@ export function createToolDefinition(tool: Tool) {
|
|||
console.log('[Tool execute] Running:', sanitizedName, params);
|
||||
|
||||
// Execute the actual npm package in a sandbox
|
||||
// Use the actual export name from the Tool record
|
||||
const result = await executePackage(
|
||||
tool.npmPackageName,
|
||||
'default', // Most TPMJS packages export a default function
|
||||
tool.package.npmPackageName,
|
||||
tool.exportName, // Use actual export name (e.g., "helloWorldTool", "default")
|
||||
params,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
|
@ -186,15 +190,18 @@ function sanitizeToolName(npmPackageName: string): string {
|
|||
|
||||
/**
|
||||
* Execute tool with AI agent using AI SDK v6
|
||||
* Requires Tool with Package relation
|
||||
*/
|
||||
export async function executeToolWithAgent(
|
||||
tool: Tool,
|
||||
tool: Tool & { package: Package },
|
||||
userPrompt: string,
|
||||
onChunk?: (chunk: string) => void,
|
||||
onTokenUpdate?: (tokens: Partial<TokenBreakdown>) => void
|
||||
) {
|
||||
const toolDef = createToolDefinition(tool);
|
||||
const sanitizedToolName = sanitizeToolName(tool.npmPackageName);
|
||||
const sanitizedToolName = sanitizeToolName(
|
||||
`${tool.package.npmPackageName}-${tool.exportName}`
|
||||
);
|
||||
|
||||
console.log('[executeToolWithAgent] Tool name:', sanitizedToolName);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue